Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions frontend/packages/react-form-wizard/src/inputs/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type InputCommonProps<ValueT = any> = {
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
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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)
Expand All @@ -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}`
Expand Down
39 changes: 32 additions & 7 deletions frontend/packages/react-form-wizard/src/review/ReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -840,7 +845,7 @@ function shouldShowArrayInstanceTitle(
}

function ReviewTopLevelArrayInstanceExpandable(props: {
toggleLabel: string
toggleLabel: ReactNode
instanceNode: WizardDomTreeNode
isExpanded?: boolean
onExpandedChange?: (expanded: boolean) => void
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1155,11 +1160,18 @@ function renderReviewArrayInputSection(
): ReactNode {
const children = node.children ?? []
const marginLeft = reviewArrayInstanceMarginLeft(ctx.arrayInputNesting)
const arrayInputCtx: ReviewRenderCtx = { ...ctx, enclosingArrayInputError: node.error }
return (
<ReviewDomTreeNodeShell key={`array-${node.path}`}>
<Fragment>
{children.map((child, index) =>
renderReviewArrayInstanceContainer(child, ctx, afterDescriptionListGroup && index === 0, marginLeft, index)
renderReviewArrayInstanceContainer(
child,
arrayInputCtx,
afterDescriptionListGroup && index === 0,
marginLeft,
index
)
)}
</Fragment>
</ReviewDomTreeNodeShell>
Expand Down Expand Up @@ -1197,8 +1209,21 @@ function renderReviewArrayInstanceContainer(
</div>
)

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 !== '' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{topLevelLabelText}
<Tooltip content={topLevelArrayError}>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<ExclamationCircleIcon color={REVIEW_ERROR_TEXT_COLOR} />
</span>
</Tooltip>
</span>
) : (
topLevelLabelText
)

return (
<ReviewDomTreeNodeShell key={key}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +91,11 @@ export type WizardDomTreeNode =
children?: WizardDomTreeNode[]
})
| (Omit<InputOrArrayInputMeta, 'type'> & { type: InputReviewMeta.ARRAY_INPUT; children?: WizardDomTreeNode[] })
| (Extract<InputReviewStepMeta, { type: InputReviewMeta.ARRAY_INSTANCE }> & { children?: WizardDomTreeNode[] })
| (Omit<Extract<InputReviewStepMeta, { type: InputReviewMeta.ARRAY_INSTANCE }>, 'type' | 'stepId'> & {
type: InputReviewMeta.ARRAY_INSTANCE
stepId: string
children?: WizardDomTreeNode[]
})
| (Extract<InputReviewStepMeta, { type: InputReviewMeta.GROUP }> & { children?: WizardDomTreeNode[] })
| { children?: WizardDomTreeNode[] }

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand All @@ -28,46 +30,136 @@ const REVIEW_EDIT_TARGET_HIGHLIGHT_AUTO_DISMISS_MS = 2000

const reviewEditHighlightTeardownByEl = new WeakMap<Element, () => 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<OnReviewEditHandler>(
(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]
)
}

function isReviewInputNode(node: WizardDomTreeNode): node is WizardInputDomNode {
return 'type' in node && node.type === InputReviewMeta.INPUT
}

function isReviewArrayInstanceNode(
node: WizardDomTreeNode
): node is Extract<WizardDomTreeNode, { type: InputReviewMeta.ARRAY_INSTANCE }> {
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<WizardDomTreeNode, { type: InputReviewMeta.SECTION }> {
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)
Expand Down
33 changes: 30 additions & 3 deletions frontend/packages/react-form-wizard/src/review/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 },
]
}
}
Expand Down Expand Up @@ -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
}
Loading
Loading