Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ All notable changes to this project will be documented in this file.
### BREAKING

- **`state()` and `count()` return a copy, and every factory implements `Factory<T> newFactory()`.** They used to mutate the factory and return it, so `final f = UserFactory(); f.state({...}); f.make()` carried the state, and two branches off one base leaked into each other. Dart cannot construct "the same subclass" on its own, so the new abstract hook answers with the subclass constructor (`Factory<User> newFactory() => UserFactory();`). Named states move to an extension on `Factory<T>`, since a method on the subclass is out of reach after the first `state()` or `count()`; the old `state({...}) as UserFactory` cast would now throw. No factory subclass exists in this package's `lib/`, `test/` or `example/`; the `make:factory` stub and `doc/database/seeding.md` show the new shape. (`lib/src/database/seeding/factory.dart`, `assets/stubs/factory.stub`, `doc/database/seeding.md`)
- **`MagicTest.init()` resets the Gate, the Translator and the DateManager between tests.** It now calls `Gate.flush()` in `setUp` and `tearDown`, and `DateManager.reset()` plus `Translator.reset()` in `tearDown`, so an ability, a loaded catalogue or a locale no longer leaks into the next test. A suite under `MagicTest.init()` that defines abilities or loads translations in `setUpAll` loses them after the first test and must move that work to `setUp`. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`)

### Added

- **`Model.unguarded()`, `Model.unguard()`, `Model.reguard()` and `Model.isUnguarded`, Laravel's mass-assignment switch.** Inside `Model.unguarded(() => ...)` every `fill()` keeps every key, whatever `fillable` and `guarded` say; the guard comes back when the callback returns or throws, and a nested call leaves the outer scope unguarded. The callback must be synchronous, since an async one would run its later `fill()` calls guarded, and an assertion says so, now checked whether or not the call nests inside an outer `unguard()`/`unguarded()` scope. Outside it, `fill()` behaves exactly as before, `strict: true` included; `fromMap()` and `setRawAttributes()` are untouched. (`lib/src/database/eloquent/model.dart`)
- **`Factory.raw()`**, the merged definition and states without a model: always a `List<Map<String, dynamic>>`, one map per model, a single map when no `count()` was set. (`lib/src/database/seeding/factory.dart`)
- **`Carbon.setTestNow([testNow])` and `Carbon.hasTestNow()`, Laravel's frozen-clock testing helper.** `Carbon.now([timezone])` returns the frozen instant while one is set (timezone conversion still applies on top of it), `isToday()`, `isYesterday()`, `isTomorrow()`, `isFuture()`, `isPast()` and argument-less `diffForHumans()` measure against it, and `Carbon.setTestNow()` with no argument (or `null`) clears the freeze. A test that seeds an app's clock now has a Laravel-parity seam instead of threading a fake `DateTime` through every call site. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`)
- **`Number`, `Str`, `Arr`, and `Cast`, Laravel's Support helpers ported to the subset magic needs.** `Number` formats values, currency, percentages, file sizes, and abbreviations under a resolved locale (`Number.percentage(99.95, precision: 2, locale: 'tr')` -> `'%99,95'`); `Str.upper`/`Str.lower`/`Str.initials` apply the Turkish/Azerbaijani dotted-i rule `String.toUpperCase()`/`toLowerCase()` get wrong; `Arr.get`/`has`/`set`/`dot` walk a dotted path through a nested `Map<String, dynamic>`; `Cast` reads a loosely-typed wire value (`stringOr`, `intOr`, `numOrNull`, `boolOr`, `idOrNull`, ...) as a specific type, degrading to a fallback instead of throwing. All four are `abstract final class` static namespaces with no shared base. (`lib/src/support/number.dart`, `lib/src/support/str.dart`, `lib/src/support/arr.dart`, `lib/src/support/cast.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`)
- **`Carbon.shortDiffForHumans([other])`, a compact ladder for dense tables.** Steps seconds through years (`'14m ago'`, `'1mo ago'`, `'5m from now'`, `'Just now'` under one second), resolving each unit and wrapper through the `Lang` catalogue (`time.units_short.*`, `time.ago`, `time.from_now`, `time.just_now`) with an English literal fallback when no catalogue is loaded. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`)
- **`ValidatesRequests.validateRequest`/`validateRequestAsync`, running a `FormRequest` through a controller's own error bag.** `FormRequest.validate()` returns only the rule-filtered payload and never touches `validationErrors`, so a controller calling it directly skipped clearing stale errors, populating per-field errors, and repainting the form. The new methods run the same authorize/prepare sequence but validate through the mixin's `validate()` and return the FULL prepared map. `validateRequest` runs sync rules only (an `AsyncRule` is skipped); `validateRequestAsync` awaits `Validator.validateAsync()` so an `AsyncRule` actually runs. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`)
- **`CollapsesIndexedErrorKeys`, an opt-in `ValidatesRequests.errorFieldFor` override for a list field's indexed error keys.** A backend validating a list returns one wire key per element (`items.0.name`); a form with a single error slot per field has nowhere to put a per-index message. Mixed in on top of `ValidatesRequests`, it collapses such a key to its field name (`items.0.name` -> `name`), keeping the FIRST message when two indexed keys collapse onto the same field. Opt-in because the default keeping raw keys is a behaviour existing controllers already depend on. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`)
- **`RefetchesOnMount<C, W>` and `SubmitsOnce<W>`, two view-layer mixins closing gaps in magic's singleton-controller and async-submit model.** `RefetchesOnMount`, mixed onto a `MagicStatefulViewState`, fire-and-forget refetches a controller's data on every mount (not only the first, since controllers fire `onInit` once per instance for the app's lifetime); `SubmitsOnce`, mixed onto a form's `State`, guards a submit handler against a second tap while the first write is in flight, resetting in a `finally` so a throwing submit re-arms the button. (`lib/src/ui/refetches_on_mount.dart`, `lib/src/ui/submits_once.dart`, `doc/basics/views.md`, `skills/magic-framework/`)
- **`Env.filled(key, fallback)` and `Env.getOrFail(key)`, guards for a value a blank silently corrupts.** `Env.get`/`env()` only fall back when a key is entirely absent, so a present-but-blank or quote-only value silently resolved to `''` and has shipped as a blank browser tab title and a link pointing at a path with no origin. `Env.filled` treats absent, blank, and quote-only the same way, stripping one wrapping quote pair and surrounding whitespace from a present value. `Env.getOrFail` mirrors Laravel's `Env::getOrFail`, throwing a `StateError` only when the key is entirely missing. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`)
- **`MagicTest.loadTranslations(locale, {directory})`, a real translation catalogue for a widget test.** Reads `<directory>/<locale>.json` off disk, flattens it with the app's own flatten rule (`JsonAssetLoader.flatten`, now public), and installs it before awaiting the translator's load, so `trans()` resolves real catalogue strings in a test instead of rendering the raw dotted key. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`, `skills/magic-framework/`)

### Changed

Expand All @@ -21,6 +29,7 @@ All notable changes to this project will be documented in this file.
### Fixed

- **The `AbilityCallback` doc listed `bool callback(Model user)` as valid.** The gate always calls `callback(user, arguments)`, so that shape throws and the ability is denied; the doc now shows `(Model user, [dynamic arg])`. (`lib/src/auth/gate_manager.dart`)
- **`env('KEY', fallback)` returned the literal two-character string `'""'`/`"''"` for `KEY=""`/`KEY=''`, not the default and not an empty string.** `flutter_dotenv`'s own parser needs at least one character inside the quotes to strip them, so a present-but-quote-only value passed through unquoted. `Env.get` now trims a value that is exactly `'""'` or `"''"` down to `''`, matching Laravel's `Env::get` (`laravel-framework/src/Illuminate/Support/Env.php:271`): an explicit empty value is `''`, never treated as absent. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`)

## [0.0.21] - 2026-09-24

Expand Down
14 changes: 14 additions & 0 deletions assets/stubs/install/lang_en.stub
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,19 @@
"min": "The :attribute must be at least :min characters.",
"max": "The :attribute may not be greater than :max characters.",
"confirmed": "The :attribute confirmation does not match."
},
"time": {
"just_now": "Just now",
"ago": ":time ago",
"from_now": ":time from now",
"units_short": {
"second": ":counts",
"minute": ":countm",
"hour": ":counth",
"day": ":countd",
"week": ":countw",
"month": ":countmo",
"year": ":county"
}
}
}
46 changes: 46 additions & 0 deletions doc/basics/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Views extend `MagicView` or `MagicStatefulView` to give each screen a typed cont
- [Stateful Views](#stateful-views)
- [Form Handling](#form-handling)
- [Rendering Async State](#rendering-async-state)
- [Refetching Data on Mount](#refetching-on-mount)
- [Guarding a Submit Against a Double Tap](#submits-once)
- [Responsive Views](#responsive-views)
- [Generating Views](#generating-views)

Expand Down Expand Up @@ -195,6 +197,50 @@ Widget build(BuildContext context) {

Each callback is optional. Magic provides sensible defaults if omitted.

<a name="refetching-on-mount"></a>
## Refetching Data on Mount

Controllers are Type-keyed singletons and fire `onInit` ONCE per controller instance, not once per view mount, so a controller that loads its data in `onInit` fetches on the first view that resolves it and never again for the lifetime of the app. Navigating away and back re-renders the same cached rows, which reads as stale or fabricated data rather than as a stale screen.

Mix `RefetchesOnMount<Controller, View>` onto a `MagicStatefulViewState` and point `refetch` at a load method your controller defines (`ensureFresh` below). That method must JOIN a load already in flight rather than start a second one: the mount that creates the controller has already started the same load from `onInit`, so a refetch that always fires a new request sends every request twice.

```dart
class _ItemsListViewState
extends MagicStatefulViewState<ItemsController, ItemsListView>
with RefetchesOnMount<ItemsController, ItemsListView> {
@override
Future<void> refetch() => controller.ensureFresh();
}
```

The refetch is fire-and-forget: `build()` renders the cached data immediately and the view rebuilds once the fresh data lands, so a mount never blocks on the network. Have the load keep its last-known-good data on failure, so a failed refetch leaves the screen as it was. Keep a separate method for a refresh after a mutation: it must not join an older in-flight request, or it returns a snapshot without the row the user just created.

<a name="submits-once"></a>
## Guarding a Submit Against a Double Tap

An `async` submit handler wired straight to a button (`onTap: _onSubmit`) leaves nothing disabling the button for the duration of the await, so a double tap fires the write twice. On a create path that is not idempotent, two taps create two records.

Mix `SubmitsOnce<W>` onto the form's `State`, route the handler through `submitOnce`, and feed `isSubmitting` to the button's `isLoading`:

```dart
class _RegisterFormState extends State<RegisterForm> with SubmitsOnce<RegisterForm> {
Future<void> _onSubmit() async {
await Http.post('/register', data: form.data);
}

@override
Widget build(BuildContext context) {
return WButton(
isLoading: isSubmitting,
onTap: () => submitOnce(_onSubmit),
child: WText(trans('auth.register')),
);
}
}
```

Feeding `isSubmitting` to the button's `isLoading` is what actually blocks the second tap: a well-behaved button computes `isInteractive = !isLoading && !disabled` and passes `null` for `onTap` when that is false, so the spinner and the guard are the same switch. A throwing submit re-arms the button rather than leaving it spinning forever, since the reset runs in a `finally`.

<a name="responsive-views"></a>
## Responsive Views

Expand Down
29 changes: 29 additions & 0 deletions doc/digging-deeper/carbon.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Carbon is Magic's date and time utility, inspired by PHP's Carbon library, offer
- [Manipulation](#manipulation)
- [Comparison](#comparison)
- [Human Readable](#human-readable)
- [Short Human Readable](#short-human-readable)
- [Timezone Support](#timezone-support)
- [Testing](#testing)
- [Model Integration](#model-integration)
Expand Down Expand Up @@ -189,6 +190,34 @@ date1.diffForHumans(date2); // "5 days before"
date2.diffForHumans(date1); // "5 days after"
```

<a name="short-human-readable"></a>
## Short Human Readable

`shortDiffForHumans([other])` is a compact ladder (seconds, minutes, hours, days, weeks, months, years, each threshold exclusive of the next) for dense tables and list rows where `diffForHumans()` reads too wide. It measures against Carbon's clock (frozen while `Carbon.setTestNow` is set), or against `other` when given:

```dart
createdAt.shortDiffForHumans(); // "14m ago"
dueAt.shortDiffForHumans(); // "5m from now"
```

A gap under one second reads `"Just now"`; a future instant reads `":time from now"` instead of `":time ago"`. Months are truncated 30-day buckets, so a 45-day gap reads `"1mo ago"`, not `"1 month, 15 days ago"`.

Every unit and wrapper resolves through the `Lang` catalogue when present (`time.units_short.<unit>`, `time.ago`, `time.from_now`, `time.just_now`), falling back to the English literal (`:counts`, `:countm`, `:counth`, `:countd`, `:countw`, `:countmo`, `:county`, `:time ago`, `:time from now`, `Just now`) when no catalogue is loaded or the key is missing:

```json
{
"time": {
"units_short": { "minute": ":count dk" },
"ago": ":time önce"
}
}
```

```dart
// With the catalogue above loaded for 'tr':
event.shortDiffForHumans(); // "14 dk önce"
```

<a name="timezone-support"></a>
## Timezone Support

Expand Down
Loading
Loading