Skip to content

Commit dc2376a

Browse files
feat(inputs): FORM input type with a Next/Back execution wizard (#16655)
Add the FORM input type and render FORM-grouped inputs as a multi-step Next/Back wizard (shared by the Flow Execute modal and EE Apps), and surface input render/resolution failures eagerly. - Wizard: one step per FORM + one per contiguous run of ungrouped inputs + a recap; per-section Edit, Back/Next, value persistence; delay-gated "Loading" on Next; payload-signature dedup + in-flight coalesce of validates. - unflattenToForms() rebuilds the FORM tree from flat dotted leaves + formGroups so EE Apps drives the same wizard; promisified @Validation emit. - Surface render errors (a SELECT expression/subflow(), or an input defaults Pebble expression, that fails to render) as soon as the input is shown — via a renderError flag on InputOutputValidationException, serialized in the execute-form validate response and read by InputsForm.inputError. Plain value errors (e.g. required-but-empty) stay gated until interaction. updateDefaults no longer pre-fills an unrendered expression-default (which masked the error). - FORM child labels show the bare name; unit + storybook coverage.
1 parent 050a539 commit dc2376a

46 files changed

Lines changed: 2523 additions & 293 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/src/main/java/io/kestra/core/exceptions/InputOutputValidationException.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,37 @@
1212
public class InputOutputValidationException extends KestraRuntimeException {
1313
private static final long serialVersionUID = 1L;
1414

15+
/**
16+
* Whether this error comes from rendering/resolving the input (e.g. a SELECT's {@code expression}
17+
* or an input's {@code defaults} using a Pebble function that failed) rather than from validating a
18+
* provided value (e.g. a required input left empty). A render error means the field itself is broken.
19+
*/
20+
private final boolean renderError;
21+
1522
public InputOutputValidationException(String message) {
23+
this(message, false);
24+
}
25+
26+
public InputOutputValidationException(String message, boolean renderError) {
1627
super(message);
28+
this.renderError = renderError;
29+
}
30+
31+
public boolean isRenderError() {
32+
return renderError;
1733
}
1834

1935
public static InputOutputValidationException of(String message, Input<?> input) {
2036
String inputMessage = "Invalid value for input" + " `" + input.getId() + "`. Cause: " + message;
2137
return new InputOutputValidationException(inputMessage);
2238
}
2339

40+
/** As {@link #of(String, Input)}, but flags the error as a render/resolution failure (broken field). */
41+
public static InputOutputValidationException ofRenderError(String message, Input<?> input) {
42+
String inputMessage = "Invalid value for input" + " `" + input.getId() + "`. Cause: " + message;
43+
return new InputOutputValidationException(inputMessage, true);
44+
}
45+
2446
public static InputOutputValidationException of(String message, Output output) {
2547
String outputMessage = "Invalid value for output" + " `" + output.getId() + "`. Cause: " + message;
2648
return new InputOutputValidationException(outputMessage);

core/src/main/java/io/kestra/core/models/flows/FlowInterface.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,32 @@ public interface FlowInterface extends FlowId, SoftDeletable<FlowInterface>, Ten
4141

4242
List<Label> getLabels();
4343

44+
/**
45+
* The inputs as authored in the flow source, which may contain {@link io.kestra.core.models.flows.input.FormInput}
46+
* grouping nodes. This is the structure meant for serialization and the UI.
47+
* <p>
48+
* <strong>Do not use this when resolving, validating, or turning inputs into variables</strong> — use
49+
* {@link #resolvableInputs()} instead, which expands every {@code FORM} into its dotted-path leaves. Reaching for
50+
* {@code getInputs()} on a resolution path and forgetting to expand silently leaks {@code FORM} nodes into the
51+
* flat keyspace.
52+
*/
4453
List<Input<?>> getInputs();
4554

55+
/**
56+
* The inputs flattened for resolution: every {@link io.kestra.core.models.flows.input.FormInput} is expanded into
57+
* its dotted-path leaves (see {@link Input#expandToLeaves(List)}).
58+
* <p>
59+
* Use this anywhere inputs are resolved, validated, or turned into variables. {@link #getInputs()} returns the
60+
* authored structure (which may contain {@code FORM} nodes) and is meant for serialization and the UI only —
61+
* reaching for it during resolution and forgetting to expand silently leaks {@code FORM} nodes into the flat
62+
* keyspace.
63+
*/
64+
@JsonIgnore
65+
default List<Input<?>> resolvableInputs() {
66+
List<Input<?>> inputs = getInputs();
67+
return inputs == null ? List.of() : Input.expandToLeaves(inputs);
68+
}
69+
4670
List<Output> getOutputs();
4771

4872
Map<String, Object> getVariables();

core/src/main/java/io/kestra/core/models/flows/Input.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import io.kestra.core.models.flows.input.*;
77
import io.kestra.core.models.property.Property;
8+
import io.kestra.core.serializers.JacksonMapper;
89
import io.kestra.core.validations.InputValidation;
910

1011
import io.swagger.v3.oas.annotations.media.Schema;
@@ -18,6 +19,10 @@
1819
import lombok.NoArgsConstructor;
1920
import lombok.experimental.SuperBuilder;
2021

22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.Map;
25+
2126
@SuperBuilder
2227
@Getter
2328
@NoArgsConstructor
@@ -41,6 +46,7 @@
4146
@JsonSubTypes.Type(value = MultiselectInput.class, name = "MULTISELECT"),
4247
@JsonSubTypes.Type(value = YamlInput.class, name = "YAML"),
4348
@JsonSubTypes.Type(value = EmailInput.class, name = "EMAIL"),
49+
@JsonSubTypes.Type(value = FormInput.class, name = "FORM"),
4450
}
4551
)
4652
@InputValidation
@@ -90,4 +96,59 @@ public abstract class Input<T> implements Data {
9096
String displayName;
9197

9298
public abstract void validate(T input) throws ConstraintViolationException;
99+
100+
/**
101+
* Expands every {@link io.kestra.core.models.flows.input.FormInput} into copies of its children whose id is
102+
* rewritten to the dotted path ({@code environment} + {@code .} + {@code region} -> {@code environment.region}).
103+
* <p>
104+
* The resolution core keys inputs by {@link #getId()} and reassembles dotted keys into a nested map via
105+
* {@link io.kestra.core.utils.MapUtils#flattenToNestedMap(java.util.Map)}, so a leaf carrying the dotted id is all
106+
* that is needed for the nested payload to materialize. {@code dependsOn} is intentionally NOT rewritten — authors
107+
* reference siblings by their full dotted path; an unresolved bare ref is rejected loudly by flow validation.
108+
* Forms cannot be nested (enforced by validation), so expansion is single-level.
109+
*
110+
* @return a flat list of leaf inputs with no {@code FORM} node surviving, or the input list unchanged when null/empty.
111+
*/
112+
public static List<Input<?>> expandToLeaves(List<Input<?>> inputs) {
113+
if (inputs == null || inputs.isEmpty()) {
114+
return inputs;
115+
}
116+
117+
List<Input<?>> leaves = new ArrayList<>();
118+
for (Input<?> input : inputs) {
119+
if (input instanceof io.kestra.core.models.flows.input.FormInput form) {
120+
if (form.getInputs() != null) {
121+
for (Input<?> child : form.getInputs()) {
122+
leaves.add(copyWithId(child, form.getId() + "." + child.getId()));
123+
}
124+
}
125+
} else {
126+
leaves.add(input);
127+
}
128+
}
129+
return leaves;
130+
}
131+
132+
/**
133+
* Copies {@code input} with its {@code id} replaced by {@code newId}, preserving the concrete subtype.
134+
* <p>
135+
* Done via a Jackson round-trip rather than a builder: {@code @SuperBuilder(toBuilder=...)} does not generate
136+
* {@code toBuilder()} on this abstract class, and the resolution core casts leaves to their concrete subtype
137+
* ({@code (ArrayInput) input}, {@code case StringInput i -> ...}). The round-trip re-resolves the subtype through
138+
* {@link JsonTypeInfo} on the {@code type} property, so {@code copyWithId(stringInput, ...) instanceof StringInput}.
139+
*/
140+
private static Input<?> copyWithId(Input<?> input, String newId) {
141+
Map<String, Object> map = JacksonMapper.toMap(input);
142+
map.put("id", newId);
143+
return JacksonMapper.toMap(map, Input.class);
144+
}
145+
146+
/**
147+
* @return all expanded leaf paths (e.g. {@code environment.region}, {@code credentials.api_key}, {@code api_key}),
148+
* used by uniqueness validation to reject duplicate paths and prefix conflicts.
149+
*/
150+
public static List<String> collectExpandedPaths(List<Input<?>> inputs) {
151+
List<Input<?>> expanded = expandToLeaves(inputs);
152+
return expanded == null ? List.of() : expanded.stream().map(Input::getId).toList();
153+
}
93154
}

core/src/main/java/io/kestra/core/models/flows/Type.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ public enum Type {
2222
ARRAY(ArrayInput.class.getName()),
2323
MULTISELECT(MultiselectInput.class.getName()),
2424
YAML(YamlInput.class.getName()),
25-
EMAIL(EmailInput.class.getName());
25+
EMAIL(EmailInput.class.getName()),
26+
FORM(FormInput.class.getName());
2627

2728
private final String clsName;
2829

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.kestra.core.models.flows.input;
2+
3+
import java.util.List;
4+
5+
import io.kestra.core.models.flows.Input;
6+
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
import jakarta.validation.ConstraintViolationException;
9+
import jakarta.validation.Valid;
10+
import jakarta.validation.constraints.NotNull;
11+
import lombok.Getter;
12+
import lombok.NoArgsConstructor;
13+
import lombok.experimental.SuperBuilder;
14+
15+
@SuperBuilder
16+
@Getter
17+
@NoArgsConstructor
18+
public class FormInput extends Input<Void> {
19+
@Schema(
20+
title = "The inputs grouped under this form.",
21+
description = "A FORM groups related inputs under a shared display name. Each child input is resolved " +
22+
"under the form's id as a nested path — e.g. a `region` input inside an `environment` form is " +
23+
"referenced as `{{ inputs.environment.region }}`. To depend on a sibling, use the full dotted path " +
24+
"in `dependsOn` (e.g. `dependsOn: [environment.data_center]`). Forms cannot be nested."
25+
)
26+
@NotNull
27+
@Valid
28+
private List<Input<?>> inputs;
29+
30+
@Override
31+
public void validate(Void input) throws ConstraintViolationException {
32+
// no-op: a FORM is a structural wrapper expanded into its children before resolution
33+
}
34+
}

core/src/main/java/io/kestra/core/runners/FlowInputOutput.java

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ public Mono<Map<String, Object>> readExecutionInputs(final List<Input<?>> inputs
139139
return readData(inputs, execution, data, true).map(inputData -> this.readExecutionInputs(inputs, flow, execution, inputData));
140140
}
141141

142-
private Mono<Map<String, Object>> readData(List<Input<?>> inputs, Execution execution, Publisher<CompletedPart> data, boolean uploadFiles) {
142+
private Mono<Map<String, Object>> readData(List<Input<?>> rawInputs, Execution execution, Publisher<CompletedPart> data, boolean uploadFiles) {
143+
// Expand FORM inputs so FILE part matching works against dotted leaf ids.
144+
final List<Input<?>> inputs = Input.expandToLeaves(rawInputs);
143145
return Flux.from(data)
144146
.publishOn(Schedulers.boundedElastic()).<Map.Entry<String, String>> handle((input, sink) ->
145147
{
@@ -281,8 +283,12 @@ public List<InputAndValue> resolveInputs(
281283
return Collections.emptyList();
282284
}
283285

286+
// Expand FORM inputs into dotted-id leaves so resolution runs on a flat list and the nested
287+
// payload reassembles via flattenToNestedMap. Idempotent on already-expanded leaves.
288+
final List<Input<?>> leafInputs = Input.expandToLeaves(inputs);
289+
284290
final Map<String, ResolvableInput> resolvableInputMap = Collections.unmodifiableMap(
285-
inputs.stream()
291+
leafInputs.stream()
286292
.map(input -> ResolvableInput.of(input, data.get(input.getId())))
287293
.collect(Collectors.toMap(it -> it.get().input().getId(), Function.identity(), (o1, o2) -> o1, LinkedHashMap::new))
288294
);
@@ -333,23 +339,35 @@ private InputAndValue resolveInputValue(
333339
return resolvable.get();
334340
}
335341

336-
// render input
337-
input = RenderableInput.mayRenderInput(input, expression ->
338-
{
339-
try {
340-
return runContext.renderTyped(expression);
341-
} catch (IllegalVariableEvaluationException e) {
342-
throw new RuntimeException(e.getMessage(), e);
343-
}
344-
});
342+
// render input (e.g. a SELECT's dynamic `expression` values). A failure here means the field
343+
// itself can't be rendered, so flag it as a render error so the UI can surface it eagerly.
344+
try {
345+
input = RenderableInput.mayRenderInput(input, expression ->
346+
{
347+
try {
348+
return runContext.renderTyped(expression);
349+
} catch (IllegalVariableEvaluationException e) {
350+
throw new RuntimeException(e.getMessage(), e);
351+
}
352+
});
353+
} catch (Exception e) {
354+
resolvable.resolveWithError(InputOutputValidationException.ofRenderError(e.getMessage(), input));
355+
return resolvable.get();
356+
}
345357
resolvable.setInput(input);
346358

347359
Object value = resolvable.get().value();
348360

349-
// resolve default if needed
361+
// resolve default if needed; a `defaults` that is a Pebble expression (e.g. subflow()/secret())
362+
// can itself fail to render — that is also a broken field, so flag it as a render error.
350363
if (value == null && input.getDefaults() != null) {
351364
RunContext runContextForDefault = decryptSecrets ? runContext : buildRunContextForExecutionAndInputs(flow, execution, dependencies, false);
352-
value = resolveDefaultValue(input, runContextForDefault);
365+
try {
366+
value = resolveDefaultValue(input, runContextForDefault);
367+
} catch (Exception e) {
368+
resolvable.resolveWithError(InputOutputValidationException.ofRenderError(e.getMessage(), input));
369+
return resolvable.get();
370+
}
353371
resolvable.isDefault(true);
354372
}
355373

@@ -396,6 +414,7 @@ public static Object resolveDefaultValue(Input<?> input, PropertyContext rendere
396414
case JSON, YAML -> resolveDefaultPropertyAs(input, renderer, Object.class);
397415
case ARRAY -> resolveDefaultPropertyAsList(input, renderer, Object.class);
398416
case MULTISELECT -> resolveDefaultPropertyAsList(input, renderer, String.class);
417+
case FORM -> throw new IllegalStateException("FORM inputs must be expanded before resolution");
399418
};
400419
}
401420

@@ -410,20 +429,21 @@ private static <T> Object resolveDefaultPropertyAsList(Input<?> input, PropertyC
410429
}
411430

412431
private RunContext buildRunContextForExecutionAndInputs(final FlowInterface flow, final Execution execution, Map<String, InputAndValue> dependencies, final boolean decryptSecrets) {
413-
Map<String, Object> flattenInputs = MapUtils.flattenToNestedMap(
414-
dependencies.entrySet()
415-
.stream()
416-
.collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue().value()), HashMap::putAll)
417-
);
432+
Map<String, Object> flatInputs = dependencies.entrySet()
433+
.stream()
434+
.collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue().value()), HashMap::putAll);
418435
// Hack: Pre-inject all inputs that have a default value with 'null' to prevent
419436
// RunContextFactory from attempting to render them when absent, which could
420437
// otherwise cause an exception if a Pebble expression is involved.
421-
List<Input<?>> inputs = Optional.ofNullable(flow).map(FlowInterface::getInputs).orElse(List.of());
438+
// FORM inputs are expanded to dotted leaves first, and defaults are injected into the flat map
439+
// before nesting so they end up under the form key (e.g. inputs.environment.region).
440+
List<Input<?>> inputs = flow == null ? List.of() : flow.resolvableInputs();
422441
for (Input<?> input : inputs) {
423-
if (input.getDefaults() != null && !flattenInputs.containsKey(input.getId())) {
424-
flattenInputs.put(input.getId(), null);
442+
if (input.getDefaults() != null && !flatInputs.containsKey(input.getId())) {
443+
flatInputs.put(input.getId(), null);
425444
}
426445
}
446+
Map<String, Object> flattenInputs = MapUtils.flattenToNestedMap(flatInputs);
427447
return runContextFactory.get().of(flow, execution, vars -> vars.withInputs(flattenInputs), decryptSecrets);
428448
}
429449

@@ -558,6 +578,7 @@ private Object parseType(Execution execution, Type type, String id, Type element
558578
yield asList;
559579
}
560580
}
581+
case FORM -> throw new IllegalStateException("FORM inputs must be expanded before resolution");
561582
};
562583
} catch (IllegalArgumentException | ConstraintViolationException e) {
563584
throw e;

core/src/main/java/io/kestra/core/runners/RunContextFactory.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ private List<String> secretInputsFromFlow(FlowInterface flow) {
254254
return Collections.emptyList();
255255
}
256256

257-
return flow.getInputs().stream()
257+
// FORM inputs are expanded to dotted leaves so a SECRET grouped under a form is masked by its dotted path.
258+
return flow.resolvableInputs().stream()
258259
.filter(input -> input.getType() == Type.SECRET)
259260
.map(input -> input.getId()).toList();
260261
}

0 commit comments

Comments
 (0)