diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 9cfc8004b..b0b22de34 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -205,8 +205,8 @@ "Field": "Field", "Value": "Value", "Close": "Close", - "View {{ name }}": "View {{ name }}", - "Edit {{ name }}": "Edit {{ name }}", + "View {catalogItemEl}": "View {catalogItemEl}", + "Edit {catalogItemEl}": "Edit {catalogItemEl}", "Create": "Create", "General info": "General info", "Type and configuration": "Type and configuration", @@ -414,9 +414,8 @@ "Device is in package mode. The catalog item cannot be deployed to this device.": "Device is in package mode. The catalog item cannot be deployed to this device.", "Failed to find operating system": "Failed to find operating system", "Failed to find application": "Failed to find application", + "Deploy {catalogItemEl}": "Deploy {catalogItemEl}", "Software catalog": "Software catalog", - "Deploy {{ name }}": "Deploy {{ name }}", - "Edit {{name}}": "Edit {{name}}", "Return to device catalog": "Return to device catalog", "Return to fleet catalog": "Return to fleet catalog", "Review update specifications": "Review update specifications", @@ -462,7 +461,6 @@ "Failed to find requested version {{ version }}": "Failed to find requested version {{ version }}", "Invalid channel {{ channel }} for version {{ version }}": "Invalid channel {{ channel }} for version {{ version }}", "Loading catalog item": "Loading catalog item", - "Deploy {{name}}": "Deploy {{name}}", "Application name": "Application name", "Application name must be unique.": "Application name must be unique.", "Configure via:": "Configure via:", diff --git a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard.tsx b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard.tsx index e52cc4497..77213b3f3 100644 --- a/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard.tsx +++ b/libs/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard.tsx @@ -11,6 +11,7 @@ import { WizardStep, type WizardStepType, } from '@patternfly/react-core'; +import { Trans } from 'react-i18next'; import { type CatalogItem } from '@flightctl/types/alpha'; import { Formik, type FormikErrors } from 'formik'; @@ -38,6 +39,7 @@ import { getErrorMessage } from '../../../utils/error'; import { usePermissionsContext } from '../../common/PermissionsContext'; import PageWithPermissions from '../../common/PageWithPermissions'; import { RESOURCE, VERB } from '../../../types/rbac'; +import { CatalogItemLabel } from '../CatalogItemDetails'; const orderedIds = [generalInfoStepId, typeConfigStepId, versionStepId, reviewStepId]; @@ -109,13 +111,22 @@ const AddCatalogItemWizard = () => { const initialValues = editItem ? getInitialValuesFromItem(editItem) : getInitialValues(); const isReadOnly = !!editItem?.metadata?.owner; - let pageTitle: string; + const catalogItemEl = ; + let titleEl: React.ReactNode; if (isReadOnly) { - pageTitle = t('View {{ name }}', { name: editItem?.spec.displayName || editItem?.metadata.name }); + titleEl = ( + + View {catalogItemEl} + + ); } else if (isEdit) { - pageTitle = t('Edit {{ name }}', { name: editItem?.spec.displayName || editItem?.metadata.name }); + titleEl = ( + + Edit {catalogItemEl} + + ); } else { - pageTitle = t('Create catalog item'); + titleEl = t('Create catalog item'); } let content: React.ReactNode = ( @@ -230,12 +241,12 @@ const AddCatalogItemWizard = () => { {t('Software Catalog')} - {pageTitle} + {titleEl} - {pageTitle} + {titleEl} {content} diff --git a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx index 809179fbb..f36a5ee64 100644 --- a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx +++ b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx @@ -41,6 +41,7 @@ import DeleteModal from '../modals/DeleteModal/DeleteModal'; import WithTooltip from '../common/WithTooltip'; import { buildAllDropdownActions } from '../common/ActionsDropdownList'; import FlightCtlPageDrawer from '../common/FlightCtlPageDrawer'; +import TruncatedText from '../common/TruncatedText'; import CatalogItemIcon from './CatalogItemIcon'; import { InstallSpec } from './InstallWizard/steps/SpecificationsStep'; import { type InstallSpecFormik } from './InstallWizard/types'; @@ -67,6 +68,21 @@ type CatalogItemDetailsHeaderProps = { item: CatalogItem; }; +export const CatalogItemLabel = ({ + item, + fallbackId, + shortened, +}: { + item?: CatalogItem; + fallbackId?: string; + shortened?: boolean; +}) => ( + +); + export const CatalogItemDetailsHeader = ({ item }: CatalogItemDetailsHeaderProps) => { const { t } = useTranslation(); return ( @@ -75,7 +91,9 @@ export const CatalogItemDetailsHeader = ({ item }: CatalogItemDetailsHeaderProps - {item.spec.displayName || item.metadata.name} + + <CatalogItemLabel item={item} shortened /> + {item.spec.provider && ( {t('Provided by {{provider}}', { provider: item.spec.provider })} diff --git a/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx index da91e6036..945bf973f 100644 --- a/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx +++ b/libs/ui-components/src/components/Catalog/CatalogItemTitle.tsx @@ -7,6 +7,7 @@ import type { CatalogItemRefSpec } from '@flightctl/types'; import type { CatalogItem } from '@flightctl/types/alpha'; import { useTranslation } from '../../hooks/useTranslation'; import CatalogItemIcon from './CatalogItemIcon'; +import { CatalogItemLabel } from './CatalogItemDetails'; const formatVersionLine = (t: TFunction, version?: string, channel?: string) => { if (!version) { @@ -90,25 +91,26 @@ export const BrokenCatalogItemTitle = ({ }; const CatalogItemTitle = ({ - headerTitle, item, + title, version, channel, - description, }: { item: CatalogItem; - headerTitle: string; + title?: string; version?: string; channel?: string; - description?: string; -}) => ( - } - title={headerTitle} - description={description} - version={version} - channel={channel} - /> -); +}) => { + const nameEl = ; + return ( + } + title={title || nameEl} + description={title ? nameEl : undefined} + version={version} + channel={channel} + /> + ); +}; export default CatalogItemTitle; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx index 5c51eeef4..5b540bd21 100644 --- a/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx @@ -7,6 +7,8 @@ import { Content, ContentVariants, EmptyState, + Flex, + FlexItem, PageSection, Spinner, Stack, @@ -15,6 +17,7 @@ import { } from '@patternfly/react-core'; import { CatalogItemCategory } from '@flightctl/types/alpha'; import { load } from 'js-yaml'; +import { Trans } from 'react-i18next'; import type { ApplicationProviderSpec, Device, Fleet, ImageOrCatalogItemRefSpec, PatchRequest } from '@flightctl/types'; import ErrorBoundary from '../../common/ErrorBoundary'; @@ -35,6 +38,7 @@ import { appendJSONPatch } from '../../../utils/patches/patch'; import { type CatalogEditWizardMode, getEditWizardMode } from '../../../utils/catalog'; import EditOsWizard from './EditOsWizard'; import EditAppWizard from './EditAppWizard'; +import { CatalogItemLabel } from '../CatalogItemDetails'; type EditWizardProps = { mode: CatalogEditWizardMode; @@ -192,7 +196,21 @@ const EditWizard = ({ } } - const catalogDisplayName = catalogItem?.spec.displayName || params.itemId; + const catalogItemEl = ; + let titleEl: React.ReactNode; + if (version) { + titleEl = ( + + Deploy {catalogItemEl} + + ); + } else { + titleEl = ( + + Edit {catalogItemEl} + + ); + } return ( <> @@ -213,16 +231,19 @@ const EditWizard = ({ {t('Software catalog')} - {`${catalogDisplayName}${appName ? ` (${appName})` : ''}`} + + + {appName ? `(${appName}) ` : ''} + {titleEl} + + - {version - ? t('Deploy {{ name }}', { name: catalogDisplayName }) - : t('Edit {{name}}', { name: catalogDisplayName })} + {titleEl} diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx index ae66b54c2..49b938c0c 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx +++ b/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx @@ -25,6 +25,7 @@ import { getErrorMessage } from '../../../utils/error'; import { usePermissionsContext } from '../../common/PermissionsContext'; import PageWithPermissions from '../../common/PageWithPermissions'; import { RESOURCE, VERB } from '../../../types/rbac'; +import { CatalogItemLabel } from '../CatalogItemDetails'; const InstallWizard = () => { const { t } = useTranslation(); @@ -49,6 +50,8 @@ const InstallWizard = () => { content = ; } + const titleEl = ; + return ( <> @@ -56,14 +59,14 @@ const InstallWizard = () => { {t('Software Catalog')} - {catalogItem?.spec.displayName || params.itemId} + {titleEl} - {t('Deploy {{name}}', { name: catalogItem?.spec.displayName || params.itemId })} + {t('Deploy')} {titleEl} diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx index 78728cb9c..8863f117f 100644 --- a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx @@ -25,7 +25,6 @@ import { import FlightCtlModal from '@flightctl/ui-components/src/components/common/FlightCtlModal'; import * as React from 'react'; import { type FormikErrors, useFormikContext } from 'formik'; -import * as semver from 'semver'; import ReactMarkdown from 'react-markdown'; import { type TFunction } from 'react-i18next'; @@ -42,6 +41,7 @@ import { applyInitialConfig, getInitialAppConfig } from '../utils'; import { type InstallAppFormik, type InstallSpecFormik, type TargetPickerFormik } from '../types'; import WithTooltip from '../../../common/WithTooltip'; import { getFullContainerURI } from '../../../../utils/catalog'; +import { getSortedChannelVersions } from '../../../CatalogComposition/catalogCompositionUtils'; type VersionDropdownProps = { catalogItem: CatalogItem; @@ -106,14 +106,11 @@ export const InstallSpec = ({ return acc; }, {}); - const versions = catalogItem.spec.versions.sort((v1, v2) => semver.compare(v2.version, v1.version)); - - const channelVersions = versions.filter( - (v) => - v.channels.includes(values.channel) && (targetSet ? !!getFullContainerURI(catalogItem.spec.artifacts, v) : true), + const channelVersions = getSortedChannelVersions(catalogItem, values.channel).filter((v) => + targetSet ? !!getFullContainerURI(catalogItem.spec.artifacts, v) : true, ); - const currentVersion = versions.find((v) => v.version === values.version); + const currentVersion = catalogItem.spec.versions.find((v) => v.version === values.version); return ( <> @@ -131,7 +128,7 @@ export const InstallSpec = ({ name="channel" items={channels} onChange={(val) => { - const newChannelVersions = versions.filter((v) => v.channels.includes(val)); + const newChannelVersions = getSortedChannelVersions(catalogItem, val); if (!newChannelVersions.some((v) => v.version === values.version)) { const newVersion = newChannelVersions.length ? newChannelVersions[0].version : undefined; setFieldValue('version', newVersion, true); diff --git a/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx index 3b1f50065..8b7523462 100644 --- a/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx +++ b/libs/ui-components/src/components/Catalog/InstalledSoftwareItem.tsx @@ -23,16 +23,7 @@ const SoftwareItemTitle = ({ return ; } - const itemName = (data.item.spec.displayName || data.item.metadata.name) as string; - return ( - - ); + return ; }; const SoftwareItemUpdateBadge = ({ diff --git a/libs/ui-components/src/components/CatalogComposition/catalogCompositionUtils.ts b/libs/ui-components/src/components/CatalogComposition/catalogCompositionUtils.ts index 6f016f767..b531946e7 100644 --- a/libs/ui-components/src/components/CatalogComposition/catalogCompositionUtils.ts +++ b/libs/ui-components/src/components/CatalogComposition/catalogCompositionUtils.ts @@ -157,7 +157,7 @@ export const getDefaultChannelAndVersion = (item: CatalogItem) => { }; } - const versions = item.spec.versions.sort((v1, v2) => semver.rcompare(v1.version, v2.version)); + const versions = [...item.spec.versions].sort((v1, v2) => semver.rcompare(v1.version, v2.version)); // release then prerelease const latestVersion = versions.find((v) => !semver.prerelease(v.version)) || versions[0]; diff --git a/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts index a84335874..415ffb518 100644 --- a/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts +++ b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts @@ -1,4 +1,4 @@ -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { RESOURCE, VERB } from '../../types/rbac'; import type { PermissionCheck } from '../common/PermissionsContext'; import type { QuickStartPhaseDefinition } from './types'; diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx index 28b5035d6..c4b090549 100644 --- a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Card, CardBody, diff --git a/libs/ui-components/src/components/Terminal/AppTerminal.tsx b/libs/ui-components/src/components/Terminal/AppTerminal.tsx index 7f234807d..41e185470 100644 --- a/libs/ui-components/src/components/Terminal/AppTerminal.tsx +++ b/libs/ui-components/src/components/Terminal/AppTerminal.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Alert, AlertActionLink, diff --git a/libs/ui-components/src/components/Terminal/TerminalConnectError.tsx b/libs/ui-components/src/components/Terminal/TerminalConnectError.tsx index 65b24752f..f8859a041 100644 --- a/libs/ui-components/src/components/Terminal/TerminalConnectError.tsx +++ b/libs/ui-components/src/components/Terminal/TerminalConnectError.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Alert, AlertActionLink } from '@patternfly/react-core'; -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import type { AppConsoleConnectError } from '../../hooks/useAppConsoleWebSocket'; import { useTranslation } from '../../hooks/useTranslation'; diff --git a/libs/ui-components/src/components/common/ResourceLink.tsx b/libs/ui-components/src/components/common/ResourceLink.tsx index f0075f035..469c91cda 100644 --- a/libs/ui-components/src/components/common/ResourceLink.tsx +++ b/libs/ui-components/src/components/common/ResourceLink.tsx @@ -1,12 +1,11 @@ import * as React from 'react'; import { Link, type RouteWithPostfix } from '../../hooks/useNavigate'; +import { getDisplayText } from '../../utils/displayText'; import CopyButton from './CopyButton'; import './ResourceLink.css'; -const maxDisplayLength = 50; - type ResourceDisplayLinkProps = { id: string; name?: string; @@ -15,16 +14,6 @@ type ResourceDisplayLinkProps = { 'data-testid'?: string; }; -export const getDisplayText = (name: string | undefined) => { - if (!name) { - return '-'; - } - if (name.length <= maxDisplayLength) { - return name; - } - return `${name.substring(0, 6)}...${name.substring(name.length - 7)}`; -}; - const ResourceLink = ({ id, name, diff --git a/libs/ui-components/src/components/common/TruncatedText.css b/libs/ui-components/src/components/common/TruncatedText.css new file mode 100644 index 000000000..ff8b0e90b --- /dev/null +++ b/libs/ui-components/src/components/common/TruncatedText.css @@ -0,0 +1,35 @@ +.fctl-truncated-text { + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; +} + +.fctl-truncated-text__copy { + text-wrap: nowrap; + overflow-x: hidden; +} + +.fctl-truncated-text .pf-v6-c-button { + /* The copy button is hidden by default */ + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} + +.fctl-truncated-text:hover .pf-v6-c-button, +.fctl-truncated-text:focus-within .pf-v6-c-button { + /* Reveal the copy button on hover or when a descendant has keyboard focus */ + clip-path: none; + overflow: visible; + white-space: initial; +} + +@media (hover: none) { + .fctl-truncated-text .pf-v6-c-button { + /* Touch devices have no reliable hover; keep the copy button visible */ + clip-path: none; + overflow: visible; + white-space: initial; + } +} diff --git a/libs/ui-components/src/components/common/TruncatedText.tsx b/libs/ui-components/src/components/common/TruncatedText.tsx new file mode 100644 index 000000000..cd113e86c --- /dev/null +++ b/libs/ui-components/src/components/common/TruncatedText.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import CopyButton from './CopyButton'; +import { defaultMaxDisplayLength, getDisplayText } from '../../utils/displayText'; + +import './TruncatedText.css'; + +type TruncatedTextProps = { + text: string; + showCopy?: boolean; + maxChars?: number; + leadingChars?: number; + children?: (textContent: string) => React.ReactNode; +}; + +const TruncatedText = ({ + text, + showCopy, + leadingChars, + maxChars = defaultMaxDisplayLength, + children, +}: TruncatedTextProps) => { + const displayText = getDisplayText(text, maxChars, leadingChars); + const shouldShowCopy = showCopy ?? text !== displayText; + + return ( + + {children ? children(displayText) : displayText} + {shouldShowCopy && } + + ); +}; + +export default TruncatedText; diff --git a/libs/ui-components/src/components/modals/DeleteModal/DeleteModal.tsx b/libs/ui-components/src/components/modals/DeleteModal/DeleteModal.tsx index 7fb819b9b..a64f3eafc 100644 --- a/libs/ui-components/src/components/modals/DeleteModal/DeleteModal.tsx +++ b/libs/ui-components/src/components/modals/DeleteModal/DeleteModal.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Alert, Button, ModalBody, ModalFooter, ModalHeader, Stack, StackItem } from '@patternfly/react-core'; import { ResourceKind } from '@flightctl/types'; diff --git a/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx b/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx index 1ab8835d7..a9281356a 100644 --- a/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx +++ b/libs/ui-components/src/hooks/useDeviceSpecSystemInfo.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { TFunction } from 'react-i18next'; +import type { TFunction } from 'i18next'; import type { DeviceSystemInfo } from '@flightctl/types'; diff --git a/libs/ui-components/src/utils/displayText.ts b/libs/ui-components/src/utils/displayText.ts new file mode 100644 index 000000000..62a6df8da --- /dev/null +++ b/libs/ui-components/src/utils/displayText.ts @@ -0,0 +1,12 @@ +/** EDM-4074: default max length before middle-ellipsis shortening applies. */ +export const defaultMaxDisplayLength = 50; + +export const getDisplayText = (text: string | undefined, maxLength = defaultMaxDisplayLength, leadingChars = 6) => { + if (!text) { + return '-'; + } + if (text.length <= maxLength) { + return text; + } + return `${text.substring(0, leadingChars)}...${text.substring(text.length - 7)}`; +};