diff --git a/lib/src/common_fields.dart b/lib/src/common_fields.dart index 79222e8d..8447d30c 100644 --- a/lib/src/common_fields.dart +++ b/lib/src/common_fields.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'package:angel_framework/angel_framework.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:image/image.dart'; import 'field.dart'; import 'form_renderer.dart'; @@ -20,8 +22,9 @@ class TextField extends Field { bool isRequired = true, this.isTextArea = false, this.trim = true, - this.confirmedAs}) - : super(name, label: label, isRequired: isRequired); + this.confirmedAs, + String type = 'text'}) + : super(name, type, label: label, isRequired: isRequired); @override FutureOr accept(FormRenderer renderer) => @@ -59,8 +62,9 @@ class TextField extends Field { /// A [Field] that checks simply for its presence in the given data. /// Typically used for checkboxes. class BoolField extends Field { - BoolField(String name, {String label, bool isRequired = true}) - : super(name, label: label, isRequired: isRequired); + BoolField(String name, + {String label, bool isRequired = true, String type = 'checkbox'}) + : super(name, type, label: label, isRequired: isRequired); @override FutureOr accept(FormRenderer renderer) => @@ -89,8 +93,13 @@ class NumField extends Field { final num step; NumField(String name, - {String label, bool isRequired = true, this.max, this.min, this.step}) - : super(name, label: label, isRequired: isRequired) { + {String label, + String type = 'number', + bool isRequired = true, + this.max, + this.min, + this.step}) + : super(name, type, label: label, isRequired: isRequired) { _textField = TextField(name, label: label, isRequired: isRequired); } @@ -127,8 +136,14 @@ class NumField extends Field { /// A [NumField] that coerces its value to a [double]. class DoubleField extends NumField { DoubleField(String name, - {String label, bool isRequired = true, num step, double min, double max}) + {String label, + String type = 'number', + bool isRequired = true, + num step, + double min, + double max}) : super(name, + type: type, label: label, isRequired: isRequired, step: step, @@ -153,9 +168,15 @@ class DoubleField extends NumField { /// Passing a [double] will result in an error, so [step] defaults to 1. class IntField extends NumField { IntField(String name, - {String label, bool isRequired = true, num step = 1, int min, int max}) + {String label, + String type = 'number', + bool isRequired = true, + num step = 1, + int min, + int max}) : super(name, label: label, + type: type, isRequired: isRequired, step: step, min: min, @@ -179,3 +200,153 @@ class IntField extends NumField { } } } + +/// A [Field] that parses its value as an ISO6801 [DateTime]. +class DateTimeField extends Field { + // Reuse text validation logic. + TextField _textField; + + /// The minimum/maximum value for the field. + final DateTime min, max; + + /// The amount for a form field to increment by. + final num step; + + DateTimeField(String name, + {String label, + bool isRequired = true, + this.max, + this.min, + this.step, + String type = 'datetime-local'}) + : super(name, type, label: label, isRequired: isRequired) { + _textField = TextField(name, label: label, isRequired: isRequired); + } + + @override + FutureOr accept(FormRenderer renderer) => + renderer.visitDateTimeField(this); + + @override + Future> read(RequestContext req, + Map fields, Iterable files) async { + var result = await _textField.read(req, fields, files); + if (result == null) { + return null; + } else if (result.isSuccess != true) { + return FieldReadResult.failure(result.errors); + } else { + var value = DateTime.tryParse(result.value); + if (value != null) { + return FieldReadResult.success(value); + } else { + return FieldReadResult.failure( + ['"$name" must be a properly-formatted date.']); + } + } + } +} + +/// A [Field] that validates an [UploadedFile]. +class FileField extends Field { + /// If `true` (default), then the file must have a `content-type`. + final bool requireContentType; + + /// If `true` (default: `false`), then the file must have an associated + /// filename. + final bool requireFilename; + + /// If provided, then the `content-type` must be present in this [Iterable]. + final Iterable allowedContentTypes; + + FileField(String name, + {String label, + bool isRequired = true, + this.requireContentType = true, + this.requireFilename = false, + this.allowedContentTypes}) + : super(name, 'file', label: label, isRequired: isRequired) { + assert(allowedContentTypes == null || allowedContentTypes.isNotEmpty); + } + + @override + FutureOr accept(FormRenderer renderer) => + renderer.visitFileField(this); + + @override + FutureOr> read(RequestContext req, + Map fields, Iterable files) { + var file = files.firstWhere((f) => f.name == name, orElse: () => null); + if (file == null) { + return null; + } else if ((requireContentType || allowedContentTypes != null) && + file.contentType == null) { + return FieldReadResult.failure( + ['A content type must be given for file "$name".']); + } else if (requireFilename && file.filename == null) { + return FieldReadResult.failure( + ['A filename must be given for file "$name".']); + } else if (allowedContentTypes != null && + !allowedContentTypes.contains(file.contentType)) { + return FieldReadResult.failure([ + 'File "$name" cannot have content type ' + '"${file.contentType}". Allowed types: ' + '${allowedContentTypes.join(', ')}' + ]); + } else { + return FieldReadResult.success(file); + } + } +} + +/// A wrapper around [FileField] that reads its input into an [Image]. +/// +/// **CAUTION**: The uploaded file will be read in memory. +class ImageField extends Field { + FileField _fileField; + + /// The underlying [FileField]. + FileField get fileField => _fileField; + + ImageField(String name, + {String label, + bool isRequired = true, + bool requireContentType = true, + bool requireFilename = false, + Iterable allowedContentTypes}) + : super(name, 'file', label: label, isRequired: isRequired) { + _fileField = FileField(name, + label: label, + isRequired: isRequired, + requireContentType: requireContentType, + requireFilename: requireFilename, + allowedContentTypes: allowedContentTypes); + } + + @override + FutureOr accept(FormRenderer renderer) => + renderer.visitImageField(this); + + @override + FutureOr> read(RequestContext req, + Map fields, Iterable files) async { + var result = await fileField.read(req, fields, files); + if (result == null) { + return null; + } else if (!result.isSuccess) { + return FieldReadResult.failure(result.errors); + } else { + try { + var image = decodeImage(await result.value.readAsBytes()); + if (image == null) { + return FieldReadResult.failure(['"$name" must be an image file.']); + } else { + return FieldReadResult.success(image); + } + } on ImageException catch (e) { + return FieldReadResult.failure( + ['Error in image file "$name": ${e.message}']); + } + } + } +} diff --git a/lib/src/field.dart b/lib/src/field.dart index c788711b..d4aa529e 100644 --- a/lib/src/field.dart +++ b/lib/src/field.dart @@ -38,7 +38,10 @@ abstract class Field { /// present, an error will be generated. final bool isRequired; - Field(this.name, {this.label, this.isRequired = true}); + /// The input `type` attribute, if applicable. + final String type; + + Field(this.name, this.type, {this.label, this.isRequired = true}); /// Reads the value from the request body. /// @@ -91,7 +94,8 @@ class _MatchedField extends Field { final Iterable matchers; _MatchedField(this.inner, this.matchers) - : super(inner.name, label: inner.label, isRequired: inner.isRequired) { + : super(inner.name, inner.type, + label: inner.label, isRequired: inner.isRequired) { assert(matchers.isNotEmpty); } diff --git a/lib/src/form_renderer.dart b/lib/src/form_renderer.dart index 3e267328..858b0b98 100644 --- a/lib/src/form_renderer.dart +++ b/lib/src/form_renderer.dart @@ -9,6 +9,12 @@ abstract class FormRenderer { FutureOr visitBoolField(BoolField field); + FutureOr visitDateTimeField(DateTimeField field); + + FutureOr visitFileField(FileField field); + + FutureOr visitImageField(ImageField field); + FutureOr visitNumField(NumField field); FutureOr visitTextField(TextField field); diff --git a/pubspec.yaml b/pubspec.yaml index 8b477633..b36e1a0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,9 @@ environment: sdk: ">=2.0.0 <3.0.0" dependencies: angel_framework: ^2.0.0 + duration: ^2.0.0 html_builder: ^1.0.0 + image: ^2.0.0 matcher: ^0.12.5 dev_dependencies: angel_orm: ^2.1.0-beta