diff --git a/frontend/packages/react-form-wizard/src/inputs/Input.ts b/frontend/packages/react-form-wizard/src/inputs/Input.ts index df3c5193712..66ceefe8a5c 100644 --- a/frontend/packages/react-form-wizard/src/inputs/Input.ts +++ b/frontend/packages/react-form-wizard/src/inputs/Input.ts @@ -43,6 +43,8 @@ export type InputCommonProps = { secret?: boolean inputValueToPathValue?: (inputValue: unknown, pathValue: unknown) => unknown + /** When set with a shared `path`, appends a stable review-registration suffix from the current input value (e.g. sync option key). */ + inputValueToPathString?: (value: unknown) => string pathValueToInputValue?: (pathValue: unknown) => unknown onValueChange?: (value: unknown, item?: any) => void } @@ -161,9 +163,8 @@ export function useInput(props: InputCommonProps, options?: { isArrayInput?: boo props.path, item ) - if (props.inputValueToPathValue) { - const transformed = props.inputValueToPathValue(true, false) - registrationPath = `${registrationPath}#${JSON.stringify(transformed)}` + if (props.inputValueToPathString) { + registrationPath = `${registrationPath}${props.inputValueToPathString(value)}` } if (props.id) { diff --git a/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx b/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx index bf795769368..efcb08be39e 100644 --- a/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx +++ b/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx @@ -30,7 +30,6 @@ export type WizCustomWrapperInputProps = WizCustomWrapperBase & { nonEditable?: boolean /** When set, the review row renders as a PatternFly Alert instead of a description-list entry. */ alertVariant?: 'info' | 'warning' | 'danger' | 'success' - inputValueToPathValue?: (inputValue: unknown, pathValue: unknown) => unknown } export type WizCustomWrapperGroupProps = WizCustomWrapperBase & { @@ -48,7 +47,6 @@ export function WizCustomWrapper(props: WizCustomWrapperProps) { const value = isGroup ? undefined : props.value const nonEditable = isGroup ? undefined : props.nonEditable const alertVariant = isGroup ? undefined : props.alertVariant - const inputValueToPathValue = isGroup ? undefined : props.inputValueToPathValue const hidden = useInputHidden(props) const item = useContext(ItemContext) @@ -58,10 +56,6 @@ export function WizCustomWrapper(props: WizCustomWrapperProps) { const bumpReviewDomTree = useBumpReviewDomTree() let registrationPath = buildReviewInputRegistrationPath(reviewPathPrefixSegments, path, item) - if (!isGroup && inputValueToPathValue) { - const transformed = inputValueToPathValue(true, false) - registrationPath = `${registrationPath}#${JSON.stringify(transformed)}` - } if (idProp) { registrationPath = `${registrationPath};id=${idProp}` diff --git a/frontend/packages/react-form-wizard/src/review/ReviewStep.tsx b/frontend/packages/react-form-wizard/src/review/ReviewStep.tsx index 2d862f4561a..95f6adfeaf4 100644 --- a/frontend/packages/react-form-wizard/src/review/ReviewStep.tsx +++ b/frontend/packages/react-form-wizard/src/review/ReviewStep.tsx @@ -39,7 +39,7 @@ import { InputReviewMeta, useStepRegister, type WizardDomTreeNode } from './Revi import { ReviewPenHoverZone, useReviewEditHandler, type OnReviewEditHandler } from './ReviewStepNavigation' import { ReviewStepFindList } from './ReviewStepFindList' import { ReviewStepToolbar, useReviewExpandCollapseHandlers, type ReviewToolbarAction } from './ReviewStepToolbar' -import { horizontalTermWidthModifierForInputRun, REVIEW_ERROR_TEXT_COLOR } from './utils' +import { horizontalTermWidthModifierForInputRun, REVIEW_ERROR_TEXT_COLOR, simplifyLabels } from './utils' import { Step } from '../Step' import './ReviewStep.css' @@ -72,6 +72,11 @@ type ReviewRenderCtx = { inputGroupMarginLeft: number /** Number of nested ARRAY_INPUT ancestors; top-level array body uses 0. */ arrayInputNesting: number + /** + * While rendering direct children of an {@link InputReviewMeta.ARRAY_INPUT}, the parent field's + * `error` (for the top-level instance expandable label). + */ + enclosingArrayInputError?: string onReviewEdit?: OnReviewEditHandler /** When false, hide the arrow control that highlights the field in YAML. */ showYaml?: boolean @@ -112,7 +117,7 @@ export function ReviewStep({ reviewStorageKey = 'default', showYaml }: ReviewSte for (const step of registered) { roots.push(...getWizardDomTreeRootChildren(step.tree)) } - return roots + return simplifyLabels(roots) }, [stepRegister, steps]) const wizardDomTree = useMemo((): WizardDomTreeNode | null => { @@ -416,7 +421,7 @@ export function ReviewSectionBody(props: { /** Collapsed review row: section {@link Title} plus summary {@link Badge}s derived from the section DOM tree. */ export function ReviewCollapsedContent(props: { - label: string + label: ReactNode node: WizardDomTreeNode onReviewEdit?: OnReviewEditHandler showYaml?: boolean @@ -840,7 +845,7 @@ function shouldShowArrayInstanceTitle( } function ReviewTopLevelArrayInstanceExpandable(props: { - toggleLabel: string + toggleLabel: ReactNode instanceNode: WizardDomTreeNode isExpanded?: boolean onExpandedChange?: (expanded: boolean) => void @@ -886,7 +891,7 @@ function ReviewTopLevelArrayInstanceExpandable(props: { /** Pen / YAML controls on the instance row only when collapsed; expanded state from review storage or local fallback. */ function TopLevelArrayInstancePenWrap(props: { storageKey: string - toggleLabel: string + toggleLabel: ReactNode instanceNode: WizardDomTreeNode onReviewEdit?: OnReviewEditHandler showYaml?: boolean @@ -1155,11 +1160,18 @@ function renderReviewArrayInputSection( ): ReactNode { const children = node.children ?? [] const marginLeft = reviewArrayInstanceMarginLeft(ctx.arrayInputNesting) + const arrayInputCtx: ReviewRenderCtx = { ...ctx, enclosingArrayInputError: node.error } return ( {children.map((child, index) => - renderReviewArrayInstanceContainer(child, ctx, afterDescriptionListGroup && index === 0, marginLeft, index) + renderReviewArrayInstanceContainer( + child, + arrayInputCtx, + afterDescriptionListGroup && index === 0, + marginLeft, + index + ) )} @@ -1197,8 +1209,21 @@ function renderReviewArrayInstanceContainer( ) + const topLevelLabelText = showTitle && node.label ? node.label : reviewNodeLabel(node) || `Item ${instanceIndex + 1}` + const topLevelArrayError = isTopLevelArrayInstance ? ctx.enclosingArrayInputError : undefined const topLevelToggleLabel = - showTitle && node.label ? node.label : reviewNodeLabel(node) || `Item ${instanceIndex + 1}` + topLevelArrayError != null && topLevelArrayError !== '' ? ( + + {topLevelLabelText} + + + + + + + ) : ( + topLevelLabelText + ) return ( diff --git a/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx b/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx index 44048df726e..b06b0aef867 100644 --- a/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx +++ b/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx @@ -51,6 +51,8 @@ export type InputReviewStepMeta = value: unknown label?: string type: InputReviewMeta.ARRAY_INSTANCE + /** Nearest enclosing wizard step `id` (set when building the review DOM tree). */ + stepId?: string } | { id: string @@ -89,7 +91,11 @@ export type WizardDomTreeNode = children?: WizardDomTreeNode[] }) | (Omit & { type: InputReviewMeta.ARRAY_INPUT; children?: WizardDomTreeNode[] }) - | (Extract & { children?: WizardDomTreeNode[] }) + | (Omit, 'type' | 'stepId'> & { + type: InputReviewMeta.ARRAY_INSTANCE + stepId: string + children?: WizardDomTreeNode[] + }) | (Extract & { children?: WizardDomTreeNode[] }) | { children?: WizardDomTreeNode[] } diff --git a/frontend/packages/react-form-wizard/src/review/ReviewStepNavigation.tsx b/frontend/packages/react-form-wizard/src/review/ReviewStepNavigation.tsx index 56c03cf3bc4..8c369d36324 100644 --- a/frontend/packages/react-form-wizard/src/review/ReviewStepNavigation.tsx +++ b/frontend/packages/react-form-wizard/src/review/ReviewStepNavigation.tsx @@ -1,6 +1,7 @@ /* Copyright Contributors to the Open Cluster Management project */ import { Button, DescriptionListDescription, DescriptionListTerm, useWizardContext } from '@patternfly/react-core' import { ArrowRightIcon, PenIcon } from '@patternfly/react-icons' +import get from 'get-value' import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, @@ -10,6 +11,7 @@ import { } from 'react' import { useHighlightEditorPath } from './ReviewStepContexts' import { InputReviewMeta, type WizardDomTreeNode } from './ReviewStepContexts' +import { useItem } from '../contexts/ItemContext' /** Pen / row click: go to step and scroll; arrow: set YAML editor highlight path only. */ export type ReviewEditIntent = 'navigate' | 'highlight' @@ -28,22 +30,31 @@ const REVIEW_EDIT_TARGET_HIGHLIGHT_AUTO_DISMISS_MS = 2000 const reviewEditHighlightTeardownByEl = new WeakMap void>() +/** Trailing wizard path annotation, e.g. `…#["CreateNamespace=true"]` (see ReviewStepFindList display notes). */ +const YAML_PATH_HASH_BRACKET_SUFFIX = /#\["([^"]*)"\]$/u + export function useReviewEditHandler(): OnReviewEditHandler { + const resources = useItem() const { goToStepById } = useWizardContext() const { setHighlightEditorPath } = useHighlightEditorPath() return useCallback( (node, intent = 'navigate') => { - const yamlPath = getReviewNodeYamlHighlightPath(node) + // highlight yaml in editor if (intent === 'highlight') { - if (yamlPath !== undefined) setHighlightEditorPath(yamlPath) - return + const yamlPath = getReviewNodeYamlHighlightPath(node) + if (yamlPath !== undefined && yamlPathBelongsToItem(yamlPath, resources)) { + setHighlightEditorPath(yamlPath) + return + } + // if highlight yaml fails, do link back to controls } + // link back to controls const stepId = getReviewNodeStepId(node) const domId = getReviewScrollTargetDomId(node) if (stepId) goToStepById(stepId) if (domId) scrollReviewEditTargetIntoView(domId) }, - [goToStepById, setHighlightEditorPath] + [goToStepById, resources, setHighlightEditorPath] ) } @@ -51,23 +62,104 @@ function isReviewInputNode(node: WizardDomTreeNode): node is WizardInputDomNode return 'type' in node && node.type === InputReviewMeta.INPUT } +function isReviewArrayInstanceNode( + node: WizardDomTreeNode +): node is Extract { + return 'type' in node && node.type === InputReviewMeta.ARRAY_INSTANCE +} + +/** Dot-path for `get-value` on a resource object (strip leading `kind.` when it matches the object). */ +function resourceGetPathForGetValue(target: object, normalized: string): string { + const ik = (target as { kind?: unknown }).kind + if (ik == null || String(ik) === '') return normalized + const k = String(ik) + if (normalized === k) return '' + if (normalized.startsWith(`${k}.`)) return normalized.slice(k.length + 1) + return normalized +} + +function bracketAnnotationKey(inner: string): string { + const eq = inner.indexOf('=') + return eq >= 0 ? inner.slice(0, eq) : inner +} + +const NON_ALPHANUMERIC = /[^a-zA-Z0-9]/u + +/** `value` is `key` or `key` followed by a boundary (next char not alphanumeric). */ +function arrayStringStartsWithKeyAtNonAlphanumericBoundary(value: string, key: string): boolean { + if (!value.startsWith(key)) return false + if (value.length === key.length) return true + return NON_ALPHANUMERIC.test(value[key.length]!) +} + +function yamlPathValueBelongsToTarget(target: object, normalized: string): boolean { + const resourcePath = resourceGetPathForGetValue(target, normalized) + if (resourcePath === '') return true + + const m = resourcePath.match(YAML_PATH_HASH_BRACKET_SUFFIX) + const pathWasAppended = m !== null + const basePath = pathWasAppended ? resourcePath.slice(0, m.index) : resourcePath + + const got = get(target, basePath) + if (got === undefined) return false + + if (pathWasAppended && Array.isArray(got)) { + const key = bracketAnnotationKey(m[1]) + if (key === '') return false + return got.some((el) => typeof el === 'string' && arrayStringStartsWithKeyAtNonAlphanumericBoundary(el, key)) + } + + return true +} + function isReviewSectionNode( node: WizardDomTreeNode ): node is Extract { return 'type' in node && node.type === InputReviewMeta.SECTION } +/** + * Whether `yamlPath` targets the current item / one of its resources. + * Aligns with SyncEditor highlight parsing: first `.` segment is resource `kind` when present. + */ +function yamlPathBelongsToItem(yamlPath: string, item: unknown): boolean { + const clean = yamlPath.replace(/;id=[^;]*$/u, '').trim() + if (!clean) return false + const normalized = clean.replace(/\\\./g, '.') + const firstDot = normalized.indexOf('.') + const kindHead = firstDot === -1 ? normalized : normalized.slice(0, firstDot) + + let target: unknown = item + if (Array.isArray(item)) { + target = item.find((res) => { + if (!res || typeof res !== 'object') return false + const rk = (res as { kind?: unknown }).kind + return rk != null && String(rk) !== '' && String(rk) === kindHead + }) + if (!target) return false + } + + if (target && typeof target === 'object') { + return yamlPathValueBelongsToTarget(target as object, normalized) + } + + return false +} + /** Dot path for YAML editor highlight: matches review registration path without `;id=` suffix. */ function getReviewNodeYamlHighlightPath(node: WizardDomTreeNode): string | undefined { if (!('path' in node) || typeof node.path !== 'string' || node.path === '') return undefined return node.path.replace(/;id=[^;]*$/u, '') } -/** Wizard step id for navigating from review: explicit on INPUT / SECTION, else first descendant INPUT's `stepId`. */ +/** Wizard step id for navigating from review: explicit on INPUT / ARRAY_INSTANCE / SECTION, else first descendant INPUT's `stepId`. */ function getReviewNodeStepId(node: WizardDomTreeNode): string | undefined { if (isReviewInputNode(node)) { return node.stepId && node.stepId !== '' ? node.stepId : undefined } + if (isReviewArrayInstanceNode(node)) { + return node.stepId && node.stepId !== '' ? node.stepId : undefined + } if (isReviewSectionNode(node)) { for (const c of node.children ?? []) { const id = getReviewNodeStepId(c) diff --git a/frontend/packages/react-form-wizard/src/review/utils.ts b/frontend/packages/react-form-wizard/src/review/utils.ts index 95bb90612c2..45be4c4dec8 100644 --- a/frontend/packages/react-form-wizard/src/review/utils.ts +++ b/frontend/packages/react-form-wizard/src/review/utils.ts @@ -40,7 +40,7 @@ export function buildTree( * `stepInputMap`. Elements without metadata are skipped as nodes; their descendants are still * visited so nested registered controls are not lost. * - * `parentStepId` is threaded so INPUT nodes can be associated with the enclosing wizard step. + * `parentStepId` is threaded so INPUT and ARRAY_INSTANCE nodes can be associated with the enclosing wizard step. * `reviewPathPrefixSegments` accumulates ARRAY_INPUT field paths and ARRAY_INSTANCE index segments * along the DOM path (in order); registration code uses this for array-aware review paths. */ @@ -87,8 +87,8 @@ function buildReviewSubtree( /* ARRAY_INSTANCE: repeat-group row or similar; `path` often carries the instance index segment. */ return [ hasChildren - ? { ...props, type: InputReviewMeta.ARRAY_INSTANCE, children } - : { ...props, type: InputReviewMeta.ARRAY_INSTANCE }, + ? { ...props, type: InputReviewMeta.ARRAY_INSTANCE, stepId: parentStepId, children } + : { ...props, type: InputReviewMeta.ARRAY_INSTANCE, stepId: parentStepId }, ] } } @@ -145,3 +145,30 @@ export function horizontalTermWidthModifierForInputRun( } return maxLen < 64 ? REVIEW_HORIZONTAL_TERM_WIDTH_COMPACT : REVIEW_HORIZONTAL_TERM_WIDTH_WIDE } + +/** For each whitespace-delimited word in `label`, if it contains `/`, drop the prefix through the first `/`. */ +function simplifyLabelWordSlashes(label: string): string { + return label.replace(/\S+/g, (word) => { + const i = word.indexOf('/') + if (i === -1) return word + return word.slice(i + 1) + }) +} + +/** + * Returns a deep copy of `roots` with the same shape, but every `label` simplified per + * {@link simplifyLabelWordSlashes}. Does not mutate the input trees. + */ +export function simplifyLabels(roots: readonly WizardDomTreeNode[]): WizardDomTreeNode[] { + return roots.map(simplifyLabelsInNode) +} + +function simplifyLabelsInNode(node: WizardDomTreeNode): WizardDomTreeNode { + const children = node.children?.map(simplifyLabelsInNode) + const next = children !== undefined ? { ...node, children } : node + + if ('label' in next && next.label !== undefined) { + return { ...next, label: simplifyLabelWordSlashes(next.label) } + } + return next +} diff --git a/frontend/packages/react-form-wizard/wizards/Argo/ArgoWizard.tsx b/frontend/packages/react-form-wizard/wizards/Argo/ArgoWizard.tsx index 55b1a29b500..c6b4bf6e144 100644 --- a/frontend/packages/react-form-wizard/wizards/Argo/ArgoWizard.tsx +++ b/frontend/packages/react-form-wizard/wizards/Argo/ArgoWizard.tsx @@ -484,6 +484,7 @@ export function ArgoWizard(props: ArgoWizardProps) { label="Delete resources that are no longer defined in the source repository at the end of a sync operation" path="spec.template.spec.syncPolicy.syncOptions" inputValueToPathValue={booleanToSyncOptions('PruneLast')} + inputValueToPathString={inputValueToPathString('PruneLast')} pathValueToInputValue={syncOptionsToBoolean('PruneLast')} /> @@ -503,6 +505,7 @@ export function ArgoWizard(props: ArgoWizardProps) { label="Only synchronize out-of-sync resources" path="spec.template.spec.syncPolicy.syncOptions" inputValueToPathValue={booleanToSyncOptions('ApplyOutOfSyncOnly')} + inputValueToPathString={inputValueToPathString('ApplyOutOfSyncOnly')} pathValueToInputValue={syncOptionsToBoolean('ApplyOutOfSyncOnly')} /> @@ -732,6 +739,25 @@ function syncOptionsToBoolean(key: string) { } } +function inputValueToPathString(key: string) { + return (value: unknown) => { + if (typeof value === 'boolean') { + return `#${JSON.stringify([`${key}=${value.toString()}`])}` + } + if (typeof value === 'string') { + return `#${JSON.stringify([`${key}=${value}`])}` + } + return `#${JSON.stringify([`${key}=${String(value)}`])}` + } +} + +function prunePropagationPolicyCheckboxToPathString(value: unknown) { + if (value === true) { + return `#${JSON.stringify(['PrunePropagationPolicy=background'])}` + } + return `#${JSON.stringify([])}` +} + function checkboxPrunePropagationPolicyToSyncOptions(value: unknown, array: unknown) { let newArray: unknown[] if (Array.isArray(array)) { diff --git a/frontend/src/components/SyncEditor/decorate.ts b/frontend/src/components/SyncEditor/decorate.ts index dfe47efacd7..6b2b18a57ff 100644 --- a/frontend/src/components/SyncEditor/decorate.ts +++ b/frontend/src/components/SyncEditor/decorate.ts @@ -238,21 +238,28 @@ const scrollToChangeDecoration = (editor: editorTypes.IStandaloneCodeEditor, err editor.revealLineInCenter(errorLine) }) } else if (decorations.length) { - // if visible range doesn't show any scroll-to decorations, scroll to the first one - const scrollToDecorations = decorations.filter( - (decoration) => - decoration.options.linesDecorationsClassName === 'insertedLineDecoration' || - decoration.options.className === 'syncEditorYamlHighlight' + const yamlHighlightDecorations = decorations.filter( + (decoration) => decoration.options.className === 'syncEditorYamlHighlight' ) - if ( - scrollToDecorations.length && - !scrollToDecorations.some((decoration) => { - return visibleRange.containsPosition(decoration?.range.getStartPosition()) - }) - ) { + if (yamlHighlightDecorations.length) { setTimeout(() => { - editor.revealLineInCenter(scrollToDecorations[0]?.range.getStartPosition()?.lineNumber) + editor.revealLineInCenter(yamlHighlightDecorations[0]?.range.getStartPosition()?.lineNumber) }) + } else { + // if visible range doesn't show any inserted-line decorations, scroll to the first one + const insertedLineDecorations = decorations.filter( + (decoration) => decoration.options.linesDecorationsClassName === 'insertedLineDecoration' + ) + if ( + insertedLineDecorations.length && + !insertedLineDecorations.some((decoration) => { + return visibleRange.containsPosition(decoration?.range.getStartPosition()) + }) + ) { + setTimeout(() => { + editor.revealLineInCenter(insertedLineDecorations[0]?.range.getStartPosition()?.lineNumber) + }) + } } } } diff --git a/frontend/src/wizards/Argo/ArgoWizard.tsx b/frontend/src/wizards/Argo/ArgoWizard.tsx index 361bd9e9fa6..34c34b0276e 100644 --- a/frontend/src/wizards/Argo/ArgoWizard.tsx +++ b/frontend/src/wizards/Argo/ArgoWizard.tsx @@ -695,6 +695,26 @@ function syncOptionsToBoolean(key: string) { } } +function inputValueToPathString(key: string) { + return (value: unknown) => { + if (typeof value === 'boolean') { + return `#${JSON.stringify([`${key}=${value.toString()}`])}` + } + if (typeof value === 'string') { + return `#${JSON.stringify([`${key}=${value}`])}` + } + return `#${JSON.stringify([`${key}=${String(value)}`])}` + } +} + +/** Matches `checkboxPrunePropagationPolicyToSyncOptions`: enabled state adds `PrunePropagationPolicy=background`. */ +function prunePropagationPolicyCheckboxToPathString(value: unknown) { + if (value === true) { + return `#${JSON.stringify(['PrunePropagationPolicy=background'])}` + } + return `#${JSON.stringify([])}` +} + function checkboxPrunePropagationPolicyToSyncOptions(value: unknown, array: unknown) { let newArray: unknown[] if (Array.isArray(array)) { @@ -773,6 +793,7 @@ function ArgoSyncPolicySection() { label={t('Delete resources that are no longer defined in the source repository at the end of a sync operation')} path="spec.template.spec.syncPolicy.syncOptions" inputValueToPathValue={booleanToSyncOptions('PruneLast')} + inputValueToPathString={inputValueToPathString('PruneLast')} pathValueToInputValue={syncOptionsToBoolean('PruneLast')} />