diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/ConfirmationInput.java b/spring-shell-core/src/main/java/org/springframework/shell/component/ConfirmationInput.java new file mode 100644 index 000000000..224f80762 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/ConfirmationInput.java @@ -0,0 +1,222 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.ConfirmationInput.ConfirmationInputContext; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext.MessageLevel; +import org.springframework.util.StringUtils; + +/** + * Component for a confirmation question. + * + * @author Janne Valkealahti + */ +public class ConfirmationInput extends AbstractTextComponent { + + private final boolean defaultValue; + private ConfirmationInputContext currentContext; + + public ConfirmationInput(Terminal terminal) { + this(terminal, null); + } + + public ConfirmationInput(Terminal terminal, String name) { + this(terminal, name, true, null); + } + + public ConfirmationInput(Terminal terminal, String name, boolean defaultValue) { + this(terminal, name, defaultValue, null); + } + + public ConfirmationInput(Terminal terminal, String name, boolean defaultValue, + Function> renderer) { + super(terminal, name, null); + setRenderer(renderer != null ? renderer : new DefaultRenderer()); + setTemplateLocation("classpath:org/springframework/shell/component/confirmation-input-default.stg"); + this.defaultValue = defaultValue; + } + + @Override + protected ConfirmationInputContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = ConfirmationInputContext.of(defaultValue); + currentContext.setName(getName()); + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected boolean read(BindingReader bindingReader, KeyMap keyMap, ConfirmationInputContext context) { + String operation = bindingReader.readBinding(keyMap); + String input; + switch (operation) { + case OPERATION_CHAR: + String lastBinding = bindingReader.getLastBinding(); + input = context.getInput(); + if (input == null) { + input = lastBinding; + } + else { + input = input + lastBinding; + } + context.setInput(input); + checkInput(input, context); + break; + case OPERATION_BACKSPACE: + input = context.getInput(); + if (StringUtils.hasLength(input)) { + input = input.length() > 1 ? input.substring(0, input.length() - 1) : null; + } + context.setInput(input); + checkInput(input, context); + break; + case OPERATION_EXIT: + if (StringUtils.hasText(context.getInput())) { + context.setResultValue(parseBoolean(context.getInput())); + } + else if (context.getDefaultValue() != null) { + context.setResultValue(context.getDefaultValue()); + } + return true; + default: + break; + } + return false; + } + + private Boolean parseBoolean(String input) { + if (!StringUtils.hasText(input)) { + return null; + } + input = input.trim().toLowerCase(); + switch (input) { + case "y": + case "yes": + case "1": + return true; + case "n": + case "no": + case "0": + return false; + default: + return null; + } + } + + private void checkInput(String input, ConfirmationInputContext context) { + if (!StringUtils.hasText(input)) { + context.setMessage(null); + return; + } + Boolean yesno = parseBoolean(input); + if (yesno == null) { + String msg = String.format("Sorry, your input is invalid: '%s', try again", input); + context.setMessage(msg, MessageLevel.ERROR); + } + else { + context.setMessage(null); + } + } + + public interface ConfirmationInputContext extends TextComponentContext { + + /** + * Gets a default value. + * + * @return a default value + */ + Boolean getDefaultValue(); + + /** + * Sets a default value. + * + * @param defaultValue the default value + */ + void setDefaultValue(Boolean defaultValue); + + /** + * Gets an empty {@link ConfirmationInputContext}. + * + * @return empty path input context + */ + public static ConfirmationInputContext empty() { + return of(null); + } + + /** + * Gets an {@link ConfirmationInputContext}. + * + * @return path input context + */ + public static ConfirmationInputContext of(Boolean defaultValue) { + return new DefaultConfirmationInputContext(defaultValue); + } + } + + private static class DefaultConfirmationInputContext extends BaseTextComponentContext + implements ConfirmationInputContext { + + private Boolean defaultValue; + + public DefaultConfirmationInputContext(Boolean defaultValue) { + this.defaultValue = defaultValue; + } + + @Override + public Boolean getDefaultValue() { + return defaultValue; + } + + @Override + public void setDefaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("defaultValue", getDefaultValue() != null ? getDefaultValue() : null); + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + } + + private class DefaultRenderer implements Function> { + + @Override + public List apply(ConfirmationInputContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } +} diff --git a/spring-shell-core/src/main/resources/META-INF/native-image/resource-config.json b/spring-shell-core/src/main/resources/META-INF/native-image/resource-config.json new file mode 100644 index 000000000..abeb6fdda --- /dev/null +++ b/spring-shell-core/src/main/resources/META-INF/native-image/resource-config.json @@ -0,0 +1,9 @@ +{ + "resources": { + "includes": [ + { + "pattern": "org/springframework/shell/component/.*.stg" + } + ] + } +} diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/confirmation-input-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/confirmation-input-default.stg new file mode 100644 index 000000000..822f24d02 --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/confirmation-input-default.stg @@ -0,0 +1,40 @@ +// message +message(model) ::= <% + +<(">>>"); format="level-error"> + +<(">>"); format="level-warn"> + +<(">"); format="level-info"> + +%> + +// info section after '? xxx' +info(model) ::= <% + + <("(Y/n)"); format="item-disabled"> + + <("(y/N)"); format="item-disabled"> + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<("?"); format="list-value"> +>> + +// component result +result(model) ::= << + +>> + +// component is running +running(model) ::= << + + +>> + +// main +main(model) ::= << + +>> diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/ConfirmationInputTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/ConfirmationInputTests.java new file mode 100644 index 000000000..50c0fb1bf --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/ConfirmationInputTests.java @@ -0,0 +1,153 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.shell.component.ConfirmationInput.ConfirmationInputContext; +import org.springframework.shell.component.context.ComponentContext; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfirmationInputTests extends AbstractShellTests { + + private ExecutorService service; + private CountDownLatch latch1; + private AtomicReference result1; + + @BeforeEach + public void setupTests() { + service = Executors.newFixedThreadPool(1); + latch1 = new CountDownLatch(1); + result1 = new AtomicReference<>(); + } + + @AfterEach + public void cleanupTests() throws IOException { + latch1 = null; + result1 = null; + if (service != null) { + service.shutdown(); + } + service = null; + } + + @Test + public void testResultUserInputEnterDefaultYes() throws InterruptedException, IOException { + ComponentContext empty = ComponentContext.empty(); + ConfirmationInput component1 = new ConfirmationInput(getTerminal(), "component1"); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + ConfirmationInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + ConfirmationInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isNotNull(); + assertThat(run1Context.getResultValue()).isTrue(); + } + + @Test + public void testResultUserInputEnterDefaultNo() throws InterruptedException, IOException { + ComponentContext empty = ComponentContext.empty(); + ConfirmationInput component1 = new ConfirmationInput(getTerminal(), "component1", false); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + ConfirmationInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + ConfirmationInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isNotNull(); + assertThat(run1Context.getResultValue()).isFalse(); + } + + @Test + public void testResultUserInputNo() throws InterruptedException, IOException { + ComponentContext empty = ComponentContext.empty(); + ConfirmationInput component1 = new ConfirmationInput(getTerminal(), "component1"); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + ConfirmationInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().append("no").cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + ConfirmationInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isNotNull(); + assertThat(run1Context.getResultValue()).isFalse(); + } + + @Test + public void testResultUserInputYes() throws InterruptedException, IOException { + ComponentContext empty = ComponentContext.empty(); + ConfirmationInput component1 = new ConfirmationInput(getTerminal(), "component1"); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + ConfirmationInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().append("yes").cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + ConfirmationInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isNotNull(); + assertThat(run1Context.getResultValue()).isTrue(); + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java index 01299e309..1cb2d97f7 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java @@ -23,10 +23,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ResourceLoaderAware; import org.springframework.core.io.ResourceLoader; +import org.springframework.shell.component.ConfirmationInput; import org.springframework.shell.component.MultiItemSelector; import org.springframework.shell.component.PathInput; import org.springframework.shell.component.SingleItemSelector; import org.springframework.shell.component.StringInput; +import org.springframework.shell.component.ConfirmationInput.ConfirmationInputContext; import org.springframework.shell.component.MultiItemSelector.MultiItemSelectorContext; import org.springframework.shell.component.PathInput.PathInputContext; import org.springframework.shell.component.SingleItemSelector.SingleItemSelectorContext; @@ -71,6 +73,15 @@ public String pathInput() { return "Got value " + context.getResultValue(); } + @ShellMethod(key = "component confirmation", value = "Confirmation input", group = "Components") + public String confirmationInput(boolean no) { + ConfirmationInput component = new ConfirmationInput(getTerminal(), "Enter value", !no); + component.setResourceLoader(resourceLoader); + component.setTemplateExecutor(templateExecutor); + ConfirmationInputContext context = component.run(ConfirmationInputContext.empty()); + return "Got value " + context.getResultValue(); + } + @ShellMethod(key = "component single", value = "Single selector", group = "Components") public String singleSelector() { List> items = new ArrayList<>();