diff --git a/public/locales/en.json b/public/locales/en.json index ccea36bc..dd5c315b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -7,7 +7,10 @@ }, "Entities": { "ManagedControlPlane": "Managed Control Plane", - "Project": "Project" + "Project": "Project", + "Workspace": "Workspace", + "Users": "Users", + "ServiceAccounts": "ServiceAccounts" }, "ComponentList": { "tableComponentHeader": "Name", @@ -142,7 +145,7 @@ "subtitle": "Fetching data..." }, "MemberTable": { - "columnEmailHeader": "Email", + "columnNameHeader": "Name", "columnRoleHeader": "Role", "columnTypeHeader": "Type", "columnNamespaceHeader": "Namespace" @@ -165,12 +168,18 @@ "membersHeader": "Members" }, "EditMembers": { - "addButton": "Add new member or service account", - "editHeader": "Edit member or service account", - "addHeader": "Add new member or service account", + "addButton": "Add User or ServiceAccount", + "editHeader": "Edit User or ServiceAccount", + "addHeader": "Add User or ServiceAccount", "saveButton": "Save changes", "defaultNamespaceInfo": "Leave empty to use default namespace", - "serviceAccoutsGuide": "You can also use our Service Account Guide for more information." + "serviceAccoutsGuide": "You can also use our Service Account Guide for more information.", + "reuseMembersButton": "Reuse", + "membersToastNoChanges": "No changes.", + "membersToastAdded1": "1 member added.", + "membersToastAddedN": "{{count}} members added.", + "membersToastChanged1": "1 member changed.", + "membersToastChangedN": "{{count}} members changed." }, "ProjectsPage": { @@ -315,6 +324,7 @@ "notValidChargingTargetFormat": "Use lowercase letters a-f, numbers 0-9, and hyphens (-) in the format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, "common": { + "all": "All", "documentation": "Documentation", "close": "Close", "cannotLoadData": "Cannot load data", @@ -421,4 +431,13 @@ "activate": "Activate" } } + , + "ImportMembersDialog": { + "dialogTitle": "Reuse Members", + "reuseFromLabel": "Reuse from", + "filterForLabel": "Filter for", + "addMembersButton0": "Add members", + "addMembersButton1": "Add member", + "addMembersButtonN": "Add {{count}} members" + } } diff --git a/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx b/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx index 4ab8abd1..f07d8cbf 100644 --- a/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx +++ b/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx @@ -35,7 +35,7 @@ export interface CreateProjectWorkspaceDialogProps { errors: FieldErrors; setValue: UseFormSetValue; projectName?: string; - type: 'workspace' | 'project'; + type: 'workspace' | 'project' | 'mcp'; watch: UseFormWatch; } @@ -93,7 +93,13 @@ export function CreateProjectWorkspaceDialog({ requireChargingTarget={type === 'project'} sideFormContent={ - + } /> diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index 4d1eeb3d..1dea17b9 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -1,36 +1,50 @@ -import { FC, useCallback, useState } from 'react'; +import { FC, useCallback, useMemo, useState } from 'react'; import { Button, FlexBox } from '@ui5/webcomponents-react'; import { MemberTable } from './MemberTable.tsx'; -import { Member } from '../../lib/api/types/shared/members'; +import { areMembersEqual, 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'; +import { ImportMembersDialog } from './ImportMembersDialog.tsx'; +import { useToast } from '../../context/ToastContext.tsx'; +import { TFunction } from 'i18next'; export interface EditMembersProps { members: Member[]; onMemberChanged: (members: Member[]) => void; isValidationError?: boolean; requireAtLeastOneMember?: boolean; + projectName?: string; + workspaceName?: string; + type: 'workspace' | 'project' | 'mcp'; } export const ACCOUNT_TYPES: RadioButtonsSelectOption[] = [ - { value: 'User', label: 'User Account', icon: 'employee' }, + { value: 'User', label: 'User', icon: 'employee' }, { value: 'ServiceAccount', label: 'Service Account', icon: 'machine' }, ]; export type AccountType = 'User' | 'ServiceAccount'; +const PROJECT_PREFIX = 'project-'; +const removeProjectPrefix = (name?: string) => + name?.startsWith(PROJECT_PREFIX) ? name.slice(PROJECT_PREFIX.length) : name; + export const EditMembers: FC = ({ members, onMemberChanged, isValidationError = false, requireAtLeastOneMember = true, + workspaceName, + projectName, + type, }) => { const { t } = useTranslation(); const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); const [memberToEdit, setMemberToEdit] = useState(undefined); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const handleRemoveMember = useCallback( (email: string) => { @@ -53,6 +67,39 @@ export const EditMembers: FC = ({ setIsMemberDialogOpen(false); }, []); + const handleOpenImportDialog = useCallback(() => { + setIsImportDialogOpen(true); + }, []); + + const handleCloseImportDialog = useCallback(() => { + setIsImportDialogOpen(false); + }, []); + + const toast = useToast(); + + const handleImportMembers = useCallback( + (imported: Member[]) => { + let numberOfAddedMembers = 0; + let numberOfChangedMembers = 0; + + const membersByName = new Map(members.map((member) => [member.name, member])); + imported.forEach((importedMember) => { + const existingMember = membersByName.get(importedMember.name); + if (!existingMember) { + numberOfAddedMembers++; + } else if (!areMembersEqual(importedMember, existingMember)) { + numberOfChangedMembers++; + } + membersByName.set(importedMember.name, importedMember); + }); + const updatedMembers = Array.from(membersByName.values()); + + toast.show(buildToastMessage(numberOfAddedMembers, numberOfChangedMembers, t)); + onMemberChanged(updatedMembers); + }, + [members, onMemberChanged, t, toast], + ); + const handleSaveMember = useCallback( (member: Member, isEdit: boolean) => { let updatedMembers: Member[]; @@ -74,17 +121,34 @@ export const EditMembers: FC = ({ [members, onMemberChanged, memberToEdit], ); + const computedProjectName = useMemo( + () => (type === 'mcp' ? removeProjectPrefix(projectName) : projectName), + [type, projectName], + ); + return ( - - {t('EditMembers.addButton')} - + + + {t('EditMembers.addButton')} + + {type !== 'project' && ( + + {t('EditMembers.reuseMembersButton')} + + )} + = ({ onSave={handleSaveMember} /> + {computedProjectName && ( + + )} + = ({ ); }; + +function buildToastMessage(addedCount: number, changedCount: number, t: TFunction) { + const messages: string[] = []; + + if (addedCount === 0 && changedCount === 0) { + return t('EditMembers.membersToastNoChanges'); + } + + if (addedCount > 0) { + messages.push( + addedCount === 1 + ? t('EditMembers.membersToastAdded1') + : t('EditMembers.membersToastAddedN', { count: addedCount }), + ); + } + + if (changedCount > 0) { + messages.push( + changedCount === 1 + ? t('EditMembers.membersToastChanged1') + : t('EditMembers.membersToastChangedN', { count: changedCount }), + ); + } + + return messages.join(' '); +} diff --git a/src/components/Members/ImportMembersDialog.module.css b/src/components/Members/ImportMembersDialog.module.css new file mode 100644 index 00000000..0a33bd83 --- /dev/null +++ b/src/components/Members/ImportMembersDialog.module.css @@ -0,0 +1,18 @@ +.dialog { + min-width: 650px; +} + +.grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + padding: 1rem 1rem 2rem; +} + +.gridColumnLabel { + align-self: center; +} + +.tableContainer { + padding: 1rem; +} diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx new file mode 100644 index 00000000..0128976c --- /dev/null +++ b/src/components/Members/ImportMembersDialog.tsx @@ -0,0 +1,227 @@ +import { FC, useMemo, useState, useEffect } from 'react'; +import styles from './ImportMembersDialog.module.css'; +import { + Button, + Dialog, + FlexBox, + Label, + Option, + Select, + AnalyticalTable, + Icon, + AnalyticalTablePropTypes, + SegmentedButton, + SegmentedButtonItem, + Bar, +} from '@ui5/webcomponents-react'; + +import { AnalyticalTableColumnDefinition } from '@ui5/webcomponents-react/wrappers'; +import { Member, MemberRoles, MemberRolesDetailed } from '../../lib/api/types/shared/members'; +import { ACCOUNT_TYPES } from './EditMembers.tsx'; + +import { ResourceObject } from '../../lib/api/types/crate/resourceObject.ts'; +import { useApiResource } from '../../lib/api/useApiResource.ts'; +import { useTranslation } from 'react-i18next'; +import IllustratedError from '../Shared/IllustratedError.tsx'; +import { TFunction } from 'i18next'; + +type FilteredFor = 'All' | 'Users' | 'ServiceAccounts'; +type SourceType = 'Workspace' | 'Project'; +type TableRow = { + email: string; + role: string; + kind: string; + _member: Member; +}; + +export interface ImportMembersDialogProps { + isOpen: boolean; + onClose: () => void; + onImport: (members: Member[]) => void; + projectName: string; + workspaceName?: string; +} +export const ImportMembersDialog: FC = ({ + isOpen, + projectName, + workspaceName, + onClose, + onImport, +}) => { + const [filteredFor, setFilteredFor] = useState('All'); + const [sourceType, setSourceType] = useState('Project'); + const [selectedRowIds, setSelectedRowIds] = useState({}); + + const { t } = useTranslation(); + + const { + isLoading, + data: parentResourceData, + error, + } = useApiResource( + sourceType === 'Project' + ? ResourceObject('', 'projects', projectName) + : ResourceObject(`project-${projectName}`, 'workspaces', workspaceName ?? ''), + undefined, + true, + !isOpen, + ); + + const selectedMembersCount = Object.keys(selectedRowIds ?? {}).length; + + const tableData: TableRow[] = useMemo(() => { + const members = parentResourceData?.spec?.members ?? []; + const showUsers = filteredFor !== 'ServiceAccounts'; + const showServiceAccounts = filteredFor !== 'Users'; + + return members + .filter(({ kind }) => (kind === 'User' && showUsers) || (kind === 'ServiceAccount' && showServiceAccounts)) + .map((m) => ({ + email: m.name, + role: MemberRolesDetailed[m.roles?.[0] as MemberRoles]?.displayValue, + kind: m.kind, + _member: m, + })); + }, [parentResourceData, filteredFor]); + + const columns: AnalyticalTableColumnDefinition[] = useMemo( + () => [ + { Header: t('MemberTable.columnNameHeader'), accessor: 'email' }, + { + Header: t('MemberTable.columnTypeHeader'), + accessor: 'kind', + width: 145, + Cell: (instance: { cell: { row: { original: TableRow } } }) => { + const kind = ACCOUNT_TYPES.find(({ value }) => value === instance.cell.row.original.kind); + return ( + + + {kind?.label} + + ); + }, + }, + { Header: t('MemberTable.columnRoleHeader'), accessor: 'role', width: 120 }, + ], + [t], + ); + + useEffect(() => { + setSelectedRowIds({}); + }, [isOpen]); + + const handleAddMembers = () => { + const selectedMembers = Object.entries(selectedRowIds ?? {}) + .filter(([, isSelected]) => isSelected) + .map(([idx]) => tableData[Number(idx)]._member); + + onImport(selectedMembers); + onClose(); + }; + + return ( + + + {t('buttons.cancel')} + + + {getAddMembersButtonText(selectedMembersCount, t)} + + > + } + /> + } + onClose={onClose} + > + {error ? ( + + ) : ( + <> + + + {t('ImportMembersDialog.reuseFromLabel')} + + + { + setSourceType(e.detail.selectedOption?.value as SourceType); + setSelectedRowIds({}); + }} + > + + {projectName} + + {!!workspaceName && ( + + {workspaceName} + + )} + + + + {t('ImportMembersDialog.filterForLabel')} + + + { + setFilteredFor(e.detail.selectedItems[0].dataset.id as FilteredFor); + setSelectedRowIds({}); + }} + > + + {t('common.all')} + + + {t('Entities.Users')} + + + {t('Entities.ServiceAccounts')} + + + + + + + { + setSelectedRowIds(e?.detail.selectedRowIds); + }} + /> + + > + )} + + ); +}; + +function getAddMembersButtonText(selectedMembersCount: number, t: TFunction) { + switch (selectedMembersCount) { + case 0: + return t('ImportMembersDialog.addMembersButton0'); + case 1: + return t('ImportMembersDialog.addMembersButton1'); + default: + return t('ImportMembersDialog.addMembersButtonN', { count: selectedMembersCount }); + } +} + +interface SpecMembers { + spec?: { members: { name: string; roles: string[]; kind: 'User' | 'ServiceAccount'; namespace?: string }[] }; +} diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index 35704ed0..d77131f6 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -41,7 +41,7 @@ export const MemberTable: FC = ({ const columns: AnalyticalTableColumnDefinition[] = [ { - Header: t('MemberTable.columnEmailHeader'), + Header: t('MemberTable.columnNameHeader'), accessor: 'email', }, @@ -70,36 +70,30 @@ export const MemberTable: FC = ({ }, ]; - if (onEditMember) { + if (onEditMember && onDeleteMember) { columns.push({ Header: '', id: 'edit', - width: 50, + width: 100, Cell: (instance: CellInstance) => ( - { - const selectedMember = instance.cell.row.original._member; - onEditMember(selectedMember); - }} - /> - ), - }); - } - - if (onDeleteMember) { - columns.push({ - Header: '', - id: 'delete', - width: 50, - Cell: (instance: CellInstance) => ( - { - const selectedMemberEmail = instance.cell.row.original.email; - onDeleteMember(selectedMemberEmail); - }} - /> + + { + const selectedMember = instance.cell.row.original._member; + onEditMember(selectedMember); + }} + /> + { + const selectedMemberEmail = instance.cell.row.original.email; + onDeleteMember(selectedMemberEmail); + }} + /> + ), }); } diff --git a/src/components/Members/Members.module.css b/src/components/Members/Members.module.css index 9f98d921..f8511179 100644 --- a/src/components/Members/Members.module.css +++ b/src/components/Members/Members.module.css @@ -1,5 +1,6 @@ .addButton { margin-bottom: 3px; + width: 100%; } .wrapper { @@ -25,3 +26,7 @@ max-width: 100%; text-wrap: wrap; } + +.narrowButton { + min-width: fit-content; +} diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index 17707c24..e6b63c42 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -281,6 +281,9 @@ export const CreateManagedControlPlaneWizardContainer: FC diff --git a/src/lib/api/types/crate/resourceObject.ts b/src/lib/api/types/crate/resourceObject.ts index 70aa75f5..be6c1397 100644 --- a/src/lib/api/types/crate/resourceObject.ts +++ b/src/lib/api/types/crate/resourceObject.ts @@ -1,10 +1,6 @@ import { Resource } from '../resource.ts'; -export const ResourceObject = ( - workspaceName: string, - resourceType: string, - resourceName: string, -): Resource => { +export const ResourceObject = (workspaceName: string, resourceType: string, resourceName: string): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/${workspaceName ? `namespaces/${workspaceName}/` : ''}${resourceType}/${resourceName}`, }; diff --git a/src/lib/api/types/shared/members.spec.ts b/src/lib/api/types/shared/members.spec.ts new file mode 100644 index 00000000..90768c47 --- /dev/null +++ b/src/lib/api/types/shared/members.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { areMembersEqual, Member } from './members.ts'; + +const makeMember = (overrides: Partial = {}): Member => ({ + kind: 'User', + name: 'alice', + namespace: 'default-namespace', + roles: ['Viewer', 'Admin'], + ...overrides, +}); + +describe('members', () => { + describe('areMembersEqual', () => { + it('returns true when a and b are the same reference', () => { + const a = makeMember(); + expect(areMembersEqual(a, a)).toBe(true); + }); + + it('returns true when the members have the very same attributes', () => { + const a = makeMember(); + const b = makeMember(); + expect(areMembersEqual(a, b)).toBe(true); + }); + + it('returns true even if the roles are sorted differently', () => { + const a = makeMember({ roles: ['Viewer', 'Admin'] }); + const b = makeMember({ roles: ['Admin', 'Viewer'] }); + expect(areMembersEqual(a, b)).toBe(true); + }); + + it('handles empty roles correctly', () => { + const a = makeMember({ roles: [] }); + const b = makeMember({ roles: [] }); + expect(areMembersEqual(a, b)).toBe(true); + }); + + it('returns false when b is undefined', () => { + const a = makeMember(); + expect(areMembersEqual(a, undefined)).toBe(false); + }); + + it('returns false when kinds differ', () => { + const a = makeMember({ kind: 'User' }); + const b = makeMember({ kind: 'ServiceAccount' }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('returns false when names differ', () => { + const a = makeMember({ name: 'alice' }); + const b = makeMember({ name: 'bob' }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('returns false when namespaces differ', () => { + const a = makeMember({ namespace: 'namespace-a' }); + const b = makeMember({ namespace: 'namespace-b' }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('returns false when role counts differ', () => { + const a = makeMember({ roles: ['Viewer', 'Admin'] }); + const b = makeMember({ roles: ['Viewer'] }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('returns false when roles differ (same length)', () => { + const a = makeMember({ roles: ['Viewer', 'Admin'] }); + const b = makeMember({ roles: ['Viewer', 'OtherRole'] }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('does not treat duplicate roles in a special way (debatable)', () => { + const a = makeMember({ roles: ['Viewer', 'Admin'] }); + const b = makeMember({ roles: ['Viewer', 'Viewer', 'Admin'] }); + expect(areMembersEqual(a, b)).toBe(false); + }); + + it('fails when b has a role a does not have', () => { + const a = makeMember({ roles: ['Viewer'] }); + const b = makeMember({ roles: ['Viewer', 'Admin'] }); + expect(areMembersEqual(a, b)).toBe(false); + }); + }); +}); diff --git a/src/lib/api/types/shared/members.ts b/src/lib/api/types/shared/members.ts index bead99f7..14eeac90 100644 --- a/src/lib/api/types/shared/members.ts +++ b/src/lib/api/types/shared/members.ts @@ -29,6 +29,17 @@ export interface Member { namespace?: string; } +export function areMembersEqual(a: Member, b?: Member): boolean { + return ( + !!b && + a.kind === b.kind && + a.name === b.name && + a.namespace === b.namespace && + a.roles.length === b.roles.length && + a.roles.every((r) => b.roles.includes(r)) + ); +} + export interface MemberPayload { kind: string; name: string; diff --git a/src/lib/api/useApiResource.ts b/src/lib/api/useApiResource.ts index 42f304ef..3563ebed 100644 --- a/src/lib/api/useApiResource.ts +++ b/src/lib/api/useApiResource.ts @@ -10,13 +10,16 @@ import { MutatorOptions } from 'swr/_internal'; import { CRDRequest, CRDResponse } from './types/crossplane/CRDList'; import { ProviderConfigs, ProviderConfigsData, ProviderConfigsDataForRequest } from '../shared/types'; -export const useApiResource = (resource: Resource, config?: SWRConfiguration, excludeMcpConfig?: boolean) => { +export const useApiResource = ( + resource: Resource, + config?: SWRConfiguration, + excludeMcpConfig?: boolean, + disable?: boolean, +) => { const apiConfig = useContext(ApiConfigContext); const { data, error, isLoading, isValidating } = useSWR( - resource.path === null - ? null //TODO: is null a valid key? - : [resource.path, apiConfig], + disable || resource.path === null ? null : [resource.path, apiConfig], ([path, apiConfig]) => fetchApiServerJson( path,