Browse Source

(merged from 5.4) Implements "input prompt" for ComboBox and TextField. Also includes Sampler samples. Fixes #1455

svn changeset:7337/svn branch:6.0
tags/6.7.0.beta1
Marc Englund 15 years ago
parent
commit
bdee6749d4

+ 5
- 1
WebContent/ITMILL/themes/default/select/select.css View File

@@ -102,7 +102,11 @@
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -4px;
}

.i-filterselect-prompt .i-filterselect-input {
/* input prompt active, i.e empty select */
color: #999;
font-style: italic;
}
.i-filterselect-button {
float: right;
width: 25px;

+ 14
- 4
WebContent/ITMILL/themes/default/styles.css View File

@@ -1384,7 +1384,11 @@ input.i-modified,
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -4px;
}

.i-filterselect-prompt .i-filterselect-input {
/* input prompt active, i.e empty select */
color: #999;
font-style: italic;
}
.i-filterselect-button {
float: right;
width: 25px;
@@ -2483,6 +2487,12 @@ input.i-modified,
border-color: #5daee8;
}

input.i-textfield-prompt,
textarea.i-textarea-prompt {
color: #999;
font-style: italic;
}

.i-textfield.i-readonly,
.i-textarea.i-readonly {
background: transparent;
@@ -2490,7 +2500,6 @@ input.i-modified,
border: none;
}


.i-richtextarea {
border: 1px solid #b6b6b6;
overflow: hidden;
@@ -2651,8 +2660,9 @@ input.i-modified,
.i-window-footer {
height: 8px;
margin-left: 9px;
background: transparent url(window/img/bottom-right.png) no-repeat right top;
/* IE7 bug fix */
background: transparent url(window/img/bottom-right.png) no-repeat right top;
}
.i-ie7 .i-window-footer {
position:relative;
}


+ 6
- 1
WebContent/ITMILL/themes/default/textfield/textfield.css View File

@@ -27,6 +27,12 @@
border-color: #5daee8;
}

input.i-textfield-prompt,
textarea.i-textarea-prompt {
color: #999;
font-style: italic;
}

.i-textfield.i-readonly,
.i-textarea.i-readonly {
background: transparent;
@@ -34,7 +40,6 @@
border: none;
}


.i-richtextarea {
border: 1px solid #b6b6b6;
overflow: hidden;

+ 4
- 0
src/com/itmill/toolkit/demo/sampler/FeatureSet.java View File

@@ -43,6 +43,7 @@ import com.itmill.toolkit.demo.sampler.features.notifications.NotificationWarnin
import com.itmill.toolkit.demo.sampler.features.panels.PanelBasic;
import com.itmill.toolkit.demo.sampler.features.panels.PanelLight;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxContains;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxInputPrompt;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxNewItems;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxPlain;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxStartsWith;
@@ -70,6 +71,7 @@ import com.itmill.toolkit.demo.sampler.features.text.LabelPreformatted;
import com.itmill.toolkit.demo.sampler.features.text.LabelRich;
import com.itmill.toolkit.demo.sampler.features.text.RichTextEditor;
import com.itmill.toolkit.demo.sampler.features.text.TextArea;
import com.itmill.toolkit.demo.sampler.features.text.TextFieldInputPrompt;
import com.itmill.toolkit.demo.sampler.features.text.TextFieldSecret;
import com.itmill.toolkit.demo.sampler.features.text.TextFieldSingle;
import com.itmill.toolkit.demo.sampler.features.trees.TreeActions;
@@ -213,6 +215,7 @@ public class FeatureSet extends Feature {
new TwinColumnSelect(), //
new NativeSelection(), //
new ComboBoxPlain(), //
new ComboBoxInputPrompt(), //
new ComboBoxStartsWith(), //
new ComboBoxContains(), //
new ComboBoxNewItems(), //
@@ -368,6 +371,7 @@ public class FeatureSet extends Feature {
//
new TextFieldSingle(), //
new TextFieldSecret(), //
new TextFieldInputPrompt(), //
new TextArea(), //
new RichTextEditor(), //
});

BIN
src/com/itmill/toolkit/demo/sampler/features/selects/75-ComboBoxInputPrompt.png View File


+ 44
- 0
src/com/itmill/toolkit/demo/sampler/features/selects/ComboBoxInputPrompt.java View File

@@ -0,0 +1,44 @@
package com.itmill.toolkit.demo.sampler.features.selects;
import com.itmill.toolkit.demo.sampler.APIResource;
import com.itmill.toolkit.demo.sampler.Feature;
import com.itmill.toolkit.demo.sampler.NamedExternalResource;
import com.itmill.toolkit.demo.sampler.features.text.TextFieldInputPrompt;
import com.itmill.toolkit.ui.ComboBox;
public class ComboBoxInputPrompt extends Feature {
@Override
public String getName() {
return "Combobox with input prompt";
}
@Override
public String getDescription() {
return "ComboBox is a drop-down selection component with single item selection."
+ " It can have an <i>input prompt</i> - a textual hint that is shown within"
+ " the select when no value is selected.<br/>"
+ " You can use an input prompt instead of a caption to save"
+ " space, but only do so if the function of the ComboBox is"
+ " still clear when a value is selected and the prompt is no"
+ " longer visible.";
}
@Override
public APIResource[] getRelatedAPI() {
return new APIResource[] { new APIResource(ComboBox.class) };
}
@Override
public Class[] getRelatedFeatures() {
return new Class[] { ComboBoxStartsWith.class, ComboBoxContains.class,
ComboBoxNewItems.class, TextFieldInputPrompt.class };
}
@Override
public NamedExternalResource[] getRelatedResources() {
return new NamedExternalResource[] { new NamedExternalResource(
"UI Patterns, Input Prompt",
"http://ui-patterns.com/pattern/InputPrompt") };
}
}

+ 39
- 0
src/com/itmill/toolkit/demo/sampler/features/selects/ComboBoxInputPromptExample.java View File

@@ -0,0 +1,39 @@
package com.itmill.toolkit.demo.sampler.features.selects;
import com.itmill.toolkit.data.Property;
import com.itmill.toolkit.data.Property.ValueChangeEvent;
import com.itmill.toolkit.ui.ComboBox;
import com.itmill.toolkit.ui.VerticalLayout;
public class ComboBoxInputPromptExample extends VerticalLayout implements
Property.ValueChangeListener {
private static final String[] cities = new String[] { "Berlin", "Brussels",
"Helsinki", "Madrid", "Oslo", "Paris", "Stockholm" };
public ComboBoxInputPromptExample() {
setMargin(true); // for looks: more 'air'
// Create & set input prompt
ComboBox l = new ComboBox();
l.setInputPrompt("Please select a city");
// configure & load content
l.setImmediate(true);
l.addListener(this);
for (int i = 0; i < cities.length; i++) {
l.addItem(cities[i]);
}
// add to the layout
addComponent(l);
}
/*
* Shows a notification when a selection is made.
*/
public void valueChange(ValueChangeEvent event) {
getWindow().showNotification("Selected city: " + event.getProperty());
}
}

BIN
src/com/itmill/toolkit/demo/sampler/features/text/75-TextFieldInputPrompt.png View File


+ 47
- 0
src/com/itmill/toolkit/demo/sampler/features/text/TextFieldInputPrompt.java View File

@@ -0,0 +1,47 @@
package com.itmill.toolkit.demo.sampler.features.text;
import com.itmill.toolkit.demo.sampler.APIResource;
import com.itmill.toolkit.demo.sampler.Feature;
import com.itmill.toolkit.demo.sampler.FeatureSet;
import com.itmill.toolkit.demo.sampler.NamedExternalResource;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxInputPrompt;
import com.itmill.toolkit.demo.sampler.features.selects.ComboBoxNewItems;
import com.itmill.toolkit.ui.TextField;
public class TextFieldInputPrompt extends Feature {
@Override
public String getName() {
return "Text field with input prompt";
}
@Override
public String getDescription() {
return " The TextField can have an <i>input prompt</i> - a textual hint that is shown within"
+ " the field when the field is otherwise empty.<br/>"
+ " You can use an input prompt instead of a caption to save"
+ " space, but only do so if the function of the TextField is"
+ " still clear when a value has been entered and the prompt is no"
+ " longer visible.";
}
@Override
public APIResource[] getRelatedAPI() {
return new APIResource[] { new APIResource(TextField.class) };
}
@Override
public Class[] getRelatedFeatures() {
// TODO update CB -ref to 'suggest' pattern, when available
return new Class[] { TextFieldSingle.class, TextFieldSecret.class,
ComboBoxInputPrompt.class, ComboBoxNewItems.class,
FeatureSet.Texts.class };
}
@Override
public NamedExternalResource[] getRelatedResources() {
return new NamedExternalResource[] { new NamedExternalResource(
"UI Patterns, Input Prompt",
"http://ui-patterns.com/pattern/InputPrompt") };
}
}

+ 49
- 0
src/com/itmill/toolkit/demo/sampler/features/text/TextFieldInputPromptExample.java View File

@@ -0,0 +1,49 @@
package com.itmill.toolkit.demo.sampler.features.text;
import com.itmill.toolkit.data.Property;
import com.itmill.toolkit.data.Property.ValueChangeEvent;
import com.itmill.toolkit.ui.TextField;
import com.itmill.toolkit.ui.VerticalLayout;
public class TextFieldInputPromptExample extends VerticalLayout implements
Property.ValueChangeListener {
public TextFieldInputPromptExample() {
// add som 'air' to the layout
setSpacing(true);
setMargin(true);
// Username field + input prompt
TextField username = new TextField();
username.setInputPrompt("Username");
// configure & add to layout
username.setImmediate(true);
username.addListener(this);
addComponent(username);
// Password field + input prompt
TextField password = new TextField();
password.setInputPrompt("Password");
// configure & add to layout
password.setSecret(true);
password.setImmediate(true);
password.addListener(this);
addComponent(password);
// Comment field + input prompt
TextField comment = new TextField();
comment.setInputPrompt("Comment");
// configure & add to layout
comment.setRows(3);
comment.setImmediate(true);
comment.addListener(this);
addComponent(comment);
}
public void valueChange(ValueChangeEvent event) {
getWindow().showNotification("Received " + event.getProperty());
}
}

+ 62
- 33
src/com/itmill/toolkit/terminal/gwt/client/ui/IFilterSelect.java View File

@@ -452,8 +452,7 @@ public class IFilterSelect extends Composite implements Paintable, Field,
}
if (allowNewItem) {

if (!enteredItemValue.equals(emptyText)
&& !enteredItemValue.equals(lastNewItemString)) {
if (!prompting && !enteredItemValue.equals(lastNewItemString)) {
/*
* Store last sent new item string to avoid double sends
*/
@@ -469,13 +468,18 @@ public class IFilterSelect extends Composite implements Paintable, Field,
} else {
if (currentSuggestion != null) {
String text = currentSuggestion.getReplacementString();
tb.setText((text.equals("") ? emptyText : text));
// TODO add/remove class CLASSNAME_EMPTY
/*- TODO?
if (text.equals("")) {
tb.setText(inputPrompt);
prompting = true;
addStyleDependentName(CLASSNAME_PROMPT);
} else {
tb.setText(text);
prompting = false;
removeStyleDependentName(CLASSNAME_PROMPT);
}
-*/
selectedOptionKey = currentSuggestion.key;
} else {
tb.setText(emptyText);
// TODO add class CLASSNAME_EMPTY
selectedOptionKey = null;
}
}
suggestionPopup.hide();
@@ -546,7 +550,11 @@ public class IFilterSelect extends Composite implements Paintable, Field,
private boolean enabled;

// shown in unfocused empty field, disappears on focus (e.g "Search here")
private String emptyText = "";
private static final String CLASSNAME_PROMPT = "prompt";
private static final String ATTR_INPUTPROMPT = "prompt";
private String inputPrompt = "";
private boolean prompting = false;

// Set true when popupopened has been clicked. Cleared on each UIDL-update.
// This handles the special case where are not filtering yet and the
// selected value has changed on the server-side. See #2119
@@ -555,8 +563,6 @@ public class IFilterSelect extends Composite implements Paintable, Field,
private int textboxPadding = -1;
private int componentPadding = -1;
private int suggestionPopupMinWidth = 0;
// private static final String CLASSNAME_EMPTY = "empty";
private static final String ATTR_EMPTYTEXT = "emptytext";
/*
* Stores the last new item string to avoid double submissions. Cleared on
* uidl updates
@@ -656,9 +662,11 @@ public class IFilterSelect extends Composite implements Paintable, Field,

currentPage = uidl.getIntVariable("page");

if (uidl.hasAttribute(ATTR_EMPTYTEXT)) {
// "emptytext" changed from server
emptyText = uidl.getStringAttribute(ATTR_EMPTYTEXT);
if (uidl.hasAttribute(ATTR_INPUTPROMPT)) {
// input prompt changed from server
inputPrompt = uidl.getStringAttribute(ATTR_INPUTPROMPT);
} else {
inputPrompt = "";
}

suggestionPopup.setPagingEnabled(true);
@@ -671,7 +679,7 @@ public class IFilterSelect extends Composite implements Paintable, Field,
final UIDL options = uidl.getChildUIDL(0);
totalMatches = uidl.getIntAttribute("totalMatches");

String captions = emptyText;
String captions = inputPrompt;

for (final Iterator i = options.getChildIterator(); i.hasNext();) {
final UIDL optionUidl = (UIDL) i.next();
@@ -697,9 +705,12 @@ public class IFilterSelect extends Composite implements Paintable, Field,
if ((!filtering || popupOpenerClicked) && uidl.hasVariable("selected")
&& uidl.getStringArrayVariable("selected").length == 0) {
// select nulled
tb.setText(emptyText);
if (!filtering || !popupOpenerClicked) {
tb.setText(inputPrompt);
prompting = true;
addStyleDependentName(CLASSNAME_PROMPT);
}
selectedOptionKey = null;
// TODO add class CLASSNAME_EMPTY
}

if (filtering
@@ -751,9 +762,17 @@ public class IFilterSelect extends Composite implements Paintable, Field,
// normal selection
newKey = String.valueOf(suggestion.getOptionKey());
}

String text = suggestion.getReplacementString();
tb.setText(text.equals("") ? emptyText : text);
// TODO add/remove class CLASSNAME_EMPTY
if ("".equals(newKey)) {
tb.setText(inputPrompt);
prompting = true;
addStyleDependentName(CLASSNAME_PROMPT);
} else {
tb.setText(text);
prompting = false;
removeStyleDependentName(CLASSNAME_PROMPT);
}
setSelectedItemIcon(suggestion.getIconUri());
if (!newKey.equals(selectedOptionKey)) {
selectedOptionKey = newKey;
@@ -844,12 +863,14 @@ public class IFilterSelect extends Composite implements Paintable, Field,
case KeyboardListener.KEY_ESCAPE:
if (currentSuggestion != null) {
String text = currentSuggestion.getReplacementString();
tb.setText((text.equals("") ? emptyText : text));
// TODO add/remove class CLASSNAME_EMPTY
tb.setText(text);
prompting = false;
removeStyleDependentName(CLASSNAME_PROMPT);
selectedOptionKey = currentSuggestion.key;
} else {
tb.setText(emptyText);
// TODO add class CLASSNAME_EMPTY
tb.setText(inputPrompt);
prompting = true;
addStyleDependentName(CLASSNAME_PROMPT);
selectedOptionKey = null;
}
lastFilter = "";
@@ -868,12 +889,14 @@ public class IFilterSelect extends Composite implements Paintable, Field,
public void onClick(Widget sender) {
if (enabled) {
// ask suggestionPopup if it was just closed, we are using GWT
// Popup's
// auto close feature
// Popup's auto close feature
if (!suggestionPopup.isJustClosed()) {
filterOptions(-1, "");
popupOpenerClicked = true;
lastFilter = "";
} else if (selectedOptionKey == null) {
tb.setText(inputPrompt);
prompting = true;
}
DOM.eventPreventDefault(DOM.eventGetCurrentEvent());
tb.setFocus(true);
@@ -888,13 +911,13 @@ public class IFilterSelect extends Composite implements Paintable, Field,
private native int minWidth(String captions)
/*-{
if(!captions || captions.length <= 0)
return 0;
return 0;
captions = captions.split("|");
var d = $wnd.document.createElement("div");
var html = "";
for(var i=0; i < captions.length; i++) {
html += "<div>" + captions[i] + "</div>";
// TODO apply same CSS classname as in suggestionmenu
html += "<div>" + captions[i] + "</div>";
// TODO apply same CSS classname as in suggestionmenu
}
d.style.position = "absolute";
d.style.top = "0";
@@ -908,9 +931,9 @@ public class IFilterSelect extends Composite implements Paintable, Field,
}-*/;

public void onFocus(Widget sender) {
if (emptyText.equals(tb.getText())) {
if (prompting) {
tb.setText("");
// TODO remove class CLASSNAME_EMPTY
removeStyleDependentName(CLASSNAME_PROMPT);
}
addStyleDependentName("focus");
}
@@ -920,14 +943,20 @@ public class IFilterSelect extends Composite implements Paintable, Field,
// typing so fast the popup was never opened, or it's just closed
suggestionPopup.menu.doSelectedItemAction();
}
if ("".equals(tb.getText())) {
tb.setText(emptyText);
// TODO add CLASSNAME_EMPTY
if (selectedOptionKey == null) {
tb.setText(inputPrompt);
prompting = true;
addStyleDependentName(CLASSNAME_PROMPT);
}
removeStyleDependentName("focus");
}

public void focus() {
if (prompting) {
tb.setText("");
prompting = false;
removeStyleDependentName(CLASSNAME_PROMPT);
}
tb.setFocus(true);
}


+ 30
- 4
src/com/itmill/toolkit/terminal/gwt/client/ui/ITextField.java View File

@@ -47,6 +47,11 @@ public class ITextField extends TextBoxBase implements Paintable, Field,
private int extraVerticalPixels = -1;
private int maxLength = -1;

private static final String CLASSNAME_PROMPT = "prompt";
private static final String ATTR_INPUTPROMPT = "prompt";
private String inputPrompt = null;
private boolean prompting = false;

public ITextField() {
this(DOM.createInputText());
}
@@ -86,6 +91,8 @@ public class ITextField extends TextBoxBase implements Paintable, Field,
setReadOnly(false);
}

inputPrompt = uidl.getStringAttribute(ATTR_INPUTPROMPT);

setMaxLength(uidl.hasAttribute("maxLength") ? uidl
.getIntAttribute("maxLength") : -1);

@@ -95,7 +102,15 @@ public class ITextField extends TextBoxBase implements Paintable, Field,
setColumns(new Integer(uidl.getStringAttribute("cols")).intValue());
}

setText(uidl.getStringVariable("text"));
String text = uidl.getStringVariable("text");
prompting = inputPrompt != null && (text == null || text.equals(""));
if (prompting) {
setText(inputPrompt);
addStyleDependentName(CLASSNAME_PROMPT);
} else {
setText(text);
removeStyleDependentName(CLASSNAME_PROMPT);
}
valueBeforeEdit = uidl.getStringVariable("text");
}

@@ -103,13 +118,13 @@ public class ITextField extends TextBoxBase implements Paintable, Field,
if (newMaxLength > 0) {
maxLength = newMaxLength;
if (getElement().getTagName().toLowerCase().equals("textarea")) {
// NOP no maxlenght property for textarea
// NOP no maxlength property for textarea
} else {
getElement().setPropertyInt("maxLength", maxLength);
}
} else if (maxLength != -1) {
if (getElement().getTagName().toLowerCase().equals("textarea")) {
// NOP no maxlenght property for textarea
// NOP no maxlength property for textarea
} else {
getElement().setAttribute("maxlength", "");
}
@@ -125,7 +140,8 @@ public class ITextField extends TextBoxBase implements Paintable, Field,
public void onChange(Widget sender) {
if (client != null && id != null) {
String newText = getText();
if (newText != null && !newText.equals(valueBeforeEdit)) {
if (!prompting && newText != null
&& !newText.equals(valueBeforeEdit)) {
client.updateVariable(id, "text", getText(), immediate);
valueBeforeEdit = newText;
}
@@ -142,12 +158,22 @@ public class ITextField extends TextBoxBase implements Paintable, Field,

public void onFocus(Widget sender) {
addStyleDependentName(CLASSNAME_FOCUS);
if (prompting) {
setText("");
removeStyleDependentName(CLASSNAME_PROMPT);
}
focusedTextField = this;
}

public void onLostFocus(Widget sender) {
removeStyleDependentName(CLASSNAME_FOCUS);
focusedTextField = null;
String text = getText();
prompting = inputPrompt != null && (text == null || "".equals(text));
if (prompting) {
setText(inputPrompt);
addStyleDependentName(CLASSNAME_PROMPT);
}
onChange(sender);
}


+ 22
- 9
src/com/itmill/toolkit/ui/ComboBox.java View File

@@ -7,6 +7,8 @@ package com.itmill.toolkit.ui;
import java.util.Collection;
import com.itmill.toolkit.data.Container;
import com.itmill.toolkit.terminal.PaintException;
import com.itmill.toolkit.terminal.PaintTarget;
/**
* A filtering dropdown single-select. Suitable for newItemsAllowed, but it's
@@ -18,7 +20,7 @@ import com.itmill.toolkit.data.Container;
*/
public class ComboBox extends Select {
private String emptyText = null;
private String inputPrompt = null;
public ComboBox() {
setMultiSelect(false);
@@ -51,21 +53,32 @@ public class ComboBox extends Select {
super.setMultiSelect(multiSelect);
}
/*- TODO enable and test this - client impl exists
public String getEmptyText() {
return emptyText;
/**
* Gets the current input prompt.
*
* @see #setInputPrompt(String)
* @return the current input prompt, or null if not enabled
*/
public String getInputPrompt() {
return inputPrompt;
}
public void setEmptyText(String emptyText) {
this.emptyText = emptyText;
/**
* Sets the input prompt - a textual prompt that is displayed when the
* select would otherwise be empty, to prompt the user for input.
*
* @param inputPrompt
* the desired input prompt, or null to disable
*/
public void setInputPrompt(String inputPrompt) {
this.inputPrompt = inputPrompt;
}
public void paintContent(PaintTarget target) throws PaintException {
if (emptyText != null) {
target.addAttribute("emptytext", emptyText);
if (inputPrompt != null) {
target.addAttribute("prompt", inputPrompt);
}
super.paintContent(target);
}
-*/
}

+ 10
- 1
src/com/itmill/toolkit/ui/RichTextArea.java View File

@@ -8,7 +8,7 @@ import com.itmill.toolkit.terminal.PaintException;
import com.itmill.toolkit.terminal.PaintTarget;

/**
* A simple RichTextEditor to edit HTML format text.
* A simple RichTextArea to edit HTML format text.
*
* Note, that using {@link TextField#setMaxLength(int)} method in
* {@link RichTextArea} may produce unexpected results as formatting is counted
@@ -22,4 +22,13 @@ public class RichTextArea extends TextField {
super.paintContent(target);
}

/**
* RichTextArea does not support input prompt.
*/
@Override
public void setInputPrompt(String inputPrompt) {
throw new UnsupportedOperationException(
"RichTextArea does not support inputPrompt");
}

}

+ 26
- 0
src/com/itmill/toolkit/ui/TextField.java View File

@@ -73,6 +73,8 @@ public class TextField extends AbstractField {
*/
private boolean nullSettingAllowed = false;

private String inputPrompt = null;

/**
* Maximum character count in text field.
*/
@@ -159,6 +161,10 @@ public class TextField extends AbstractField {
target.addAttribute("maxLength", getMaxLength());
}

if (inputPrompt != null) {
target.addAttribute("prompt", inputPrompt);
}

// Adds the number of column and rows
final int c = getColumns();
final int r = getRows();
@@ -474,6 +480,26 @@ public class TextField extends AbstractField {
this.nullSettingAllowed = nullSettingAllowed;
}

/**
* Gets the current input prompt.
*
* @see #setInputPrompt(String)
* @return the current input prompt, or null if not enabled
*/
public String getInputPrompt() {
return inputPrompt;
}

/**
* Sets the input prompt - a textual prompt that is displayed when the field
* would otherwise be empty, to prompt the user for input.
*
* @param inputPrompt
*/
public void setInputPrompt(String inputPrompt) {
this.inputPrompt = inputPrompt;
}

/**
* Gets the value formatter of TextField.
*

Loading…
Cancel
Save