Add file+image fields

This commit is contained in:
Tobe O 2019-10-16 21:47:14 -04:00
parent 01dbb95808
commit 2023cf221d
4 changed files with 193 additions and 10 deletions

View file

@ -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<String> {
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<U> accept<U>(FormRenderer<U> renderer) =>
@ -59,8 +62,9 @@ class TextField extends Field<String> {
/// A [Field] that checks simply for its presence in the given data.
/// Typically used for checkboxes.
class BoolField extends Field<bool> {
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<U> accept<U>(FormRenderer<U> renderer) =>
@ -89,8 +93,13 @@ class NumField<T extends num> extends Field<T> {
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<T extends num> extends Field<T> {
/// A [NumField] that coerces its value to a [double].
class DoubleField extends NumField<double> {
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<double> {
/// Passing a [double] will result in an error, so [step] defaults to 1.
class IntField extends NumField<int> {
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<int> {
}
}
}
/// A [Field] that parses its value as an ISO6801 [DateTime].
class DateTimeField extends Field<DateTime> {
// 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<U> accept<U>(FormRenderer<U> renderer) =>
renderer.visitDateTimeField(this);
@override
Future<FieldReadResult<DateTime>> read(RequestContext req,
Map<String, dynamic> fields, Iterable<UploadedFile> 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<UploadedFile> {
/// 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<MediaType> 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<U> accept<U>(FormRenderer<U> renderer) =>
renderer.visitFileField(this);
@override
FutureOr<FieldReadResult<UploadedFile>> read(RequestContext req,
Map<String, dynamic> fields, Iterable<UploadedFile> 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<Image> {
FileField _fileField;
/// The underlying [FileField].
FileField get fileField => _fileField;
ImageField(String name,
{String label,
bool isRequired = true,
bool requireContentType = true,
bool requireFilename = false,
Iterable<MediaType> allowedContentTypes})
: super(name, 'file', label: label, isRequired: isRequired) {
_fileField = FileField(name,
label: label,
isRequired: isRequired,
requireContentType: requireContentType,
requireFilename: requireFilename,
allowedContentTypes: allowedContentTypes);
}
@override
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
renderer.visitImageField(this);
@override
FutureOr<FieldReadResult<Image>> read(RequestContext req,
Map<String, dynamic> fields, Iterable<UploadedFile> 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}']);
}
}
}
}

View file

@ -38,7 +38,10 @@ abstract class Field<T> {
/// 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<T> extends Field<T> {
final Iterable<Matcher> 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);
}

View file

@ -9,6 +9,12 @@ abstract class FormRenderer<T> {
FutureOr<T> visitBoolField(BoolField field);
FutureOr<T> visitDateTimeField(DateTimeField field);
FutureOr<T> visitFileField(FileField field);
FutureOr<T> visitImageField(ImageField field);
FutureOr<T> visitNumField(NumField field);
FutureOr<T> visitTextField(TextField field);

View file

@ -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