Commit 113991da authored by Matt Perry's avatar Matt Perry Committed by GitHub

Rethink Forms. (#6569)

FormField is now a widget that can contain any type of field. Input no
longer has special code to handle form fields. Instead, there is a
helper widget InputFormField for using an Input inside a FormField.

Fixes https://github.com/flutter/flutter/issues/6097 and based on
feedback from the same.
parent 43b4fc90
......@@ -189,23 +189,25 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
});
}
return new CollapsibleBody(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
child: new Form(
child: new Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: new Input(
hintText: item.hint,
labelText: item.name,
value: new InputValue(text: item.value),
formField: new FormField<String>(
setter: (String val) { item.value = val; }
return new Form(
child: new Builder(
builder: (BuildContext context) {
return new CollapsibleBody(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
onSave: () { Form.of(context).save(); close(); },
onCancel: () { Form.of(context).reset(); close(); },
child: new Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: new InputFormField(
hintText: item.hint,
labelText: item.name,
initialValue: new InputValue(text: item.value),
onSaved: (InputValue val) { item.value = val.text; },
),
),
),
),
),
onSave: close,
onCancel: close
);
}
)
);
}
),
......@@ -221,54 +223,61 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
});
}
void changeLocation(_Location newLocation) {
setState(() {
item.value = newLocation;
});
}
return new CollapsibleBody(
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Bahamas,
groupValue: item.value,
onChanged: changeLocation
),
new Text('Bahamas')
]
),
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Barbados,
groupValue: item.value,
onChanged: changeLocation
),
new Text('Barbados')
]
),
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Bermuda,
groupValue: item.value,
onChanged: changeLocation
),
new Text('Bermuda')
]
)
]
),
onSave: close,
onCancel: close
return new Form(
child: new Builder(
builder: (BuildContext context) {
return new CollapsibleBody(
onSave: () { Form.of(context).save(); close(); },
onCancel: () { Form.of(context).reset(); close(); },
child: new FormField<_Location>(
initialValue: item.value,
onSaved: (_Location result) { item.value = result; },
builder: (FormFieldState<_Location> field) {
return new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Bahamas,
groupValue: field.value,
onChanged: field.onChanged,
),
new Text('Bahamas')
]
),
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Barbados,
groupValue: field.value,
onChanged: field.onChanged,
),
new Text('Barbados')
]
),
new Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Radio<_Location>(
value: _Location.Bermuda,
groupValue: field.value,
onChanged: field.onChanged,
),
new Text('Bermuda')
]
)
]
);
}
),
);
}
)
);
}
),
......@@ -284,22 +293,30 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
});
}
return new CollapsibleBody(
child: new Slider(
value: item.value,
min: 0.0,
max: 100.0,
divisions: 5,
activeColor: Colors.orange[100 + (item.value * 5.0).round()],
label: '${item.value.round()}',
onChanged: (double value) {
setState(() {
item.value = value;
});
return new Form(
child: new Builder(
builder: (BuildContext context) {
return new CollapsibleBody(
onSave: () { Form.of(context).save(); close(); },
onCancel: () { Form.of(context).reset(); close(); },
child: new FormField<double>(
initialValue: item.value,
onSaved: (double value) { item.value = value; },
builder: (FormFieldState<double> field) {
return new Slider(
min: 0.0,
max: 100.0,
divisions: 5,
activeColor: Colors.orange[100 + (field.value * 5.0).round()],
label: '${field.value.round()}',
value: field.value,
onChanged: field.onChanged,
);
},
),
);
}
),
onSave: close,
onCancel: close
)
);
}
)
......
......@@ -30,37 +30,39 @@ class TextFieldDemoState extends State<TextFieldDemo> {
));
}
GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
GlobalKey<FormFieldState<InputValue>> _passwordFieldKey = new GlobalKey<FormFieldState<InputValue>>();
void _handleSubmitted() {
// TODO(mpcomplete): Form could keep track of validation errors?
if (_validateName(person.name) != null ||
_validatePhoneNumber(person.phoneNumber) != null ||
_validatePassword(person.password) != null) {
FormState form = _formKey.currentState;
if (form.hasErrors) {
showInSnackBar('Please fix the errors in red before submitting.');
} else {
form.save();
showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}');
}
}
String _validateName(String value) {
if (value.isEmpty)
String _validateName(InputValue value) {
if (value.text.isEmpty)
return 'Name is required.';
RegExp nameExp = new RegExp(r'^[A-za-z ]+$');
if (!nameExp.hasMatch(value))
if (!nameExp.hasMatch(value.text))
return 'Please enter only alphabetical characters.';
return null;
}
String _validatePhoneNumber(String value) {
String _validatePhoneNumber(InputValue value) {
RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$');
if (!phoneExp.hasMatch(value))
if (!phoneExp.hasMatch(value.text))
return '###-###-#### - Please enter a valid phone number.';
return null;
}
String _validatePassword(String value) {
if (person.password == null || person.password.isEmpty)
String _validatePassword(InputValue value) {
FormFieldState<InputValue> passwordField = _passwordFieldKey.currentState;
if (passwordField.value == null || passwordField.value.text.isEmpty)
return 'Please choose a password.';
if (person.password != value)
if (passwordField.value.text != value.text)
return 'Passwords don\'t match';
return null;
}
......@@ -73,55 +75,57 @@ class TextFieldDemoState extends State<TextFieldDemo> {
title: new Text('Text fields')
),
body: new Form(
key: _formKey,
child: new Block(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: <Widget>[
new Input(
hintText: 'What do people call you?',
labelText: 'Name',
formField: new FormField<String>(
// TODO(mpcomplete): replace with person#name=
setter: (String val) { person.name = val; },
validator: _validateName
)
// It's simpler to use an InputFormField, as below, but a FormField
// that builds an Input is equivalent.
new FormField<InputValue>(
initialValue: InputValue.empty,
onSaved: (InputValue val) { person.name = val.text; },
validator: _validateName,
builder: (FormFieldState<InputValue> field) {
return new Input(
hintText: 'What do people call you?',
labelText: 'Name',
value: field.value,
onChanged: field.onChanged,
errorText: field.errorText
);
},
),
new Input(
new InputFormField(
hintText: 'Where can we reach you?',
labelText: 'Phone Number',
keyboardType: TextInputType.phone,
formField: new FormField<String>(
setter: (String val) { person.phoneNumber = val; },
validator: _validatePhoneNumber
)
onSaved: (InputValue val) { person.phoneNumber = val.text; },
validator: _validatePhoneNumber,
),
new Input(
new InputFormField(
hintText: 'Tell us about yourself (optional)',
labelText: 'Life story',
maxLines: 3,
formField: new FormField<String>()
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Flexible(
child: new Input(
child: new InputFormField(
key: _passwordFieldKey,
hintText: 'How do you log in?',
labelText: 'New Password',
hideText: true,
formField: new FormField<String>(
setter: (String val) { person.password = val; }
)
onSaved: (InputValue val) { person.password = val.text; }
)
),
new SizedBox(width: 16.0),
new Flexible(
child: new Input(
child: new InputFormField(
hintText: 'How do you log in?',
labelText: 'Re-type Password',
hideText: true,
formField: new FormField<String>(
validator: _validatePassword
)
validator: _validatePassword,
)
)
]
......
......@@ -20,13 +20,9 @@ export 'package:flutter/services.dart' show TextInputType;
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// If the [Input] has a [Form] ancestor, the [formField] property must
/// be specified. In this case, the [Input] keeps track of the value of
/// the [Input] field automatically, and the initial value can be specified
/// using the [value] property.
///
/// If the [Input] does not have a [Form] ancestor, then the [value]
/// must be updated each time the [onChanged] callback is invoked.
/// The [value] field must be updated each time the [onChanged] callback is
/// invoked. Be sure to include the full [value] provided by the [onChanged]
/// callback, or information like the current selection will be lost.
///
/// See also:
///
......@@ -39,8 +35,9 @@ class Input extends StatefulWidget {
/// Creates a text input field.
///
/// By default, the input uses a keyboard appropriate for text entry.
///
/// The [formField] argument is required if the [Input] has an ancestor [Form].
//
// Note: If you change this constructor signature, please also update
// InputFormField below.
Input({
Key key,
this.value,
......@@ -54,17 +51,12 @@ class Input extends StatefulWidget {
this.isDense: false,
this.autofocus: false,
this.maxLines: 1,
this.formField,
this.onChanged,
this.onSubmitted,
}) : super(key: key);
/// The text of the input field.
///
/// If the [Input] is in a [Form], this is the initial value only.
///
/// Otherwise, this is the current value, and must be updated every
/// time [onChanged] is called.
/// The current state of text of the input field. This includes the selected
/// text, if any, among other things.
final InputValue value;
/// The type of keyboard to use for editing the text.
......@@ -86,9 +78,6 @@ class Input extends StatefulWidget {
final String hintText;
/// Text to show when the input text is invalid.
///
/// If this is set, then the [formField]'s [FormField.validator], if any, is
/// ignored.
final String errorText;
/// The style to use for the text being edited.
......@@ -111,27 +100,12 @@ class Input extends StatefulWidget {
/// horizontally instead.
final int maxLines;
/// The [Form] entry for this input control. Required if the input is in a [Form].
/// Ignored otherwise.
///
/// Putting an Input in a [Form] means the Input will keep track of its own value,
/// using the [value] property only as the field's initial value. It also means
/// that when any field in the [Form] changes, all the widgets in the form will be
/// rebuilt, so that each field's [FormField.validator] callback can be reevaluated.
final FormField<String> formField;
/// Called when the text being edited changes.
///
/// If the [Input] is not in a [Form], the [value] must be updated each time [onChanged]
/// is invoked. (If there is a [Form], then the value is tracked in the [formField], and
/// this callback is purely advisory.)
///
/// If the [Input] is in a [Form], this is called after the [formField] is updated.
/// The [value] must be updated each time [onChanged] is invoked.
final ValueChanged<InputValue> onChanged;
/// Called when the user indicates that they are done editing the text in the field.
///
/// If the [Input] is in a [Form], this is called after the [formField] is notified.
final ValueChanged<InputValue> onSubmitted;
@override
......@@ -146,34 +120,15 @@ class _InputState extends State<Input> {
GlobalKey get focusKey => config.key is GlobalKey ? config.key : _rawInputKey;
// Optional state to retain if we are inside a Form widget.
_FormFieldData _formData;
@override
void dispose() {
_formData?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
ThemeData themeData = Theme.of(context);
BuildContext focusContext = focusKey.currentContext;
bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus);
if (_formData == null) {
_formData = _FormFieldData.maybeCreate(context, this);
} else {
_formData = _formData.maybeDispose(context);
}
InputValue value = _formData?.value ?? config.value ?? InputValue.empty;
ValueChanged<InputValue> onChanged = _formData?.onChanged ?? config.onChanged;
ValueChanged<InputValue> onSubmitted = _formData?.onSubmitted ?? config.onSubmitted;
InputValue value = config.value ?? InputValue.empty;
String errorText = config.errorText;
if (errorText == null && config.formField != null && config.formField.validator != null)
errorText = config.formField.validator(value.text);
TextStyle textStyle = config.style ?? themeData.textTheme.subhead;
Color activeColor = themeData.hintColor;
if (focused) {
......@@ -263,8 +218,8 @@ class _InputState extends State<Input> {
selectionControls: materialTextSelectionControls,
platform: Theme.of(context).platform,
keyboardType: config.keyboardType,
onChanged: onChanged,
onSubmitted: onSubmitted,
onChanged: config.onChanged,
onSubmitted: config.onSubmitted,
)
));
......@@ -312,68 +267,44 @@ class _InputState extends State<Input> {
}
}
// _FormFieldData is a helper class for _InputState for when the Input
// is in a Form.
//
// An instance is created when the Input is put in a Form, and lives
// until the Input is taken placed somewhere without a Form. (If the
// Input is moved from one Form to another, the same _FormFieldData is
// used for both forms).
//
// The _FormFieldData stores the value of the Input. Without a Form,
// the Input is essentially stateless.
class _FormFieldData {
_FormFieldData(this.inputState) {
assert(field != null);
value = inputState.config.value ?? new InputValue();
}
final _InputState inputState;
InputValue value;
FormField<String> get field => inputState.config.formField;
static _FormFieldData maybeCreate(BuildContext context, _InputState inputState) {
// Only create a _FormFieldData if this Input is a descendent of a Form.
if (FormScope.of(context) != null)
return new _FormFieldData(inputState);
return null;
}
_FormFieldData maybeDispose(BuildContext context) {
if (FormScope.of(context) != null)
return this;
dispose();
return null;
}
void dispose() {
value = null;
}
void onChanged(InputValue value) {
assert(value != null);
assert(field != null);
FormScope scope = FormScope.of(inputState.context);
assert(scope != null);
this.value = value;
if (field.setter != null)
field.setter(value.text);
if (inputState.config.onChanged != null)
inputState.config.onChanged(value);
scope.onFieldChanged();
}
void onSubmitted(InputValue value) {
assert(value != null);
assert(field != null);
FormScope scope = FormScope.of(inputState.context);
assert(scope != null);
if (scope.form.onSubmitted != null)
scope.form.onSubmitted();
if (inputState.config.onSubmitted != null)
inputState.config.onSubmitted(value);
scope.onFieldChanged();
}
/// A [FormField] that contains an [Input].
class InputFormField extends FormField<InputValue> {
InputFormField({
Key key,
GlobalKey focusKey,
TextInputType keyboardType: TextInputType.text,
Icon icon,
String labelText,
String hintText,
TextStyle style,
bool hideText: false,
bool isDense: false,
bool autofocus: false,
int maxLines: 1,
InputValue initialValue: InputValue.empty,
FormFieldSetter<InputValue> onSaved,
FormFieldValidator<InputValue> validator,
}) : super(
key: key,
initialValue: initialValue,
onSaved: onSaved,
validator: validator,
builder: (FormFieldState<InputValue> field) {
return new Input(
key: focusKey,
keyboardType: keyboardType,
icon: icon,
labelText: labelText,
hintText: hintText,
style: style,
hideText: hideText,
isDense: isDense,
autofocus: autofocus,
maxLines: maxLines,
value: field.value,
onChanged: field.onChanged,
errorText: field.errorText,
);
},
);
}
......@@ -4,7 +4,6 @@
import 'package:meta/meta.dart';
import 'basic.dart';
import 'framework.dart';
/// A container for grouping together multiple form field widgets (e.g.
......@@ -16,79 +15,94 @@ class Form extends StatefulWidget {
Form({
Key key,
@required this.child,
this.onSubmitted
}) : super(key: key) {
assert(child != null);
}
/// Called when the input is accepted anywhere on the form.
final VoidCallback onSubmitted;
/// Returns the closest [FormState] which encloses the given context.
///
/// Typical usage is as follows:
///
/// ```dart
/// FormState form = Form.of(context);
/// form.save();
/// ```
static FormState of(BuildContext context) {
_FormScope scope = context.inheritFromWidgetOfExactType(_FormScope);
return scope?._formState;
}
/// Root of the widget hierarchy that contains this form.
final Widget child;
@override
_FormState createState() => new _FormState();
FormState createState() => new FormState();
}
class _FormState extends State<Form> {
int generation = 0;
class FormState extends State<Form> {
int _generation = 0;
Set<FormFieldState<dynamic>> _fields = new Set<FormFieldState<dynamic>>();
void _onFieldChanged() {
/// Called when a form field has changed. This will cause all form fields
/// to rebuild, useful if form fields have interdependencies.
void _fieldDidChange() {
setState(() {
++generation;
++_generation;
});
}
void _register(FormFieldState<dynamic> field) {
_fields.add(field);
}
void _unregister(FormFieldState<dynamic> field) {
_fields.remove(field);
}
@override
Widget build(BuildContext context) {
return new FormScope._(
return new _FormScope(
formState: this,
generation: generation,
generation: _generation,
child: config.child
);
}
}
/// Signature for validating a form field.
typedef String FormFieldValidator<T>(T value);
/// Signature for being notified when a form field changes value.
typedef void FormFieldSetter<T>(T newValue);
/// Identifying information for form controls.
class FormField<T> {
/// Creates identifying information for form controls
FormField({
this.setter,
this.validator
});
/// Saves every FormField that is a descendant of this Form.
void save() {
for (FormFieldState<dynamic> field in _fields)
field.save();
}
/// An optional method to call with the new value when the form field changes.
final FormFieldSetter<T> setter;
/// Resets every FormField that is a descendant of this Form back to its
/// initialState.
void reset() {
for (FormFieldState<dynamic> field in _fields)
field.reset();
_fieldDidChange();
}
/// An optional method that validates an input. Returns an error string to
/// display if the input is invalid, or null otherwise.
final FormFieldValidator<T> validator;
/// Returns true if any descendant FormField has an error, false otherwise.
bool get hasErrors {
for (FormFieldState<dynamic> field in _fields) {
if (field.hasError)
return true;
}
return false;
}
}
/// A widget that establishes a scope for a [Form].
///
/// Cannot be created directly. Instead, create a [Form] widget, which builds
/// a [FormScope].
///
/// Useful for locating the closest enclosing [Form].
class FormScope extends InheritedWidget {
FormScope._({
class _FormScope extends InheritedWidget {
_FormScope({
Key key,
Widget child,
_FormState formState,
FormState formState,
int generation
}) : _formState = formState,
_generation = generation,
super(key: key, child: child);
final _FormState _formState;
final FormState _formState;
/// Incremented every time a form field has changed. This lets us know when
/// to rebuild the form.
......@@ -97,22 +111,117 @@ class FormScope extends InheritedWidget {
/// The [Form] associated with this widget.
Form get form => _formState.config;
/// The closest [FormScope] encloses the given context.
///
/// Typical usage is as follows:
///
/// ```dart
/// FormScope form = FormScope.of(context);
/// ```
static FormScope of(BuildContext context) {
return context.inheritFromWidgetOfExactType(FormScope);
@override
bool updateShouldNotify(_FormScope old) => _generation != old._generation;
}
/// Signature for validating a form field.
typedef String FormFieldValidator<T>(T value);
/// Signature for being notified when a form field changes value.
typedef void FormFieldSetter<T>(T newValue);
/// Signature for building the widget representing the form field.
typedef Widget FormFieldBuilder<T>(FormFieldState<T> field);
/// A single form field. This widget maintains the current state of the form
/// field, so that updates and validation errors are visually reflected in the
/// UI.
///
/// When used inside a [Form], you can use methods on [FormState] to query or
/// manipulate the form data as a whole. For example, calling [FormState.save]
/// will invoke each [FormField]'s [onSaved] callback in turn.
///
/// Use a [GlobalKey] with [FormField] if you want to retrieve its current
/// state, for example if you want one form field to depend on another.
///
/// See also: [Form], [InputFormField]
class FormField<T> extends StatefulWidget {
FormField({
Key key,
@required this.builder,
this.onSaved,
this.validator,
this.initialValue,
}) : super(key: key) {
assert(builder != null);
}
/// Use this to notify the Form that a form field has changed. This will
/// cause all form fields to rebuild, useful if form fields have
/// interdependencies.
void onFieldChanged() => _formState._onFieldChanged();
/// An optional method to call with the final value when the form is saved via
/// Form.save().
final FormFieldSetter<T> onSaved;
/// An optional method that validates an input. Returns an error string to
/// display if the input is invalid, or null otherwise.
final FormFieldValidator<T> validator;
/// Function that returns the widget representing this form field. It is
/// passed the form field state as input, containing the current value and
/// validation state of this field.
final FormFieldBuilder<T> builder;
/// An optional value to initialize the form field to, or null otherwise.
final T initialValue;
@override
bool updateShouldNotify(FormScope old) => _generation != old._generation;
FormFieldState<T> createState() => new FormFieldState<T>();
}
class FormFieldState<T> extends State<FormField<T>> {
T _value;
String _errorText;
/// The current value of the form field.
T get value => _value;
/// The current validation error returned by [FormField]'s [validator]
/// callback, or null if no errors.
String get errorText => _errorText;
/// True if this field has any validation errors.
bool get hasError => _errorText != null;
/// Calls the [FormField]'s onSaved method with the current value.
void save() {
if (config.onSaved != null)
config.onSaved(value);
}
/// Resets the field to its initial value.
void reset() {
setState(() {
_value = config.initialValue;
_errorText = null;
});
}
/// Updates this field's state to the new value. Useful for responding to
/// child widget changes, e.g. [Slider]'s onChanged argument.
void onChanged(T value) {
setState(() {
_value = value;
});
Form.of(context)?._fieldDidChange();
}
@override
void initState() {
super.initState();
_value = config.initialValue;
}
@override
void deactivate() {
Form.of(context)?._unregister(this);
super.deactivate();
}
@override
Widget build(BuildContext context) {
if (config.validator != null)
_errorText = config.validator(_value);
Form.of(context)?._register(this);
return config.builder(this);
}
}
......@@ -1453,6 +1453,18 @@ abstract class BuildContext {
/// ancestor is added or removed).
InheritedWidget inheritFromWidgetOfExactType(Type targetType);
/// Obtains the element corresponding to the nearest widget of the given type,
/// which must be the type of a concrete [InheritedWidget] subclass.
///
/// Calling this method is O(1) with a small constant factor.
///
/// This method does not establish a relationship with the target in the way
/// that [inheritFromWidgetOfExactType] does. It is normally used by such
/// widgets to obtain their corresponding [InheritedElement] object so that they
/// can call [InheritedElement.dispatchDependenciesChanged] to actually
/// notify the widgets that _did_ register such a relationship.
InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType);
/// Returns the nearest ancestor widget of the given type, which must be the
/// type of a concrete [Widget] subclass.
///
......@@ -2450,6 +2462,12 @@ abstract class Element implements BuildContext {
return null;
}
@override
InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType) {
InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[targetType];
return ancestor;
}
void _updateInheritance() {
assert(_active);
_inheritedWidgets = _parent?._inheritedWidgets;
......@@ -3166,8 +3184,9 @@ class InheritedElement extends ProxyElement {
/// [InheritedElement] calls this function if [InheritedWidget.updateShouldNotify]
/// returns true. Subclasses of [InheritedElement] might wish to call this
/// function at other times if their inherited information changes outside of
/// the build phase.
@protected
/// the build phase. [InheritedWidget] subclasses can also call this directly
/// by first obtaining their [InheritedElement] using
/// [BuildContext.ancestorInheritedElementForWidgetOfExactType].
void dispatchDependenciesChanged() {
for (Element dependent in _dependents) {
assert(() {
......
......@@ -14,17 +14,17 @@ void main() {
mockTextInput.enterText(text);
}
testWidgets('Setter callback is called', (WidgetTester tester) async {
testWidgets('onSaved callback is called', (WidgetTester tester) async {
GlobalKey<FormState> formKey = new GlobalKey<FormState>();
String fieldValue;
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Input(
formField: new FormField<String>(
setter: (String value) { fieldValue = value; }
)
key: formKey,
child: new InputFormField(
onSaved: (InputValue value) { fieldValue = value.text; }
)
)
)
......@@ -38,6 +38,7 @@ void main() {
Future<Null> checkText(String testValue) async {
enterText(testValue);
await tester.idle();
formKey.currentState.save();
// pump'ing is unnecessary because callback happens regardless of frames
expect(fieldValue, equals(testValue));
}
......@@ -48,17 +49,15 @@ void main() {
testWidgets('Validator sets the error text', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
String errorText(String input) => input + '/error';
String errorText(InputValue input) => input.text + '/error';
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Input(
child: new InputFormField(
key: inputKey,
formField: new FormField<String>(
validator: errorText
)
validator: errorText
)
)
)
......@@ -72,7 +71,7 @@ void main() {
await tester.idle();
await tester.pump();
// Check for a new Text widget with our error text.
expect(find.text(errorText(testValue)), findsOneWidget);
expect(find.text(errorText(new InputValue(text: testValue))), findsOneWidget);
}
await checkErrorText('Test');
......@@ -80,31 +79,29 @@ void main() {
});
testWidgets('Multiple Inputs communicate', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
GlobalKey<FormState> formKey = new GlobalKey<FormState>();
GlobalKey<FormFieldState<InputValue>> fieldKey = new GlobalKey<FormFieldState<InputValue>>();
GlobalKey inputFocusKey = new GlobalKey();
GlobalKey focusKey = new GlobalKey();
// Input 1's text value.
String fieldValue;
// Input 2's validator depends on a input 1's value.
String errorText(String input) => fieldValue.toString() + '/error';
String errorText(InputValue input) => fieldKey.currentState.value?.text.toString() + '/error';
Widget builder() {
return new Center(
child: new Material(
child: new Form(
key: formKey,
child: new Focus(
key: focusKey,
child: new Block(
children: <Widget>[
new Input(
key: inputKey,
formField: new FormField<String>(
setter: (String value) { fieldValue = value; }
)
new InputFormField(
autofocus: true,
key: fieldKey,
focusKey: inputFocusKey,
),
new Input(
formField: new FormField<String>(
validator: errorText
)
new InputFormField(
validator: errorText
)
]
)
......@@ -115,18 +112,16 @@ void main() {
}
await tester.pumpWidget(builder());
Focus.moveTo(inputKey);
await tester.pump();
Focus.moveTo(inputFocusKey);
Future<Null> checkErrorText(String testValue) async {
enterText(testValue);
await tester.idle();
await tester.pump();
expect(fieldValue, equals(testValue));
// Check for a new Text widget with our error text.
expect(find.text(errorText(testValue)), findsOneWidget);
expect(find.text(testValue + '/error'), findsOneWidget);
return null;
}
......@@ -136,20 +131,18 @@ void main() {
testWidgets('Provide initial value to input', (WidgetTester tester) async {
String initialValue = 'hello';
String currentValue;
GlobalKey<FormFieldState<InputValue>> inputKey = new GlobalKey<FormFieldState<InputValue>>();
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new Input(
value: new InputValue(text: initialValue),
formField: new FormField<String>(
setter: (String value) { currentValue = value; }
)
)
)
child: new Material(
child: new Form(
child: new InputFormField(
key: inputKey,
initialValue: new InputValue(text: initialValue),
)
)
)
);
}
......@@ -164,12 +157,63 @@ void main() {
expect(editableText.config.value.text, equals(initialValue));
// sanity check, make sure we can still edit the text and everything updates
expect(currentValue, isNull);
expect(inputKey.currentState.value.text, equals(initialValue));
enterText('world');
await tester.idle();
expect(currentValue, equals('world'));
await tester.pump();
expect(inputKey.currentState.value.text, equals('world'));
expect(editableText.config.value.text, equals('world'));
});
testWidgets('No crash when a FormField is removed from the tree', (WidgetTester tester) async {
GlobalKey<FormState> formKey = new GlobalKey<FormState>();
GlobalKey fieldKey = new GlobalKey();
String fieldValue;
Widget builder(bool remove) {
return new Center(
child: new Material(
child: new Form(
key: formKey,
child: remove ?
new Container() :
new InputFormField(
key: fieldKey,
autofocus: true,
onSaved: (InputValue value) { fieldValue = value.text; },
validator: (InputValue value) { return value.text.isEmpty ? null : 'yes'; }
)
)
)
);
}
await tester.pumpWidget(builder(false));
await tester.pump();
expect(fieldValue, isNull);
expect(formKey.currentState.hasErrors, isFalse);
enterText('Test');
await tester.idle();
await tester.pumpWidget(builder(false));
// Form wasn't saved, but validator runs immediately.
expect(fieldValue, null);
expect(formKey.currentState.hasErrors, isTrue);
formKey.currentState.save();
// Now fieldValue is saved.
expect(fieldValue, 'Test');
expect(formKey.currentState.hasErrors, isTrue);
// Now remove the field with an error.
await tester.pumpWidget(builder(true));
// Reset the form. Should not crash.
formKey.currentState.reset();
formKey.currentState.save();
expect(formKey.currentState.hasErrors, isFalse);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment