keyed_form 0.1.0 copy "keyed_form: ^0.1.0" to clipboard
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")));
}
1
likes
160
points
--
downloads

Documentation

API reference

Publisher

verified publisherv4g.space

Weekly Downloads

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.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

keyed_form_core, listen, meta

More

Packages that depend on keyed_form