diff --git a/public/locales/en.json b/public/locales/en.json index 18efc478..428b8e28 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -369,6 +369,7 @@ }, "common": { "all": "All", + "status": "Status", "documentation": "Documentation", "close": "Close", "cannotLoadData": "Cannot load data", diff --git a/src/components/ControlPlane/MCPHealthPopoverButton.tsx b/src/components/ControlPlane/MCPHealthPopoverButton.tsx index 389c7623..6d319cf7 100644 --- a/src/components/ControlPlane/MCPHealthPopoverButton.tsx +++ b/src/components/ControlPlane/MCPHealthPopoverButton.tsx @@ -7,12 +7,14 @@ import { Button, PopoverDomRef, ButtonDomRef, + LinkDomRef, } from '@ui5/webcomponents-react'; import { AnalyticalTableColumnDefinition } from '@ui5/webcomponents-react/wrappers'; import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js'; import '@ui5/webcomponents-icons/dist/copy'; import { JSX, useRef, useState } from 'react'; import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import type { LinkClickEventDetail } from '@ui5/webcomponents/dist/Link.js'; import { ControlPlaneStatusType, ReadyStatus, @@ -30,17 +32,28 @@ type MCPHealthPopoverButtonProps = { projectName: string; workspaceName: string; mcpName: string; + large?: boolean; }; -const MCPHealthPopoverButton = ({ mcpStatus, projectName, workspaceName, mcpName }: MCPHealthPopoverButtonProps) => { +const MCPHealthPopoverButton = ({ + mcpStatus, + projectName, + workspaceName, + mcpName, + large = false, +}: MCPHealthPopoverButtonProps) => { const popoverRef = useRef(null); + const buttonRef = useRef(null); const [open, setOpen] = useState(false); const { githubIssuesSupportTicket } = useLink(); const { t } = useTranslation(); - const handleOpenerClick = (event: Ui5CustomEvent) => { + const handleOpenerClick = ( + event: Ui5CustomEvent | Ui5CustomEvent, + ) => { if (popoverRef.current) { - (popoverRef.current as unknown as { opener: EventTarget | null }).opener = event.target; + // Prefer explicit button ref as opener (works reliably); fall back to event.target + (popoverRef.current as unknown as { opener: EventTarget | null }).opener = buttonRef.current ?? event.target; setOpen((prev) => !prev); } }; @@ -141,11 +154,13 @@ const MCPHealthPopoverButton = ({ mcpStatus, projectName, workspaceName, mcpName return (
- + setOpen(false)}> { switch (status) { case ReadyStatus.Ready: - return ; + return ; case ReadyStatus.NotReady: - return ; + return ; case ReadyStatus.InDeletion: - return ; + return ; default: return <>; } diff --git a/src/components/ControlPlane/McpStatusSection.module.css b/src/components/ControlPlane/McpStatusSection.module.css new file mode 100644 index 00000000..3c8e1493 --- /dev/null +++ b/src/components/ControlPlane/McpStatusSection.module.css @@ -0,0 +1,5 @@ +.statusLabel { + font-weight: bold; + margin-bottom: 0.75rem; + margin-left: 0.6rem; +} diff --git a/src/components/ControlPlane/McpStatusSection.tsx b/src/components/ControlPlane/McpStatusSection.tsx new file mode 100644 index 00000000..a6d70b95 --- /dev/null +++ b/src/components/ControlPlane/McpStatusSection.tsx @@ -0,0 +1,29 @@ +import { FlexBox, Text } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import MCPHealthPopoverButton from './MCPHealthPopoverButton.tsx'; +import { ControlPlaneStatusType } from '../../lib/api/types/crate/controlPlanes.ts'; +import styles from './McpStatusSection.module.css'; + +interface McpStatusSectionProps { + mcpStatus: ControlPlaneStatusType | undefined; + projectName: string; + workspaceName: string; + mcpName: string; +} + +export function McpStatusSection({ mcpStatus, projectName, workspaceName, mcpName }: McpStatusSectionProps) { + const { t } = useTranslation(); + + return ( + + {t('common.status')}: + + + ); +} diff --git a/src/components/ControlPlane/statusUtils.ts b/src/components/ControlPlane/statusUtils.ts new file mode 100644 index 00000000..714669c6 --- /dev/null +++ b/src/components/ControlPlane/statusUtils.ts @@ -0,0 +1,14 @@ +import { ReadyStatus } from '../../lib/api/types/crate/controlPlanes'; + +export const getClassNameForOverallStatus = (status: ReadyStatus | undefined): string => { + switch (status) { + case ReadyStatus.Ready: + return 'ready'; + case ReadyStatus.NotReady: + return 'not-ready'; + case ReadyStatus.InDeletion: + return 'deleting'; + default: + return ''; + } +}; diff --git a/src/components/ControlPlanes/List/MembersAvatarView.tsx b/src/components/ControlPlanes/List/MembersAvatarView.tsx index 94e7b30b..6ac0c427 100644 --- a/src/components/ControlPlanes/List/MembersAvatarView.tsx +++ b/src/components/ControlPlanes/List/MembersAvatarView.tsx @@ -10,9 +10,10 @@ interface Props { project?: string; workspace?: string; members: Member[]; + hideNamespaceColumn?: boolean; } -export function MembersAvatarView({ members, project, workspace }: Props) { +export function MembersAvatarView({ members, project, workspace, hideNamespaceColumn = false }: Props) { const openerId = useId(); const [popoverIsOpen, setPopoverIsOpen] = useState(false); const avatars = []; @@ -44,7 +45,7 @@ export function MembersAvatarView({ members, project, workspace }: Props) { setPopoverIsOpen(false); }} > - +
); diff --git a/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.module.css b/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.module.css new file mode 100644 index 00000000..ed74e9a3 --- /dev/null +++ b/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.module.css @@ -0,0 +1,5 @@ +.membersTitle { + margin-left: 5px; + font-weight: bold; + margin-bottom: 0.5rem; +} diff --git a/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.tsx b/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.tsx new file mode 100644 index 00000000..63a04081 --- /dev/null +++ b/src/components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.tsx @@ -0,0 +1,24 @@ +import { MembersAvatarView } from '../List/MembersAvatarView.tsx'; +import { convertRoleBindingsToMembers } from '../../../utils/convertRoleBindingsToMembers.ts'; +import { FlexBox, Text } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import styles from './McpMembersAvatarView.module.css'; + +interface Props { + project?: string; + workspace?: string; + roleBindings?: { role: string; subjects: { kind: string; name: string }[] }[]; +} + +export function McpMembersAvatarView({ roleBindings, project, workspace }: Props) { + const members = convertRoleBindingsToMembers(roleBindings); + const { t } = useTranslation(); + return ( + + + {t('common.members')} ({members.length}): + + + + ); +} diff --git a/src/components/Helper/AnimatedHoverTextButton.module.css b/src/components/Helper/AnimatedHoverTextButton.module.css new file mode 100644 index 00000000..7314cc0b --- /dev/null +++ b/src/components/Helper/AnimatedHoverTextButton.module.css @@ -0,0 +1,24 @@ +.text { + margin-right: 0.5rem; +} + +.link:focus-within, +.link:focus-visible { + background-color: transparent; +} + +.ready { + color: var(--sapPositiveColor); +} + +.not-ready { + color: var(--sapNegativeColor); +} + +.deleting { + color: var(--sapCriticalColor); +} + +.large { + font-size: 1.25rem; +} diff --git a/src/components/Helper/AnimatedHoverTextButton.tsx b/src/components/Helper/AnimatedHoverTextButton.tsx index f68f7b1e..d0b0509c 100644 --- a/src/components/Helper/AnimatedHoverTextButton.tsx +++ b/src/components/Helper/AnimatedHoverTextButton.tsx @@ -1,34 +1,72 @@ -import { Button, ButtonDomRef, FlexBox, FlexBoxAlignItems, Text } from '@ui5/webcomponents-react'; +import { Button, ButtonDomRef, FlexBox, FlexBoxAlignItems } from '@ui5/webcomponents-react'; import '@ui5/webcomponents-icons/dist/copy'; -import { JSX, useId, useState } from 'react'; -import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; +import { JSX, useId, useState, forwardRef } from 'react'; import type { Ui5CustomEvent } from '@ui5/webcomponents-react-base'; import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import styles from './AnimatedHoverTextButton.module.css'; +import { getClassNameForOverallStatus } from '../ControlPlane/statusUtils'; +import { ReadyStatus } from '../../lib/api/types/crate/controlPlanes.ts'; +import cx from 'clsx'; type HoverTextButtonProps = { id?: string; text: string; icon: JSX.Element; onClick: (event: Ui5CustomEvent) => void; + large?: boolean; }; -export const AnimatedHoverTextButton = ({ id, text, icon, onClick }: HoverTextButtonProps) => { - const [hover, setHover] = useState(false); - const generatedId = useId(); - id ??= generatedId; +export const AnimatedHoverTextButton = forwardRef( + ({ id, text, icon, onClick, large = false }: HoverTextButtonProps, ref) => { + const [hover, setHover] = useState(false); - return ( - - ); -}; + ); + + if (large) { + return ( + + ); + } + + return ( + + ); + }, +); + +AnimatedHoverTextButton.displayName = 'AnimatedHoverTextButton'; diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index 1a62131f..5f8cc0b4 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -20,6 +20,7 @@ type MemberTableProps = { onEditMember?: (member: Member) => void; isValidationError?: boolean; requireAtLeastOneMember: boolean; + hideNamespaceColumn?: boolean; }; export const MemberTable: FC = ({ @@ -28,6 +29,7 @@ export const MemberTable: FC = ({ onEditMember, isValidationError = false, requireAtLeastOneMember, + hideNamespaceColumn = false, }) => { const { t } = useTranslation(); @@ -56,11 +58,14 @@ export const MemberTable: FC = ({ accessor: 'role', width: 105, }, - { + ]; + + if (!hideNamespaceColumn) { + columns.push({ Header: t('MemberTable.columnNamespaceHeader'), accessor: 'namespace', - }, - ]; + }); + } if (onEditMember && onDeleteMember) { columns.push({ diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index 75a13635..ca5e7f8f 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -1,5 +1,6 @@ import { BusyIndicator, + FlexBox, ObjectPage, ObjectPageHeader, ObjectPageSection, @@ -22,7 +23,7 @@ import { ManagedResources } from '../../../components/ControlPlane/ManagedResour import { ProvidersConfig } from '../../../components/ControlPlane/ProvidersConfig.tsx'; import { Providers } from '../../../components/ControlPlane/Providers.tsx'; import ComponentList from '../../../components/ControlPlane/ComponentList.tsx'; -import MCPHealthPopoverButton from '../../../components/ControlPlane/MCPHealthPopoverButton.tsx'; + import { useApiResource } from '../../../lib/api/useApiResource.ts'; import { YamlViewButton } from '../../../components/Yaml/YamlViewButton.tsx'; @@ -43,6 +44,8 @@ import { McpHeader } from '../components/McpHeader/McpHeader.tsx'; import { ComponentsDashboard } from '../components/ComponentsDashboard/ComponentsDashboard.tsx'; import { ManagedControlPlaneAuthorization } from '../authorization/ManagedControlPlaneAuthorization.tsx'; import { Center } from '../../../components/Ui/Center/Center.tsx'; +import { McpMembersAvatarView } from '../../../components/ControlPlanes/McpMembersAvatarView/McpMembersAvatarView.tsx'; +import { McpStatusSection } from '../../../components/ControlPlane/McpStatusSection.tsx'; export type McpPageSectionId = 'overview' | 'crossplane' | 'flux' | 'landscapers'; @@ -114,12 +117,6 @@ export default function McpPage() { //TODO: actionBar should use Toolbar and ToolbarButton for consistent design actionsBar={
- - + + + + + } onSelectedSectionChange={() => setSelectedSectionId(undefined)} diff --git a/src/utils/convertRoleBindingsToMembers.ts b/src/utils/convertRoleBindingsToMembers.ts new file mode 100644 index 00000000..320e752f --- /dev/null +++ b/src/utils/convertRoleBindingsToMembers.ts @@ -0,0 +1,31 @@ +import { Member } from '../lib/api/types/shared/members.ts'; + +export function convertRoleBindingsToMembers( + roleBindings?: { role: string; subjects: { kind: string; name: string }[] }[], +): Member[] { + if (!roleBindings) return []; + + const memberMap = new Map(); + + for (const binding of roleBindings) { + for (const subject of binding.subjects) { + const key = `${subject.kind}-${subject.name}`; + if (memberMap.has(key)) { + // Add role to existing member + const member = memberMap.get(key)!; + if (!member.roles.includes(binding.role)) { + member.roles.push(binding.role); + } + } else { + // Create new member + memberMap.set(key, { + kind: subject.kind, + name: subject.name, + roles: [binding.role], + }); + } + } + } + + return Array.from(memberMap.values()); +}