From 23c23d7d0667030215bda30d2b476ca115e5150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Tue, 26 Aug 2025 12:22:15 +0200 Subject: [PATCH 01/26] init --- src/components/Members/EditMembers.tsx | 21 +++ .../Members/ImportMembersDialog.tsx | 134 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/components/Members/ImportMembersDialog.tsx diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index 4d1eeb3d..292c6ea9 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -6,6 +6,7 @@ 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'; export interface EditMembersProps { members: Member[]; @@ -31,6 +32,7 @@ export const EditMembers: FC = ({ const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); const [memberToEdit, setMemberToEdit] = useState(undefined); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const handleRemoveMember = useCallback( (email: string) => { @@ -53,6 +55,14 @@ export const EditMembers: FC = ({ setIsMemberDialogOpen(false); }, []); + const handleOpenImportDialog = useCallback(() => { + setIsImportDialogOpen(true); + }, []); + + const handleCloseImportDialog = useCallback(() => { + setIsImportDialogOpen(false); + }, []); + const handleSaveMember = useCallback( (member: Member, isEdit: boolean) => { let updatedMembers: Member[]; @@ -85,6 +95,15 @@ export const EditMembers: FC = ({ > {t('EditMembers.addButton')} + = ({ onSave={handleSaveMember} /> + + void; +}; + +export const ImportMembersDialog: FC = ({ open, onClose }) => { + const [step, setStep] = useState(1); + + const formSchema = useMemo( + () => + z + .object({ + parentType: z.union([z.literal('Workspace'), z.literal('Project')]), + importMembers: z.boolean(), + importServiceAccounts: z.boolean(), + }) + .refine((data) => data.importMembers || data.importServiceAccounts, { + path: ['importMembers'], + message: 'Select at least one option', + }), + [], + ); + + const { handleSubmit, setValue, watch, reset } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + parentType: '', + importMembers: false, + importServiceAccounts: false, + }, + }); + + const parentType = watch('parentType'); + const importMembers = watch('importMembers'); + const importServiceAccounts = watch('importServiceAccounts'); + const canProceed = parentType !== '' && (importMembers || importServiceAccounts); + + const handleDialogAfterClose = () => { + reset(); + setStep(1); + }; + + const onSubmitStepOne = () => { + setStep(2); + }; + + return ( + + {step === 1 && ( + + + + + + + + setValue('importMembers', e.detail.checked, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + /> + + setValue('importServiceAccounts', e.detail.checked, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + /> + + + + + + + + )} + + {step === 2 && ( + + Step 1 + + + + + + + )} + + ); +}; From 0c01a1a4ceaf0876da9bb491f0d734135ffe3ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 27 Aug 2025 10:41:25 +0200 Subject: [PATCH 02/26] Update ImportMembersDialog.tsx --- .../Members/ImportMembersDialog.tsx | 146 ++++++++++++++---- 1 file changed, 118 insertions(+), 28 deletions(-) diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index 536e1483..0ab2058a 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -1,8 +1,22 @@ import { FC, useMemo, useState } from 'react'; -import { Button, CheckBox, Dialog, FlexBox, Label, Option, Select, Title } from '@ui5/webcomponents-react'; +import { + Button, + CheckBox, + Dialog, + FlexBox, + Label, + Option, + Select, + Ui5CustomEvent, + CheckBoxDomRef, +} from '@ui5/webcomponents-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; +import { AnalyticalTable, Icon } 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'; type ParentType = 'Workspace' | 'Project'; @@ -24,7 +38,7 @@ export const ImportMembersDialog: FC = ({ open, onClos () => z .object({ - parentType: z.union([z.literal('Workspace'), z.literal('Project')]), + parentType: z.union([z.literal('Workspace'), z.literal('Project'), z.literal('')]), importMembers: z.boolean(), importServiceAccounts: z.boolean(), }) @@ -35,11 +49,11 @@ export const ImportMembersDialog: FC = ({ open, onClos [], ); - const { handleSubmit, setValue, watch, reset } = useForm({ + const { handleSubmit, setValue, watch } = useForm({ resolver: zodResolver(formSchema), mode: 'onChange', defaultValues: { - parentType: '', + parentType: 'Project', importMembers: false, importServiceAccounts: false, }, @@ -49,21 +63,15 @@ export const ImportMembersDialog: FC = ({ open, onClos const importMembers = watch('importMembers'); const importServiceAccounts = watch('importServiceAccounts'); const canProceed = parentType !== '' && (importMembers || importServiceAccounts); - - const handleDialogAfterClose = () => { - reset(); - setStep(1); - }; - const onSubmitStepOne = () => { setStep(2); }; - + console.log(parentType, importMembers, importServiceAccounts); return ( {step === 1 && ( @@ -71,8 +79,9 @@ export const ImportMembersDialog: FC = ({ open, onClos @@ -130,11 +145,13 @@ export const ImportMembersDialog: FC = ({ open, onClos {step === 2 && ( )} @@ -148,19 +165,9 @@ type SelectionRow = { _member: Member; }; -const getMockedProjectMembers = (): Member[] => [ - { name: 'p.project@example.com', role: MemberRoles.view, kind: 'User' }, - { name: 'pp.project@example.com', role: MemberRoles.admin, kind: 'User' }, - { name: 'sa-project-reader', role: MemberRoles.view, kind: 'ServiceAccount', namespace: 'project-default' }, - { name: 'sa-project-admin', role: MemberRoles.admin, kind: 'ServiceAccount', namespace: 'project-ops' }, -]; - -const getMockedWorkspaceMembers = (): Member[] => [ - { name: 'w.workspace@example.com', role: MemberRoles.admin, kind: 'User' }, - { name: 'ww.workspace@example.com', role: MemberRoles.view, kind: 'User' }, - { name: 'sa-ws-view', role: MemberRoles.view, kind: 'ServiceAccount', namespace: 'workspace-default' }, - { name: 'sa-ws-admin', role: MemberRoles.admin, kind: 'ServiceAccount', namespace: 'workspace-ops' }, -]; +interface SpecMembers { + spec?: { members: { name: string; roles: string[]; kind: 'User' | 'ServiceAccount'; namespace?: string }[] }; +} const ImportMembersSelectionTable: FC<{ onCancel: () => void; @@ -168,15 +175,37 @@ const ImportMembersSelectionTable: FC<{ parentType: ParentType; includeMembers: boolean; includeServiceAccounts: boolean; -}> = ({ onCancel, onImport, parentType, includeMembers, includeServiceAccounts }) => { - const mockedMembers: Member[] = parentType === 'Project' ? getMockedProjectMembers() : getMockedWorkspaceMembers(); + workspaceName?: string; + projectName?: string; +}> = ({ onCancel, onImport, parentType, workspaceName, projectName, includeMembers, includeServiceAccounts }) => { + const { + isLoading, + data: parentResourceData, + error, + } = useApiResource( + parentType === 'Project' + ? ResourceObject('', 'projects', projectName ?? '') + : ResourceObject(workspaceName ?? '', 'projects', projectName ?? ''), + undefined, + true, + ); + const [selectedEmails, setSelectedEmails] = useState>(new Set()); + if (isLoading) { + return ; + } + console.log(parentResourceData?.spec?.members); + const membersData = parentResourceData?.spec?.members ?? []; + const mockedMembers: Member[] = membersData.map(({ name, namespace, kind, roles }) => ({ + kind, + name, + role: roles.includes('admin') ? 'admin' : 'view', + namespace, + })); const filteredMockedMembers: Member[] = mockedMembers.filter( (m) => (m.kind === 'User' && includeMembers) || (m.kind === 'ServiceAccount' && includeServiceAccounts), ); - const [selectedEmails, setSelectedEmails] = useState>(new Set()); - const columns: AnalyticalTableColumnDefinition[] = [ { Header: '', @@ -242,7 +271,7 @@ const ImportMembersSelectionTable: FC<{ - diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index 8f952082..cedeab89 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -281,6 +281,8 @@ 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}`, }; From 590a1fbdaeadefcc005ded8e8be73f8e4ac09a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:04:48 +0200 Subject: [PATCH 09/26] fixes --- src/components/Dialogs/CreateProjectDialogContainer.tsx | 2 +- src/components/Dialogs/CreateWorkspaceDialogContainer.tsx | 2 +- src/components/Members/AddEditMemberDialog.tsx | 4 ++-- src/components/Members/MemberTable.tsx | 4 ++-- .../CreateManagedControlPlaneWizardContainer.tsx | 2 +- .../Wizards/CreateManagedControlPlane/SummarizeStep.tsx | 2 +- src/lib/api/types/crate/createManagedControlPlane.ts | 2 +- src/lib/api/types/crate/createProject.ts | 4 ++-- src/lib/api/types/crate/createWorkspace.ts | 4 ++-- src/lib/api/types/crate/listWorkspaces.ts | 2 +- src/lib/api/types/shared/members.ts | 2 +- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/Dialogs/CreateProjectDialogContainer.tsx b/src/components/Dialogs/CreateProjectDialogContainer.tsx index 8e4abed5..11eb95f4 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, role: MemberRoles.admin, kind: 'User' }]); + setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearForm(); diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index adfa1264..bca3b319 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, role: MemberRoles.admin, kind: 'User' }]); + setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearForm(); diff --git a/src/components/Members/AddEditMemberDialog.tsx b/src/components/Members/AddEditMemberDialog.tsx index 2d577f40..aff541ad 100644 --- a/src/components/Members/AddEditMemberDialog.tsx +++ b/src/components/Members/AddEditMemberDialog.tsx @@ -91,7 +91,7 @@ export const AddEditMemberDialog: FC = ({ if (memberToEdit) { reset({ name: memberToEdit.name, - role: memberToEdit.role || MemberRoles.view, + role: memberToEdit.roles?.[0] || MemberRoles.view, accountType: memberToEdit.kind === 'User' ? 'User' : 'ServiceAccount', namespace: memberToEdit?.namespace || '', }); @@ -111,7 +111,7 @@ export const AddEditMemberDialog: FC = ({ const newMember: Member = { name: trimmedName, - role: data.role, + roles: [data.role], kind: data.accountType, ...(data.accountType === 'ServiceAccount' && data.namespace && { namespace: data.namespace }), }; diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index 5513408d..69e7a2b9 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -111,11 +111,11 @@ export const MemberTable: FC = ({ ); } - + console.log(members); const data: MemberTableRow[] = members.map((m) => { return { email: m.name, - role: MemberRolesDetailed[m.role as MemberRoles]?.displayValue, + role: MemberRolesDetailed[m.roles?.[0] as MemberRoles]?.displayValue ?? m.roles?.toString(), kind: m.kind, namespace: m.namespace ?? '', _member: m, diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index 8f952082..17707c24 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -111,7 +111,7 @@ export const CreateManagedControlPlaneWizardContainer: FC { if (user?.email && isOpen) { - setValue('members', [{ name: user.email, role: MemberRoles.admin, kind: 'User' }]); + setValue('members', [{ name: user.email, roles: [MemberRoles.admin], kind: 'User' }]); } if (!isOpen) { clearFormFields(); diff --git a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx index 62a0686d..d9805f24 100644 --- a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx @@ -37,7 +37,7 @@ export const SummarizeStep: React.FC = ({ watch, projectName
{watch('members').map((member) => ( - + ))}
diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts index 13b1b6b6..b1a1dc06 100644 --- a/src/lib/api/types/crate/createManagedControlPlane.ts +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -135,7 +135,7 @@ export const CreateManagedControlPlane = ( authorization: { roleBindings: optional?.members?.map((member) => ({ - role: member.role, + role: member.roles?.[0], subjects: [ { kind: member.kind as AccountType, diff --git a/src/lib/api/types/crate/createProject.ts b/src/lib/api/types/crate/createProject.ts index 95bd00e5..eefeb309 100644 --- a/src/lib/api/types/crate/createProject.ts +++ b/src/lib/api/types/crate/createProject.ts @@ -45,10 +45,10 @@ export const CreateProject = ( }, spec: { members: - optional?.members?.map(({ kind, namespace, role, name }) => ({ + optional?.members?.map(({ kind, namespace, roles, name }) => ({ kind, name, - roles: [role], + roles, namespace: kind === 'ServiceAccount' ? (namespace ?? 'default') : undefined, })) ?? [], }, diff --git a/src/lib/api/types/crate/createWorkspace.ts b/src/lib/api/types/crate/createWorkspace.ts index 6f682b97..7b7c5fa0 100644 --- a/src/lib/api/types/crate/createWorkspace.ts +++ b/src/lib/api/types/crate/createWorkspace.ts @@ -48,10 +48,10 @@ export const CreateWorkspace = ( }, spec: { members: - optional?.members?.map(({ kind, namespace, role, name }) => ({ + optional?.members?.map(({ kind, namespace, roles, name }) => ({ kind, name, - roles: [role], + roles, namespace: kind === 'ServiceAccount' ? (namespace ?? 'default') : undefined, })) ?? [], }, diff --git a/src/lib/api/types/crate/listWorkspaces.ts b/src/lib/api/types/crate/listWorkspaces.ts index ffbbe59c..db45adce 100644 --- a/src/lib/api/types/crate/listWorkspaces.ts +++ b/src/lib/api/types/crate/listWorkspaces.ts @@ -22,6 +22,6 @@ export function isWorkspaceReady(workspace: ListWorkspacesType): boolean { export const ListWorkspaces = (projectName?: string): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}/workspaces`, - jq: '[.items[] | {metadata: .metadata | {name, namespace, annotations, deletionTimestamp}, status: .status, spec: .spec | {members: [.members[] | {name, roles}]}}]', + jq: '[.items[] | {metadata: .metadata | {name, namespace, annotations, deletionTimestamp}, status: .status, spec: .spec | {members: [.members[] | {name, roles, kind, namespace}]}}]', }; }; diff --git a/src/lib/api/types/shared/members.ts b/src/lib/api/types/shared/members.ts index f24b5c9d..bead99f7 100644 --- a/src/lib/api/types/shared/members.ts +++ b/src/lib/api/types/shared/members.ts @@ -25,7 +25,7 @@ export enum MemberKind { export interface Member { kind: string; name: string; - role: string; + roles: string[]; namespace?: string; } From 9b833dc52932621dfa96571979d4d01e15e3e171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:06:49 +0200 Subject: [PATCH 10/26] Update MemberTable.tsx --- src/components/Members/MemberTable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index 69e7a2b9..35704ed0 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -111,7 +111,6 @@ export const MemberTable: FC = ({ ); } - console.log(members); const data: MemberTableRow[] = members.map((m) => { return { email: m.name, From ba36f9479d92b7e30e593a0000fac0d3a3a271a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:26:42 +0200 Subject: [PATCH 11/26] Update ImportMembersDialog.tsx --- src/components/Members/ImportMembersDialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index 85a3bbb9..cdc7a3ae 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -22,6 +22,7 @@ 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'; type ParentType = 'Workspace' | 'Project'; @@ -47,7 +48,7 @@ export const ImportMembersDialog: FC = ({ onImport, }) => { const [step, setStep] = useState(1); - + const { t } = useTranslation(); const formSchema = useMemo( () => z @@ -198,7 +199,7 @@ const ImportMembersSelectionTable: FC<{ const mockedMembers: Member[] = membersData.map(({ name, namespace, kind, roles }) => ({ kind, name, - role: roles.includes('admin') ? 'admin' : 'view', + roles, namespace, })); @@ -252,7 +253,7 @@ const ImportMembersSelectionTable: FC<{ const data: SelectionRow[] = filteredMockedMembers.map((m) => ({ email: m.name, - role: MemberRolesDetailed[m.role as MemberRoles]?.displayValue, + role: MemberRolesDetailed[m.roles?.[0] as MemberRoles]?.displayValue, kind: m.kind, _member: m, })); From 74b192dbb1bb817948d60a4213d0f8839da49677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:49:24 +0200 Subject: [PATCH 12/26] refactor --- public/locales/en.json | 12 ++++- .../Dialogs/CreateProjectWorkspaceDialog.tsx | 3 +- src/components/Members/EditMembers.tsx | 22 +++++---- .../Members/ImportMembersDialog.module.css | 9 ++++ .../Members/ImportMembersDialog.tsx | 46 +++++++++---------- ...eateManagedControlPlaneWizardContainer.tsx | 1 + 6 files changed, 57 insertions(+), 36 deletions(-) create mode 100644 src/components/Members/ImportMembersDialog.module.css diff --git a/public/locales/en.json b/public/locales/en.json index ccea36bc..226d56e6 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -7,7 +7,8 @@ }, "Entities": { "ManagedControlPlane": "Managed Control Plane", - "Project": "Project" + "Project": "Project", + "Workspace": "Workspace" }, "ComponentList": { "tableComponentHeader": "Name", @@ -312,6 +313,7 @@ "maxChars": "Max length is {{maxLength}} characters.", "userExists": "User with this name already exists!", "atLeastOneUser": "You need to have at least one member assigned.", + "selectAtLeastOneOption": "Select at least one option", "notValidChargingTargetFormat": "Use lowercase letters a-f, numbers 0-9, and hyphens (-) in the format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, "common": { @@ -421,4 +423,12 @@ "activate": "Activate" } } + , + "ImportMembersDialog": { + "dialogTitle": "Import members", + "chooseParentLabel": "Choose parent to import members from", + "whatToImportLabel": "What would you like to import?", + "serviceAccountsLabel": "Service Accounts", + "addMembersButton": "Add members" + } } diff --git a/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx b/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx index 8907159e..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; } @@ -94,6 +94,7 @@ export function CreateProjectWorkspaceDialog({ sideFormContent={ = ({ requireAtLeastOneMember = true, workspaceName, projectName, + type, }) => { const { t } = useTranslation(); @@ -111,15 +113,17 @@ export const EditMembers: FC = ({ > {t('EditMembers.addButton')} - + {type !== 'project' && ( + + )} = ({ }) .refine((data) => data.importMembers || data.importServiceAccounts, { path: ['importMembers'], - message: 'Select at least one option', + message: t('validationErrors.selectAtLeastOneOption'), }), - [], + [t], ); const { handleSubmit, setValue, watch, reset, getValues } = useForm({ @@ -82,8 +83,6 @@ export const ImportMembersDialog: FC = ({ setStep(2); }; - console.log(projectName); - console.log(workspaceName); useEffect(() => { if (!open) { setStep(1); @@ -91,10 +90,10 @@ export const ImportMembersDialog: FC = ({ } }, [open, reset]); return ( - + {step === 1 && ( - + - + ) => setValue('importMembers', e.target.checked, { @@ -121,7 +120,7 @@ export const ImportMembersDialog: FC = ({ } /> ) => setValue('importServiceAccounts', e.target.checked, { @@ -135,10 +134,10 @@ export const ImportMembersDialog: FC = ({ @@ -179,14 +178,11 @@ const ImportMembersSelectionTable: FC<{ workspaceName?: string; projectName?: string; }> = ({ onCancel, onImport, parentType, workspaceName, projectName, includeMembers, includeServiceAccounts }) => { - const { - isLoading, - data: parentResourceData, - error, - } = useApiResource( + const { t } = useTranslation(); + const { isLoading, data: parentResourceData } = useApiResource( parentType === 'Project' ? ResourceObject('', 'projects', projectName ?? '') - : ResourceObject(workspaceName ?? '', 'projects', projectName ?? ''), + : ResourceObject(projectName ?? '', 'workspaces', workspaceName ?? ''), undefined, true, ); @@ -233,9 +229,9 @@ const ImportMembersSelectionTable: FC<{ ); }, }, - { Header: 'Email', accessor: 'email' }, + { Header: t('MemberTable.columnEmailHeader'), accessor: 'email' }, { - Header: 'Type', + Header: t('MemberTable.columnTypeHeader'), accessor: 'kind', width: 145, Cell: (instance: { cell: { row: { original: SelectionRow } } }) => { @@ -248,7 +244,7 @@ const ImportMembersSelectionTable: FC<{ ); }, }, - { Header: 'Role', accessor: 'role', width: 120 }, + { Header: t('MemberTable.columnRoleHeader'), accessor: 'role', width: 120 }, ]; const data: SelectionRow[] = filteredMockedMembers.map((m) => ({ @@ -266,14 +262,14 @@ const ImportMembersSelectionTable: FC<{ return ( - + diff --git a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index 1d5cf86c..e6b63c42 100644 --- a/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -283,6 +283,7 @@ export const CreateManagedControlPlaneWizardContainer: FC From 402c41035c18625c463372ea62d2ab71811916c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:56:28 +0200 Subject: [PATCH 13/26] fix --- public/locales/en.json | 4 +++- src/components/Members/ImportMembersDialog.tsx | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/public/locales/en.json b/public/locales/en.json index 226d56e6..1ecbab57 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -429,6 +429,8 @@ "chooseParentLabel": "Choose parent to import members from", "whatToImportLabel": "What would you like to import?", "serviceAccountsLabel": "Service Accounts", - "addMembersButton": "Add members" + "addMembersButton": "Add members", + "selectAllButton": "Select all", + "deselectAllButton": "Deselect all" } } diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index 822b20c4..1ba322bd 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -262,6 +262,17 @@ const ImportMembersSelectionTable: FC<{ return ( + + + + From ccdd7a5a5a3b454c0457189b459c08c5d3b3b7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 28 Aug 2025 14:57:49 +0200 Subject: [PATCH 14/26] Update ImportMembersDialog.tsx --- src/components/Members/ImportMembersDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index 1ba322bd..73fcc27c 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -86,7 +86,7 @@ export const ImportMembersDialog: FC = ({ useEffect(() => { if (!open) { setStep(1); - reset({ parentType: 'Project', importMembers: false, importServiceAccounts: false }); + reset({ parentType: 'Project', importMembers: true, importServiceAccounts: true }); } }, [open, reset]); return ( @@ -110,7 +110,7 @@ export const ImportMembersDialog: FC = ({ ) => setValue('importMembers', e.target.checked, { shouldValidate: true, @@ -121,7 +121,7 @@ export const ImportMembersDialog: FC = ({ /> ) => setValue('importServiceAccounts', e.target.checked, { shouldValidate: true, From 0ae01d9e2651c1b6efff673d064df5065c82e35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 29 Aug 2025 09:24:00 +0200 Subject: [PATCH 15/26] Update ImportMembersDialog.tsx --- src/components/Members/ImportMembersDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index 73fcc27c..ae3df70c 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -190,7 +190,7 @@ const ImportMembersSelectionTable: FC<{ if (isLoading) { return ; } - console.log(parentResourceData?.spec?.members); + const membersData = parentResourceData?.spec?.members ?? []; const mockedMembers: Member[] = membersData.map(({ name, namespace, kind, roles }) => ({ kind, From 3dd5531f4cb58a662ce77574667b72836d265a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 29 Aug 2025 09:30:45 +0200 Subject: [PATCH 16/26] Update ImportMembersDialog.tsx --- .../Members/ImportMembersDialog.tsx | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index ae3df70c..37d5cd86 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -24,6 +24,7 @@ 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'; type ParentType = 'Workspace' | 'Project'; @@ -65,7 +66,7 @@ export const ImportMembersDialog: FC = ({ [t], ); - const { handleSubmit, setValue, watch, reset, getValues } = useForm({ + const { handleSubmit, setValue, watch, reset } = useForm({ resolver: zodResolver(formSchema), mode: 'onChange', defaultValues: { @@ -97,8 +98,8 @@ export const ImportMembersDialog: FC = ({ ) => { - const selected = e.detail.selectedOption?.dataset?.value as ParentType; + onChange={(e: CustomEvent<{ selectedOption: { value?: string } }>) => { + const selected = e.detail.selectedOption?.value as ParentType; setValue('parentType', selected, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); }} > @@ -187,7 +187,7 @@ const ImportMembersSelectionTable: FC<{ } = useApiResource( parentType === 'Project' ? ResourceObject('', 'projects', projectName ?? '') - : ResourceObject(projectName ?? '', 'workspaces', workspaceName ?? ''), + : ResourceObject(`project-${projectName ?? ''}`, 'workspaces', workspaceName ?? ''), undefined, true, ); From 781b8734ebb8a40a3aec442f2674df3000a73df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 29 Aug 2025 11:38:53 +0200 Subject: [PATCH 18/26] fix --- src/components/Members/EditMembers.tsx | 9 +++++++-- src/components/Members/ImportMembersDialog.module.css | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index a2e3aac4..2ca5475f 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -1,4 +1,4 @@ -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'; @@ -104,6 +104,11 @@ export const EditMembers: FC = ({ [members, onMemberChanged, memberToEdit], ); + const computedProjectName = useMemo( + () => (type === 'mcp' ? removeProjectPrefix(projectName) : projectName), + [type, projectName], + ); + return ( - {type !== 'project' && ( + - )} + {type !== 'project' && ( + + )} + = ({ 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) => ( - )} @@ -140,13 +139,15 @@ export const EditMembers: FC = ({ onSave={handleSaveMember} /> - + {computedProjectName && ( + + )} void; onImport: (members: Member[]) => void; - projectName?: string; + projectName: string; workspaceName?: string; -}; - +} export const ImportMembersDialog: FC = ({ - open, + isOpen, projectName, workspaceName, onClose, onImport, }) => { - const [step, setStep] = useState(1); - const { t } = useTranslation(); - const formSchema = useMemo( - () => - z - .object({ - parentType: z.union([z.literal('Workspace'), z.literal('Project'), z.literal('')]), - importMembers: z.boolean(), - importServiceAccounts: z.boolean(), - }) - .refine((data) => data.importMembers || data.importServiceAccounts, { - path: ['importMembers'], - message: t('validationErrors.selectAtLeastOneOption'), - }), - [t], - ); - - const { handleSubmit, setValue, watch, reset } = useForm({ - resolver: zodResolver(formSchema), - mode: 'onChange', - defaultValues: { - parentType: 'Project', - importMembers: true, - importServiceAccounts: true, - }, - }); - - const parentType = watch('parentType'); - const importMembers = watch('importMembers'); - const importServiceAccounts = watch('importServiceAccounts'); - const canProceed = parentType !== '' && (importMembers || importServiceAccounts); - const onSubmitStepOne = () => { - setStep(2); - }; - - useEffect(() => { - if (!open) { - setStep(1); - reset({ parentType: 'Project', importMembers: true, importServiceAccounts: true }); - } - }, [open, reset]); - return ( - - {step === 1 && ( - - - - - - - ) => - setValue('importMembers', e.target.checked, { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }) - } - /> - ) => - setValue('importServiceAccounts', e.target.checked, { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }) - } - /> - - - - - - - - )} - - {step === 2 && ( - - )} - - ); -}; + const [filteredFor, setFilteredFor] = useState('All'); + const [sourceType, setSourceType] = useState('Project'); + const [selectedRowIds, setSelectedRowIds] = useState({}); -type SelectionRow = { - email: string; - role: string; - kind: string; - _member: Member; -}; - -interface SpecMembers { - spec?: { members: { name: string; roles: string[]; kind: 'User' | 'ServiceAccount'; namespace?: string }[] }; -} - -const ImportMembersSelectionTable: FC<{ - onCancel: () => void; - onImport: (members: Member[]) => void; - parentType: ParentType; - includeMembers: boolean; - includeServiceAccounts: boolean; - workspaceName?: string; - projectName?: string; -}> = ({ onCancel, onImport, parentType, workspaceName, projectName, includeMembers, includeServiceAccounts }) => { const { t } = useTranslation(); + const { isLoading, data: parentResourceData, error, } = useApiResource( - parentType === 'Project' - ? ResourceObject('', 'projects', projectName ?? '') - : ResourceObject(`project-${projectName ?? ''}`, 'workspaces', workspaceName ?? ''), + sourceType === 'Project' + ? ResourceObject('', 'projects', projectName) + : ResourceObject(`project-${projectName}`, 'workspaces', workspaceName ?? ''), undefined, true, - ); - const [selectedEmails, setSelectedEmails] = useState>(new Set()); - if (isLoading) { - return ; - } - - const membersData = parentResourceData?.spec?.members ?? []; - const mockedMembers: Member[] = membersData.map(({ name, namespace, kind, roles }) => ({ - kind, - name, - roles, - namespace, - })); - - const filteredMockedMembers: Member[] = mockedMembers.filter( - (m) => (m.kind === 'User' && includeMembers) || (m.kind === 'ServiceAccount' && includeServiceAccounts), + !isOpen, ); - const columns: AnalyticalTableColumnDefinition[] = [ - { - Header: '', - id: 'select', - width: 60, - Cell: (instance: { cell: { row: { original: SelectionRow } } }) => { - const email = instance.cell.row.original.email; - const checked = selectedEmails.has(email); - return ( - ) => { - setSelectedEmails((prev) => { - const next = new Set(prev); - if (e.target.checked) { - next.add(email); - } else { - next.delete(email); - } - return next; - }); - }} - /> - ); + 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.columnEmailHeader'), accessor: 'email' }, - { - Header: t('MemberTable.columnTypeHeader'), - accessor: 'kind', - width: 145, - Cell: (instance: { cell: { row: { original: SelectionRow } } }) => { - const kind = ACCOUNT_TYPES.find(({ value }) => value === instance.cell.row.original.kind); - return ( - - - {kind?.label} - - ); - }, - }, - { Header: t('MemberTable.columnRoleHeader'), accessor: 'role', width: 120 }, - ]; + { Header: t('MemberTable.columnRoleHeader'), accessor: 'role', width: 120 }, + ], + [t], + ); - const data: SelectionRow[] = filteredMockedMembers.map((m) => ({ - email: m.name, - role: MemberRolesDetailed[m.roles?.[0] as MemberRoles]?.displayValue, - kind: m.kind, - _member: m, - })); + useEffect(() => { + setSelectedRowIds({}); + }, [isOpen]); const handleAddMembers = () => { - const selected = filteredMockedMembers.filter((m) => selectedEmails.has(m.name)); - onImport(selected); - onCancel(); + const selectedMembers = Object.entries(selectedRowIds ?? {}) + .filter(([, isSelected]) => isSelected) + .map(([idx]) => tableData[Number(idx)]._member); + + onImport(selectedMembers); + onClose(); }; return ( - + + + + + } + /> + } + onClose={onClose} + > {error ? ( ) : ( -
- - - - - - -
+ <> +
+
+ +
+
+ +
+
+ +
+
+ { + 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) { + let addButtonText = ''; + switch (selectedMembersCount) { + case 0: + addButtonText = t('ImportMembersDialog.addMembersButton0'); + break; + case 1: + addButtonText = t('ImportMembersDialog.addMembersButton1'); + break; + default: + addButtonText = t('ImportMembersDialog.addMembersButtonN', { count: selectedMembersCount }); + break; + } + return addButtonText; +} + +interface SpecMembers { + spec?: { members: { name: string; roles: string[]; kind: 'User' | 'ServiceAccount'; namespace?: 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, From 3aa41c97c63709d78ae3bfba095178f972246dd7 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 4 Sep 2025 14:26:32 +0200 Subject: [PATCH 24/26] Implement review feedback --- src/components/Members/EditMembers.tsx | 4 +++- src/components/Members/ImportMembersDialog.tsx | 11 +++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index 0538639e..e899aa10 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -25,7 +25,9 @@ export const ACCOUNT_TYPES: RadioButtonsSelectOption[] = [ export type AccountType = 'User' | 'ServiceAccount'; -const removeProjectPrefix = (name?: string) => (name?.startsWith('project-') ? name.slice('project-'.length) : name); +const PROJECT_PREFIX = 'project-'; +const removeProjectPrefix = (name?: string) => + name?.startsWith(PROJECT_PREFIX) ? name.slice(PROJECT_PREFIX.length) : name; export const EditMembers: FC = ({ members, diff --git a/src/components/Members/ImportMembersDialog.tsx b/src/components/Members/ImportMembersDialog.tsx index f225bee3..0128976c 100644 --- a/src/components/Members/ImportMembersDialog.tsx +++ b/src/components/Members/ImportMembersDialog.tsx @@ -212,19 +212,14 @@ export const ImportMembersDialog: FC = ({ }; function getAddMembersButtonText(selectedMembersCount: number, t: TFunction) { - let addButtonText = ''; switch (selectedMembersCount) { case 0: - addButtonText = t('ImportMembersDialog.addMembersButton0'); - break; + return t('ImportMembersDialog.addMembersButton0'); case 1: - addButtonText = t('ImportMembersDialog.addMembersButton1'); - break; + return t('ImportMembersDialog.addMembersButton1'); default: - addButtonText = t('ImportMembersDialog.addMembersButtonN', { count: selectedMembersCount }); - break; + return t('ImportMembersDialog.addMembersButtonN', { count: selectedMembersCount }); } - return addButtonText; } interface SpecMembers { From 503af2a0b76d853a97dd9f8be0db3f3ca58ad7c5 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 4 Sep 2025 16:52:04 +0200 Subject: [PATCH 25/26] Add toast --- public/locales/en.json | 9 ++- src/components/Members/EditMembers.tsx | 56 ++++++++++++++-- src/lib/api/types/shared/members.spec.ts | 84 ++++++++++++++++++++++++ src/lib/api/types/shared/members.ts | 11 ++++ 4 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 src/lib/api/types/shared/members.spec.ts diff --git a/public/locales/en.json b/public/locales/en.json index 87e89528..dd5c315b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -174,7 +174,12 @@ "saveButton": "Save changes", "defaultNamespaceInfo": "Leave empty to use default namespace", "serviceAccoutsGuide": "You can also use our Service Account Guide for more information.", - "reuseMembersButton": "Reuse" + "reuseMembersButton": "Reuse", + "membersToastNoChanges": "No changes.", + "membersToastAdded1": "1 member added.", + "membersToastAddedN": "{{count}} members added.", + "membersToastChanged1": "1 member changed.", + "membersToastChangedN": "{{count}} members changed." }, "ProjectsPage": { @@ -432,7 +437,7 @@ "reuseFromLabel": "Reuse from", "filterForLabel": "Filter for", "addMembersButton0": "Add members", - "addMembersButton1": "Add 1 member", + "addMembersButton1": "Add member", "addMembersButtonN": "Add {{count}} members" } } diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index e899aa10..5209e693 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -1,12 +1,14 @@ 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[]; @@ -73,15 +75,29 @@ export const EditMembers: FC = ({ setIsImportDialogOpen(false); }, []); + const toast = useToast(); + const handleImportMembers = useCallback( (imported: Member[]) => { - const byName = new Map(); - members.forEach((m) => byName.set(m.name, m)); - imported.forEach((m) => byName.set(m.name, m)); - const merged = Array.from(byName.values()); - onMemberChanged(merged); + 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], + [members, onMemberChanged, t], ); const handleSaveMember = useCallback( @@ -161,3 +177,29 @@ export const EditMembers: FC = ({
); }; + +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/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; From e8b65f7b533b170aaa2889bb013c6673b3250881 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 4 Sep 2025 16:54:13 +0200 Subject: [PATCH 26/26] Fix linting --- src/components/Members/EditMembers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index 5209e693..1dea17b9 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -97,7 +97,7 @@ export const EditMembers: FC = ({ toast.show(buildToastMessage(numberOfAddedMembers, numberOfChangedMembers, t)); onMemberChanged(updatedMembers); }, - [members, onMemberChanged, t], + [members, onMemberChanged, t, toast], ); const handleSaveMember = useCallback(