keyed_form 0.1.0
keyed_form: ^0.1.0 copied to clipboard
A form-state controller for immutable aggregates, built on keyed_form_core: holds the working draft, FieldKey-addressed validation errors and touched/dirty/submit state.
example/keyed_form_example.dart
// The form-state controller — the react-hook-form of this family, pure Dart.
//
// `KeyedFormController` owns the editable draft, the FieldKey-keyed errors and
// the touched / dirty / submitted bookkeeping that decides *when* an error is
// shown. No Flutter: it runs in a plain `main()` here, and drives a widget
// tree unchanged via `keyed_form_flutter`.
//
// dart run example/keyed_form_example.dart
import 'package:keyed_form/keyed_form.dart';
// ── Model + field references ──────────────────────────────────────────────
// Normally generated by `keyed_form_gen`; hand-written here to stay
// single-file. `Stop` is a `KeyedRow` so list rows are addressed by id.
class TourForm {
const TourForm({this.title = '', this.stops = const []});
final String title;
final List<Stop> stops;
TourForm copyWith({String? title, List<Stop>? stops}) =>
TourForm(title: title ?? this.title, stops: stops ?? this.stops);
@override
bool operator ==(Object other) =>
other is TourForm &&
other.title == title &&
_sameStops(other.stops, stops);
@override
int get hashCode => Object.hash(title, Object.hashAll(stops));
}
class Stop implements KeyedRow {
const Stop({required this.clientId, this.city = '', this.nights = 1});
@override
final String clientId;
final String city;
final int nights;
Stop copyWith({String? city, int? nights}) => Stop(
clientId: clientId,
city: city ?? this.city,
nights: nights ?? this.nights,
);
@override
bool operator ==(Object other) =>
other is Stop &&
other.clientId == clientId &&
other.city == city &&
other.nights == nights;
@override
int get hashCode => Object.hash(clientId, city, nights);
}
bool _sameStops(List<Stop> a, List<Stop> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
abstract final class StopFields {
static StrictFieldRef<Stop, String> get city =>
StrictFieldRef<Stop, String>.of(
key: FieldKey.name('city'),
get: (s) => s.city,
set: (s, v) => s.copyWith(city: v),
);
}
final class StopFieldRefs extends DelegatingFieldRef<TourForm, Stop> {
StopFieldRefs(super.inner);
FieldRef<TourForm, String> get city => inner.then(StopFields.city);
}
abstract final class TourFields {
static StrictFieldRef<TourForm, String> get title =>
StrictFieldRef<TourForm, String>.of(
key: FieldKey.name('title'),
get: (t) => t.title,
set: (t, v) => t.copyWith(title: v),
);
static StrictFieldRef<TourForm, List<Stop>> get stops =>
StrictFieldRef<TourForm, List<Stop>>.of(
key: FieldKey.name('stops'),
get: (t) => t.stops,
set: (t, v) => t.copyWith(stops: v),
);
static StopFieldRefs stop(String clientId) =>
StopFieldRefs(stops.at(clientId, (s) => s.clientId == clientId));
}
// ── The resolver: draft -> field-keyed errors (a zod `safeParse`) ─────────
FieldErrors<String> validateTour(TourForm draft, FieldKey? scope) {
final errors = <FieldKey, String>{};
if (draft.title.trim().length < 3) {
errors[TourFields.title.key] = 'Title needs at least 3 characters';
}
if (draft.stops.isEmpty) {
errors[TourFields.stops.key] = 'Add at least one stop';
}
for (final stop in draft.stops) {
if (stop.city.trim().isEmpty) {
errors[TourFields.stop(stop.clientId).city.key] = 'City is required';
}
}
final totalNights = draft.stops.fold<int>(0, (sum, s) => sum + s.nights);
if (totalNights > 14) {
errors[TourFields.stops.key] = 'A tour can be at most 14 nights';
}
return FieldErrors(errors);
}
void main() {
final form = KeyedFormController<TourForm>(
initialValue: const TourForm(
title: 'Kyoto in autumn',
stops: [Stop(clientId: 'a', city: 'Kyoto', nights: 3)],
),
mode: KeyedFormMode.onTouched,
resolver: validateTour,
);
// 1. `form.field(ref)` is the per-field facade — statically typed on the
// field's value, so `title.set(1000)` would not compile.
final title = form.field(TourFields.title);
// Write a bad value. The error is in the map immediately, but not yet
// *visible* under onTouched — the field hasn't been touched.
title.set('Ky');
print(form.errors(TourFields.title)); // Title needs at least 3 characters
print(title.error); // null
title.touch();
print(title.error); // Title needs at least 3 characters
title.set('Kyoto in early autumn');
print(title.error); // null — fixed
// 2. Field-array editing, by id (the `useFieldArray` analogue).
final stops = form.field(TourFields.stops).list();
stops.append(const Stop(clientId: 'b', city: '', nights: 20));
print(form.value.stops.length); // 2
// 3. Whole-draft validate — sets `submitted`, so every error shows.
print('valid: ${form.validate()}'); // valid: false
for (final key in form.visibleErrorKeys) {
print(' ${key.toPath()}: ${form.errors.byKey(key)}');
}
// stops.['b'].city: City is required
// stops: A tour can be at most 14 nights
// 4. Fix the offending row and revalidate.
stops.updateById('b', (s) => s.copyWith(city: 'Nara', nights: 2));
print('valid: ${form.validate()}'); // valid: true
// 5. Dirty tracking, against the value the form was seeded with.
print(
'dirty: ${form.isDirty}, title changed: ${form.differs(TourFields.title)}',
);
// 6. An immutable snapshot for hosts that want a value, not a listenable.
final snap = form.snapshot;
print('snapshot dirty=${snap.isDirty} errors=${snap.errors.length}');
// 7. Merge errors the server sent back, addressed by wire path.
form.setServerErrorPaths(const {
"stops.['a'].city": 'City not recognised by the booking system',
});
print(form.visibleError(FieldKey.parse("stops.['a'].city")));
}