From 5b4a1e32bdf841755db698842770ae2765f9d134 Mon Sep 17 00:00:00 2001 From: David Miculit Date: Fri, 21 Aug 2026 21:42:00 +0300 Subject: [PATCH 1/3] feat: initial version of a schema properties modal which syncs with a readOnly payload textarea --- ui/job-view/CustomJobActions.jsx | 299 +++++++++++++++++++++++++++---- 1 file changed, 268 insertions(+), 31 deletions(-) diff --git a/ui/job-view/CustomJobActions.jsx b/ui/job-view/CustomJobActions.jsx index 8b0e51fc68f..9d7991130e0 100644 --- a/ui/job-view/CustomJobActions.jsx +++ b/ui/job-view/CustomJobActions.jsx @@ -16,6 +16,7 @@ import { checkRootUrl } from '../taskcluster-auth-callback/constants'; import { notify } from '../shared/stores/notificationStore'; import { usePushesStore } from '../shared/stores/pushesStore'; +import SimpleTooltip from '../shared/SimpleTooltip'; class CustomJobActions extends React.PureComponent { constructor(props) { @@ -31,6 +32,8 @@ class CustomJobActions extends React.PureComponent { selectedAction: {}, schema: '', payload: '', + payloadObject: {}, + inputValues: {}, dropdownOpen: false, }; } @@ -76,33 +79,175 @@ class CustomJobActions extends React.PureComponent { onChangeAction = (actionName) => { const { actions } = this.state; - const selectedAction = actions[actionName]; + const selectedAction = actions?.[actionName]; - if (actionName) { - this.setState({ selectedAction }); + if (actionName && selectedAction) { + this.setState({ dropdownOpen: false }); this.updateSelectedAction(selectedAction); } }; - onChangePayload(payload) { - this.setState({ payload }); - } - - toggleDropdown = () => { - this.setState((prevState) => ({ dropdownOpen: !prevState.dropdownOpen })); + toggleDropdown = (isOpen) => { + this.setState((prevState) => ({ + dropdownOpen: typeof isOpen === 'boolean' ? isOpen : !prevState.dropdownOpen + })); }; updateSelectedAction = (action) => { const { ajv } = this.state; - if (action.schema) { + if (action?.schema) { + let defaults = {}; + try { + defaults = jsonSchemaDefaults(action.schema) || {}; + } catch (_e) { + defaults = {}; + } + + const cleanPayloadObj = {}; + const initialInputValues = {}; + + if (defaults && typeof defaults === 'object') { + Object.entries(defaults).forEach(([key, val]) => { + if (val !== '' && val !== null && val !== undefined) { + cleanPayloadObj[key] = val; + initialInputValues[key] = + typeof val === 'object' ? JSON.stringify(val) : String(val); + } + }); + } + this.setState({ + selectedAction: action, schema: jsyaml.dump(action.schema), - payload: jsyaml.dump(jsonSchemaDefaults(action.schema)), + payloadObject: cleanPayloadObj, + inputValues: initialInputValues, + payload: Object.keys(cleanPayloadObj).length ? jsyaml.dump(cleanPayloadObj) : '', validate: ajv.compile(action.schema), }); } else { - this.setState({ schema: null, payload: null, validate: null }); + this.setState({ + selectedAction: action || {}, + schema: null, + payloadObject: {}, + inputValues: {}, + payload: null, + validate: null, + }); + } + }; + + handlePropertyChange = (key, rawValue, propSchema = {}) => { + const type = propSchema.type; + const isEnum = Array.isArray(propSchema.enum); + + this.setState((prevState) => { + const nextPayloadObj = { ...prevState.payloadObject }; + const nextInputValues = { ...prevState.inputValues }; + + if (rawValue === '' || rawValue === null || rawValue === undefined) { + delete nextPayloadObj[key]; + delete nextInputValues[key]; + } else if (isEnum) { + const matchedEnum = propSchema.enum.find((opt) => String(opt) === rawValue); + if (matchedEnum !== undefined) { + nextPayloadObj[key] = matchedEnum; + nextInputValues[key] = String(matchedEnum); + } else { + delete nextPayloadObj[key]; + delete nextInputValues[key]; + } + } else if (type === 'number' || type === 'integer') { + const allowNegative = propSchema.minimum === undefined || propSchema.minimum < 0; + let sanitized = rawValue; + + if (type === 'integer') { + sanitized = allowNegative + ? rawValue.replace(/(?!^-)[^0-9]/g, '') + : rawValue.replace(/[^0-9]/g, ''); + } else { + sanitized = allowNegative + ? rawValue.replace(/(?!^-)[^0-9.]/g, '') + : rawValue.replace(/[^0-9.]/g, ''); + + const parts = sanitized.split('.'); + if (parts.length > 2) { + sanitized = `${parts[0]}.${parts.slice(1).join('')}`; + } + } + + if (sanitized !== '' && sanitized !== '-') { + let numVal = Number(sanitized); + + if (!Number.isNaN(numVal)) { + if (propSchema.maximum !== undefined && numVal > propSchema.maximum) { + numVal = propSchema.maximum; + sanitized = String(propSchema.maximum); + } + nextPayloadObj[key] = numVal; + } else { + delete nextPayloadObj[key]; + } + } else { + delete nextPayloadObj[key]; + } + + nextInputValues[key] = sanitized; + } else if (type === 'boolean') { + const boolVal = rawValue === 'true'; + nextPayloadObj[key] = boolVal; + nextInputValues[key] = String(boolVal); + } else { + nextPayloadObj[key] = rawValue; + nextInputValues[key] = rawValue; + } + + const yamlPayload = Object.keys(nextPayloadObj).length > 0 ? jsyaml.dump(nextPayloadObj) : ''; + + return { + payloadObject: nextPayloadObj, + inputValues: nextInputValues, + payload: yamlPayload, + }; + }); + }; + + handleNumberBlur = (key, propSchema) => { + const { inputValues } = this.state; + const currentInput = inputValues[key]; + + if (currentInput === undefined || currentInput === null || currentInput === '') { + return; + } + + let cleaned = currentInput; + if (cleaned.endsWith('.')) { + cleaned = cleaned.slice(0, -1); + } + if (cleaned === '-' || cleaned === '-.' || cleaned === '.') { + cleaned = ''; + } + + if (cleaned === '') { + this.handlePropertyChange(key, '', propSchema); + return; + } + + let numVal = Number(cleaned); + if (!Number.isNaN(numVal)) { + if (propSchema.minimum !== undefined && numVal < propSchema.minimum) { + numVal = propSchema.minimum; + } + if (propSchema.maximum !== undefined && numVal > propSchema.maximum) { + numVal = propSchema.maximum; + } + + const targetValStr = String(numVal); + if (targetValStr !== currentInput) { + this.handlePropertyChange(key, targetValStr, propSchema); + } + } else { + this.handlePropertyChange(key, '', propSchema); } }; @@ -111,7 +256,7 @@ class CustomJobActions extends React.PureComponent { const { ajv, validate, - payload, + payloadObject, decisionTaskId, originalTaskId, originalTask, @@ -120,16 +265,10 @@ class CustomJobActions extends React.PureComponent { } = this.state; const { currentRepo } = this.props; - let input = null; - if (validate && payload) { - try { - input = jsyaml.load(payload); - } catch (e) { - this.setState({ triggering: false }); - notify(`YAML Error: ${e.message}`, 'danger'); - return; - } - const valid = validate(input); + const input = Object.keys(payloadObject).length > 0 ? payloadObject : null; + + if (validate) { + const valid = validate(input || {}); if (!valid) { this.setState({ triggering: false }); notify(ajv.errorsText(validate.errors), 'danger'); @@ -186,6 +325,103 @@ class CustomJobActions extends React.PureComponent { } }; + renderPropertyFields = () => { + const { selectedAction, inputValues } = this.state; + const properties = selectedAction?.schema?.properties; + + if (!properties || Object.keys(properties).length === 0) { + return null; + } + + return ( +
+
Action Properties
+
+ {Object.entries(properties).map(([propKey, propSchema]) => { + const inputValue = inputValues[propKey] ?? ''; + const isNumber = propSchema.type === 'number' || propSchema.type === 'integer'; + const isBoolean = propSchema.type === 'boolean'; + const isEnum = Array.isArray(propSchema.enum); + + let rangeSuffix = ''; + if (propSchema.minimum !== undefined && propSchema.maximum !== undefined) { + rangeSuffix = ` (${propSchema.minimum}-${propSchema.maximum})`; + } else if (propSchema.minimum !== undefined) { + rangeSuffix = ` (min: ${propSchema.minimum})`; + } else if (propSchema.maximum !== undefined) { + rangeSuffix = ` (max: ${propSchema.maximum})`; + } + + const displayName = `${propKey}${rangeSuffix}`; + + const labelContent = propSchema.description ? ( + + ) : ( + {displayName} + ); + + return ( +
+ + {labelContent} + + + {isEnum ? ( + + this.handlePropertyChange(propKey, e.target.value, propSchema) + } + > + + {propSchema.enum.map((opt) => ( + + ))} + + ) : isBoolean ? ( + + this.handlePropertyChange(propKey, e.target.value, propSchema) + } + > + + + + + ) : isNumber ? ( + this.handleNumberBlur(propKey, propSchema)} + onChange={(e) => + this.handlePropertyChange(propKey, e.target.value, propSchema) + } + /> + ) : ( + + this.handlePropertyChange(propKey, e.target.value, propSchema) + } + /> + )} +
+ ); + })} +
+
+ ); + }; + render() { const { toggle } = this.props; const { triggering, selectedAction, schema, actions, payload } = this.state; @@ -205,7 +441,7 @@ class CustomJobActions extends React.PureComponent { {!!actions && (
- Action + Action )}
+ + {this.renderPropertyFields()} +
{!!selectedAction.schema && (
- + Payload