Add confirmation component

- New ConfirmationInput component
- Add missing native resource config for templates.
- Add sample "component confirmation", use flag --no
  to switch default from yes to no.
- Fixes #366
This commit is contained in:
Janne Valkealahti
2022-02-11 10:00:16 +00:00
parent 2f4209785e
commit 785a9268e3
5 changed files with 435 additions and 0 deletions

View File

@@ -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<Boolean, ConfirmationInputContext> {
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<ConfirmationInputContext, List<AttributedString>> 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<String> 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<Boolean, ConfirmationInputContext> {
/**
* 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<Boolean, ConfirmationInputContext>
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<String, Object> toTemplateModel() {
Map<String, Object> attributes = super.toTemplateModel();
attributes.put("defaultValue", getDefaultValue() != null ? getDefaultValue() : null);
Map<String, Object> model = new HashMap<>();
model.put("model", attributes);
return model;
}
}
private class DefaultRenderer implements Function<ConfirmationInputContext, List<AttributedString>> {
@Override
public List<AttributedString> apply(ConfirmationInputContext context) {
return renderTemplateResource(context.toTemplateModel());
}
}
}

View File

@@ -0,0 +1,9 @@
{
"resources": {
"includes": [
{
"pattern": "org/springframework/shell/component/.*.stg"
}
]
}
}

View File

@@ -0,0 +1,40 @@
// message
message(model) ::= <%
<if(model.message && model.hasMessageLevelError)>
<(">>>"); format="level-error"> <model.message; format="level-error">
<elseif(model.message && model.hasMessageLevelWarn)>
<(">>"); format="level-warn"> <model.message; format="level-warn">
<elseif(model.message && model.hasMessageLevelInfo)>
<(">"); format="level-info"> <model.message; format="level-info">
<endif>
%>
// info section after '? xxx'
info(model) ::= <%
<if(model.defaultValue)>
<("(Y/n)"); format="item-disabled">
<else>
<("(y/N)"); format="item-disabled">
<endif>
%>
// start '? xxx' shows both running and result
question_name(model) ::= <<
<("?"); format="list-value"> <model.name; format="title">
>>
// component result
result(model) ::= <<
<question_name(model)> <model.resultValue; format="value">
>>
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<message(model)>
>>
// main
main(model) ::= <<
<if(model.resultValue)><result(model)><else><running(model)><endif>
>>

View File

@@ -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<ConfirmationInputContext> 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();
}
}

View File

@@ -23,10 +23,12 @@ import java.util.stream.Collectors;
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 class ComponentCommands extends AbstractShellComponent implements Resourc
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<SelectorItem<String>> items = new ArrayList<>();