From e3cbec2f90d0eec97d3136bbda247a3938f896d3 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 9 Jun 2026 13:17:17 +0200 Subject: [PATCH] EDM-4070: Handle non-editable image promotions Made-with: Cursor --- libs/i18n/locales/en/translation.json | 16 ++- .../ImageBuildDetailsTab.tsx | 98 +++++++++++-------- .../ImagePromotion/ImagePromotionForm.tsx | 48 ++------- .../ImagePromotionFormatsField.tsx | 64 ++++++++++++ .../ImagePromotion/ImagePromotionModal.tsx | 47 ++++++--- .../src/components/ImagePromotion/utils.ts | 61 +++++++++++- 6 files changed, 236 insertions(+), 98 deletions(-) create mode 100644 libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 7c3db06d0..1d23b83c7 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -1277,6 +1277,7 @@ "Export images": "Export images", "Add to catalog": "Add to catalog", "Delete image build": "Delete image build", + "View image promotion": "View image promotion", "Edit image promotion": "Edit image promotion", "Delete image promotion": "Delete image promotion", "Target catalog": "Target catalog", @@ -1342,17 +1343,24 @@ "Failed to load catalogs": "Failed to load catalogs", "Failed to load catalog items": "Failed to load catalog items", "Image Promotion name": "Image Promotion name", - "Formats": "Formats", - "Remove {{ exportLabel }}": "Remove {{ exportLabel }}", - "Add {{ exportLabel }}": "Add {{ exportLabel }}", - "No additional formats": "No additional formats", "Add as": "Add as", "Existing catalog item": "Existing catalog item", "New catalog item": "New catalog item", + "No additional formats": "No additional formats", + "No formats selected": "No formats selected", + "Formats": "Formats", + "Remove {{ exportLabel }}": "Remove {{ exportLabel }}", + "Add {{ exportLabel }}": "Add {{ exportLabel }}", "Add build to catalog": "Add build to catalog", "Failed to update ImagePromotion": "Failed to update ImagePromotion", "Failed to create ImagePromotion": "Failed to create ImagePromotion", "Failed to load Image promotions": "Failed to load Image promotions", + "You do not have permissions to update image promotions": "You do not have permissions to update image promotions", + "Image promotion cannot be edited while publishing is in progress": "Image promotion cannot be edited while publishing is in progress", + "Failed image promotions cannot be edited. Create a new image promotion to retry.": "Failed image promotions cannot be edited. Create a new image promotion to retry.", + "Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.": "Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.", + "Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.": "Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.", + "All export formats from this image build are already included in this promotion": "All export formats from this image build are already included in this promotion", "Catalog item is required": "Catalog item is required", "Select or create repository": "Select or create repository", "Add resource sync": "Add resource sync", diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx index b70a0daed..06589e372 100644 --- a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx @@ -22,8 +22,10 @@ import { import { TFunction, Trans } from 'react-i18next'; import { InfoCircleIcon } from '@patternfly/react-icons/dist/js/icons/info-circle-icon'; -import { BindingType, ImagePromotion } from '@flightctl/types/imagebuilder'; +import { BindingType, ImageExport, ImagePromotion } from '@flightctl/types/imagebuilder'; import ImagePromotionModal from '../../ImagePromotion/ImagePromotionModal'; +import { getPromotionEditDisabledReason } from '../../ImagePromotion/utils'; +import { getDisabledTooltipProps } from '../../../utils/tooltip'; import ImagePromotionStatus from '../../ImagePromotion/ImagePromotionStatus'; import { getDateDisplay } from '../../../utils/dates'; import { getExportFormatLabel, getImageReference } from '../../../utils/imageBuilds'; @@ -50,40 +52,45 @@ const detailsPermissions = [ { kind: RESOURCE.IMAGE_PROMOTION, verb: VERB.DELETE }, ]; +type PromotionAction = 'view' | 'edit' | 'delete'; + +type PromotionActionState = { + id: string; + action: PromotionAction; +}; + const ImagePromotionRow = ({ imagePromotion, - onDeleteClick, - onEditClick, - canEditPromotions, + editDisabledReason, + onActionClick, canDeletePromotions, }: { imagePromotion: ImagePromotion; - onDeleteClick: VoidFunction; - onEditClick: VoidFunction; - canEditPromotions: boolean; + editDisabledReason?: string; + onActionClick: (action: PromotionAction) => void; canDeletePromotions: boolean; }) => { const { t } = useTranslation(); const actions: IAction[] = [ - ...(canEditPromotions - ? [ - { - title: t('Edit image promotion'), - onClick: onEditClick, - }, - ] - : []), - ...(canDeletePromotions - ? [ - { - title: t('Delete image promotion'), - onClick: onDeleteClick, - }, - ] - : []), + { + title: t('View image promotion'), + onClick: () => onActionClick('view'), + }, + { + title: t('Edit image promotion'), + onClick: () => onActionClick('edit'), + ...getDisabledTooltipProps(editDisabledReason), + }, ]; + if (canDeletePromotions) { + actions.push({ + title: t('Delete image promotion'), + onClick: () => onActionClick('delete'), + }); + } + return ( {imagePromotion.metadata.name || ''} @@ -93,11 +100,9 @@ const ImagePromotionRow = ({ - {!!actions.length && ( - - - - )} + + + ); }; @@ -130,8 +135,9 @@ const ImagePromotionsCard = ({ const { error, isLoading, isUpdating, pagination, imagePromotions, refetchPromotions } = useImagePromotionsContext(); - const [promotionToDeleteId, setPromotionToDeleteId] = React.useState(); - const [promotionToEditId, setPromotionToEditId] = React.useState(); + const [promotionAction, setPromotionAction] = React.useState(); + + const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => (e as ImageExport).spec.format); let content: React.ReactNode; if (error) { @@ -153,9 +159,8 @@ const ImagePromotionsCard = ({ setPromotionToEditId(promotion.metadata.name)} - onDeleteClick={() => setPromotionToDeleteId(promotion.metadata.name)} - canEditPromotions={canEditPromotions} + editDisabledReason={getPromotionEditDisabledReason(promotion, availableFormats, canEditPromotions, t)} + onActionClick={(action) => setPromotionAction({ id: promotion.metadata.name as string, action })} canDeletePromotions={canDeletePromotions} /> ))} @@ -166,30 +171,41 @@ const ImagePromotionsCard = ({ ); } - const promotionToDelete = imagePromotions.find((p) => p.metadata.name === promotionToDeleteId); - const promotionToEdit = imagePromotions.find((p) => p.metadata.name === promotionToEditId); + const activePromotion = promotionAction + ? imagePromotions.find((p) => p.metadata.name === promotionAction.id) + : undefined; + + const closePromotionAction = () => setPromotionAction(undefined); return ( {t('Image promotions')} {content} - {promotionToDelete && ( + {activePromotion && promotionAction?.action === 'delete' && ( { if (hasDeleted) { refetchPromotions(); } - setPromotionToDeleteId(undefined); + closePromotionAction(); }} /> )} - {promotionToEdit && ( + {activePromotion && promotionAction?.action === 'view' && ( + + )} + {activePromotion && promotionAction?.action === 'edit' && ( { - setPromotionToEditId(undefined); + closePromotionAction(); if (updated) { refetchPromotions(); } diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx index 6e9f796fb..b1b4368d6 100644 --- a/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx +++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Button, FormGroup, FormSection, Spinner, Stack, StackItem } from '@patternfly/react-core'; +import { Alert, FormGroup, FormSection, Spinner } from '@patternfly/react-core'; import FlightCtlForm from '../form/FlightCtlForm'; import { useTranslation } from '../../hooks/useTranslation'; @@ -15,8 +15,8 @@ import NameField from '../form/NameField'; import { getDnsSubdomainValidations } from '../form/validations'; import { useCatalogItems } from '../Catalog/useCatalogs'; import { getErrorMessage } from '../../utils/error'; -import { getExportFormatLabel } from '../../utils/imageBuilds'; import { ImagePromotionFormValues } from './types'; +import ImagePromotionFormatsField from './ImagePromotionFormatsField'; const NewItemForm = ({ isDisabled }: { isDisabled?: boolean }) => { const { t } = useTranslation(); @@ -110,14 +110,16 @@ const ExistingItemForm = ({ catalogItems, isDisabled }: { catalogItems: CatalogI const ImagePromotionForm = ({ isEdit, + canAmendExportFormats = true, availableFormats, }: { isEdit?: boolean; + canAmendExportFormats?: boolean; availableFormats?: ExportFormatType[]; }) => { const { t } = useTranslation(); - const { values, setFieldValue } = useFormikContext(); + const { values } = useFormikContext(); const [catalogList, catalogsLoading, catalogsErr] = useFetchPeriodically({ endpoint: 'catalogs', @@ -167,10 +169,6 @@ const ImagePromotionForm = ({ ); } - const hasExports = availableFormats?.length || values.exportFormats.length; - - const extraFormats = availableFormats?.filter((f) => !values.exportFormats.includes(f)); - return ( - - {hasExports ? ( - - {values.exportFormats.map((format) => getExportFormatLabel(t, format)).join(', ')} - {extraFormats?.map((f) => { - const isIncluded = values.additionalExportFormats?.includes(f); - const exportLabel = getExportFormatLabel(t, f); - return ( - - - - ); - })} - - ) : ( - t('No additional formats') - )} - + diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx new file mode 100644 index 000000000..d31b76241 --- /dev/null +++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Button, FormGroup, Stack, StackItem } from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { ExportFormatType } from '@flightctl/types/imagebuilder'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { getExportFormatLabel } from '../../utils/imageBuilds'; +import { ImagePromotionFormValues } from './types'; + +type ImagePromotionFormatsFieldProps = { + isEdit?: boolean; + canAmendExportFormats: boolean; + availableFormats?: ExportFormatType[]; +}; + +const ImagePromotionFormatsField = ({ + isEdit, + canAmendExportFormats = true, + availableFormats, +}: ImagePromotionFormatsFieldProps) => { + const { t } = useTranslation(); + const { values, setFieldValue } = useFormikContext(); + + const hasExports = availableFormats?.length || values.exportFormats.length; + const extraFormats = availableFormats?.filter((f) => !values.exportFormats.includes(f)) || []; + const emptyFormatsMessage = isEdit ? t('No additional formats') : t('No formats selected'); + + return ( + + {hasExports ? ( + + {values.exportFormats.map((format) => getExportFormatLabel(t, format)).join(', ')} + {extraFormats?.map((f) => { + const isIncluded = values.additionalExportFormats?.includes(f); + const exportLabel = getExportFormatLabel(t, f); + return ( + + + + ); + })} + + ) : ( + emptyFormatsMessage + )} + + ); +}; + +export default ImagePromotionFormatsField; diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx index 1f2b78b1b..74bff8e2b 100644 --- a/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx +++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx @@ -12,7 +12,7 @@ import { useCatalogItem } from '../Catalog/useCatalogs'; import { ExportFormatType, ImageExport, ImagePromotion, ImagePromotionList } from '@flightctl/types/imagebuilder'; import { PatchRequest } from '@flightctl/types'; import { getErrorMessage } from '../../utils/error'; -import { getImagePromotionValidationSchema } from './utils'; +import { canPromotionBeEdited, getImagePromotionValidationSchema } from './utils'; import { RESOURCE, VERB } from '../../types/rbac'; import { usePermissionsContext } from '../common/PermissionsContext'; import { ImageBuildWithExports } from '../../types/extraTypes'; @@ -46,6 +46,7 @@ type ImagePromotionFormContainerProps = { imageBuild: ImageBuildWithExports; parentPromotion: ImagePromotion | undefined; imagePromotion?: ImagePromotion; + canBeEdited: boolean; }; const ImagePromotionFormContainer = ({ @@ -53,12 +54,14 @@ const ImagePromotionFormContainer = ({ imageBuild, parentPromotion, imagePromotion, + canBeEdited, }: ImagePromotionFormContainerProps) => { const { t } = useTranslation(); const { post, patch } = useFetch(); const [error, setError] = React.useState(); const isEdit = !!imagePromotion; + const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => e?.spec.format as ExportFormatType); const target = parentPromotion?.spec.target; const [catalogItem, catalogItemLoading] = useCatalogItem(target?.catalogName, target?.catalogItemName); @@ -121,11 +124,16 @@ const ImagePromotionFormContainer = ({ > {({ isValid, isSubmitting, dirty, submitForm }) => ( onClose()} variant="small"> - + e?.spec.format as ExportFormatType)} + canAmendExportFormats={!isEdit || canBeEdited} + availableFormats={availableFormats} /> {!!error && ( - + {(!isEdit || canBeEdited) && ( + + )} @@ -163,13 +173,17 @@ const ImagePromotionModal = ({ onClose, imageBuild, imagePromotion, + readOnly, }: { onClose: (updated?: boolean) => void; imageBuild: ImageBuildWithExports; imagePromotion?: ImagePromotion; + readOnly?: boolean; }) => { const { t } = useTranslation(); const isEdit = !!imagePromotion; + const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => e?.spec.format as ExportFormatType); + const canBeEdited = !readOnly && imagePromotion ? canPromotionBeEdited(imagePromotion, availableFormats) : false; const parentBuildName = !isEdit ? imageBuild.metadata.annotations?.[NEW_VERSION_FROM_ANNOTATION] : undefined; const { checkPermissions } = usePermissionsContext(); const [canList] = checkPermissions(promotionPermissions); @@ -188,7 +202,11 @@ const ImagePromotionModal = ({ if (promotionsError) { return ( onClose(false)} variant="small"> - + {getErrorMessage(promotionsError)} @@ -206,6 +224,7 @@ const ImagePromotionModal = ({ imageBuild={imageBuild} parentPromotion={parentPromotion} imagePromotion={imagePromotion} + canBeEdited={canBeEdited} /> ); }; diff --git a/libs/ui-components/src/components/ImagePromotion/utils.ts b/libs/ui-components/src/components/ImagePromotion/utils.ts index 282548b3b..14ec8ccfa 100644 --- a/libs/ui-components/src/components/ImagePromotion/utils.ts +++ b/libs/ui-components/src/components/ImagePromotion/utils.ts @@ -1,6 +1,13 @@ import { TFunction } from 'react-i18next'; import * as Yup from 'yup'; -import { ExistingCatalogItemTarget, ImagePromotion, NewCatalogItemTarget } from '@flightctl/types/imagebuilder'; +import { + ExistingCatalogItemTarget, + ExportFormatType, + ImagePromotion, + ImagePromotionConditionReason, + ImagePromotionConditionType, + NewCatalogItemTarget, +} from '@flightctl/types/imagebuilder'; import { CatalogItem } from '@flightctl/types/alpha'; import semver from 'semver'; @@ -13,6 +20,58 @@ import { optionalSemverRange, } from '../Catalog/AddCatalogItemWizard/utils'; +const NON_EDITABLE_PROMOTION_REASONS = new Set([ + ImagePromotionConditionReason.ImagePromotionConditionReasonFailed, + ImagePromotionConditionReason.ImagePromotionConditionReasonBuildFailed, + ImagePromotionConditionReason.ImagePromotionConditionReasonBuildCanceled, + ImagePromotionConditionReason.ImagePromotionConditionReasonPublishing, +]); + +const getImagePromotionReadyReason = (promotion: ImagePromotion): ImagePromotionConditionReason | undefined => { + const readyCondition = promotion.status?.conditions?.find( + (c) => c.type === ImagePromotionConditionType.ImagePromotionConditionTypeReady, + ); + return readyCondition?.reason as ImagePromotionConditionReason | undefined; +}; + +// Currently only true if additional export formats can be appended to the promotion. +export const canPromotionBeEdited = (promotion: ImagePromotion, availableFormats: ExportFormatType[]): boolean => { + const reason = getImagePromotionReadyReason(promotion); + if (reason && NON_EDITABLE_PROMOTION_REASONS.has(reason)) { + return false; + } + const currentFormats = promotion.spec.source.exportFormats || []; + return availableFormats.some((format) => !currentFormats.includes(format)); +}; + +export const getPromotionEditDisabledReason = ( + promotion: ImagePromotion, + availableFormats: ExportFormatType[], + canEdit: boolean, + t: TFunction, +): string | undefined => { + if (!canEdit) { + return t('You do not have permissions to update image promotions'); + } + if (canPromotionBeEdited(promotion, availableFormats)) { + return undefined; + } + + const reason = getImagePromotionReadyReason(promotion); + switch (reason) { + case ImagePromotionConditionReason.ImagePromotionConditionReasonPublishing: + return t('Image promotion cannot be edited while publishing is in progress'); + case ImagePromotionConditionReason.ImagePromotionConditionReasonFailed: + return t('Failed image promotions cannot be edited. Create a new image promotion to retry.'); + case ImagePromotionConditionReason.ImagePromotionConditionReasonBuildFailed: + return t('Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.'); + case ImagePromotionConditionReason.ImagePromotionConditionReasonBuildCanceled: + return t('Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.'); + default: + return t('All export formats from this image build are already included in this promotion'); + } +}; + export const getEditInitialValues = (imagePromotion: ImagePromotion): ImagePromotionFormValues => { const target = imagePromotion.spec.target; const isExisting = target.type === ExistingCatalogItemTarget.type.EXISTING_CATALOG_ITEM;