feat(core): add prefill prop to input to allow nullable value (#11819)

Added a new 'prefill' property for all inputs
to specify an optional UI hint for pre-filling the input,while
allowing the input to be nullable.

Fixes: #11819
This commit is contained in:
Florian Hussonnois
2025-10-13 11:17:25 +02:00
committed by Florian Hussonnois
parent 163e1e2c8b
commit 6361a02deb
9 changed files with 142 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.kestra.core.models.flows.input.*;
import io.kestra.core.models.property.Property;
import io.kestra.core.validations.InputValidation;
import io.micronaut.core.annotation.Introspected;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.ConstraintViolationException;
@@ -44,6 +45,7 @@ import lombok.experimental.SuperBuilder;
@JsonSubTypes.Type(value = YamlInput.class, name = "YAML"),
@JsonSubTypes.Type(value = EmailInput.class, name = "EMAIL"),
})
@InputValidation
public abstract class Input<T> implements Data {
@Schema(
title = "The ID of the input."
@@ -80,7 +82,13 @@ public abstract class Input<T> implements Data {
title = "The default value to use if no value is specified."
)
Property<T> defaults;
@Schema(
title = "The suggested value for the input.",
description = "Optional UI hint for pre-filling the input. Cannot be used together with a default value."
)
Property<T> prefill;
@Schema(
title = "The display name of the input."
)

View File

@@ -130,7 +130,7 @@ public class FlowInputOutput {
private Mono<Map<String, Object>> readData(List<Input<?>> inputs, Execution execution, Publisher<CompletedPart> data, boolean uploadFiles) {
return Flux.from(data)
.publishOn(Schedulers.boundedElastic())
.<AbstractMap.SimpleEntry<String, String>>handle((input, sink) -> {
.<Map.Entry<String, String>>handle((input, sink) -> {
if (input instanceof CompletedFileUpload fileUpload) {
boolean oldStyleInput = false;
if ("files".equals(fileUpload.getName())) {
@@ -150,7 +150,7 @@ public class FlowInputOutput {
.getContextStorageURI()
);
fileUpload.discard();
sink.next(new AbstractMap.SimpleEntry<>(inputId, from.toString()));
sink.next(Map.entry(inputId, from.toString()));
} else {
try {
final String fileExtension = FileInput.findFileInputExtension(inputs, fileName);
@@ -165,7 +165,7 @@ public class FlowInputOutput {
return;
}
URI from = storageInterface.from(execution, inputId, fileName, tempFile);
sink.next(new AbstractMap.SimpleEntry<>(inputId, from.toString()));
sink.next(Map.entry(inputId, from.toString()));
} finally {
if (!tempFile.delete()) {
tempFile.deleteOnExit();
@@ -178,13 +178,13 @@ public class FlowInputOutput {
}
} else {
try {
sink.next(new AbstractMap.SimpleEntry<>(input.getName(), new String(input.getBytes())));
sink.next(Map.entry(input.getName(), new String(input.getBytes())));
} catch (IOException e) {
sink.error(e);
}
}
})
.collectMap(AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue);
.collectMap(Map.Entry::getKey, Map.Entry::getValue);
}
/**

View File

@@ -0,0 +1,16 @@
package io.kestra.core.validations;
import io.kestra.core.validations.validator.InputValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = InputValidator.class)
public @interface InputValidation {
String message() default "invalid input";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,46 @@
package io.kestra.core.validations.validator;
import io.kestra.core.models.flows.Input;
import io.kestra.core.runners.VariableRenderer;
import io.kestra.core.validations.InputValidation;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
@Introspected
public class InputValidator implements ConstraintValidator<InputValidation, Input<?>> {
@Inject
VariableRenderer variableRenderer;
@Override
public boolean isValid(@Nullable Input<?> value, @NonNull AnnotationValue<InputValidation> annotationMetadata, @NonNull ConstraintValidatorContext context) {
if (value == null) {
return true; // nulls are allowed according to spec
}
if (value.getDefaults() != null && Boolean.FALSE.equals(value.getRequired())) {
context.disableDefaultConstraintViolation();
context
.buildConstraintViolationWithTemplate("Inputs with a default value must be required, since the default is always applied.")
.addConstraintViolation();
return false;
}
if (value.getDefaults() != null && value.getPrefill() != null) {
context.disableDefaultConstraintViolation();
context
.buildConstraintViolationWithTemplate("Inputs with a default value cannot also have a prefill.")
.addConstraintViolation();
return false;
}
return true;
}
}

View File

@@ -35,6 +35,7 @@ import java.util.Map;
import java.util.Optional;
import static io.kestra.core.tenant.TenantService.MAIN_TENANT;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
class FlowInputOutputTest {
@@ -335,8 +336,7 @@ class FlowInputOutputTest {
// Then
Assertions.assertEquals(TEST_SECRET_VALUE, ((MultiselectInput)results.getFirst().input()).getValues().getFirst());
}
@Test
void shouldNotObfuscateSecretsWhenReadingInputs() {
// Given
@@ -354,6 +354,22 @@ class FlowInputOutputTest {
Assertions.assertEquals(TEST_SECRET_VALUE, results.get("input"));
}
@Test
void shouldGetDefaultWhenPassingNoDataForRequiredInput() {
// Given
StringInput input = StringInput.builder()
.id("input")
.type(Type.STRING)
.defaults(Property.ofValue("default"))
.build();
// When
Map<String, Object> results = flowInputOutput.readExecutionInputs(List.of(input), null, DEFAULT_TEST_EXECUTION, Mono.empty()).block();
// Then
assertThat(results.get("input")).isEqualTo("default");
}
private static class MemoryCompletedPart implements CompletedPart {
protected final String name;

View File

@@ -1,8 +1,10 @@
package io.kestra.core.validations;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.flows.GenericFlow;
import io.kestra.core.models.validations.ModelValidator;
import io.kestra.core.serializers.YamlParser;
import io.kestra.core.tenant.TenantService;
import io.kestra.core.utils.TestsUtils;
import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject;
@@ -66,6 +68,50 @@ class FlowValidationTest {
assertThat(validate.isPresent()).isFalse();
}
@Test
void shouldGetConstraintErrorGivenInputWithBothDefaultsAndSuggestion() {
// Given
GenericFlow flow = GenericFlow.fromYaml(TenantService.MAIN_TENANT, """
id: test
namespace: unittest
inputs:
- id: input
type: STRING
prefill: "suggestion"
defaults: "defaults"
tasks: []
""");
// When
Optional<ConstraintViolationException> validate = modelValidator.isValid(flow);
// Then
assertThat(validate.isPresent()).isEqualTo(true);
assertThat(validate.get().getMessage()).contains("Inputs with a default value cannot also have a suggestion.");
}
@Test
void shouldGetConstraintErrorGivenOptionalInputWithDefault() {
// Given
GenericFlow flow = GenericFlow.fromYaml(TenantService.MAIN_TENANT, """
id: test
namespace: unittest
inputs:
- id: input
type: STRING
defaults: "defaults"
required: false
tasks: []
""");
// When
Optional<ConstraintViolationException> validate = modelValidator.isValid(flow);
// Then
assertThat(validate.isPresent()).isEqualTo(true);
assertThat(validate.get().getMessage()).contains("Inputs with a default value must be required, since the default is always applied.");
}
private Flow parse(String path) {
URL resource = TestsUtils.class.getClassLoader().getResource(path);

View File

@@ -3,11 +3,9 @@ namespace: io.kestra.tests
inputs:
- name: namespace
type: STRING
required: false
defaults: "io.kestra.tests"
- name: errorOnMissing
type: BOOL
required: false
defaults: false
tasks:
- id: get

View File

@@ -539,7 +539,7 @@
const metadataCallback = (response) => {
this.inputsMetaData = response.inputs.reduce((acc,it) => {
if(it.enabled){
acc.push({...it.input, errors: it.errors, value: it.value, isDefault: it.isDefault});
acc.push({...it.input, errors: it.errors, value: it.value || it.input.prefill, isDefault: it.isDefault});
}
return acc;
}, [])

View File

@@ -211,7 +211,7 @@ class PluginControllerTest {
assertThat(doc.getSchema().getProperties().size()).isEqualTo(3);
Map<String, Object> properties = (Map<String, Object>) doc.getSchema().getProperties().get("properties");
assertThat(properties.size()).isEqualTo(8);
assertThat(properties.size()).isEqualTo(9);
assertThat(((Map<String, Object>) properties.get("name")).get("$deprecated")).isEqualTo(true);
}
}