Actions
MagicAction<I, O> is a stateless, single-purpose write unit, Fortify's Actions\* ported: a subclass takes one input, does one write, and throws on failure instead of answering a bool.
On this page
Introduction#
A write scattered across a controller method (validate, call the endpoint, patch the local cache, show a toast, guard against a double-tap) repeats the same shape at every call site. MagicAction names the one piece that is genuinely single-purpose (the write itself) and RunsActions names the piece every call site already needed (in-flight guarding, error routing) so neither has to be re-typed per screen.
Writing an Action#
Subclass MagicAction<I, O> with the input and output types the write needs, and implement handle:
class PauseMonitor extends MagicAction<String, void> {
const PauseMonitor();
@override
Future<void> handle(String monitorId) async {
final response = await Http.update('monitors', monitorId, {'status': 'paused'});
if (!response.successful) {
throw Exception(response.errorMessage ?? 'Failed to pause monitor');
}
}
}
An action carries no MagicController base and nothing to dispose: it is a plain, often const, class. Resolve it through MagicAction.resolve rather than constructing it directly, so a test can swap the implementation via MagicAction.bind without the call site changing:
await MagicAction.resolve(PauseMonitor.new).handle(monitorId);
Running an Action from a Controller#
Mix RunsActions onto the controller and call runAction, checking the ActionOutcome it answers:
class MonitorController extends MagicController with RunsActions {
Future<void> pause(String id) async {
final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id);
if (!outcome.succeeded) return;
}
}
runAction answers an ActionOutcome<O>, a sealed type with three cases, so a caller can no longer mistake a refused double-tap for a success by checking a null result:
ActionSucceeded<O>, carryingvalue(the action's own result), whenhandleran to completion;ActionFailed<O>, carryingerror(the exceptionhandlethrew), on either failure mode below;ActionRefused<O>, whenkey(or the shared unkeyed slot) was already running: the action never ran a second time.
ActionOutcome also carries two convenience getters usable without a switch: succeeded (true only for ActionSucceeded) and valueOrNull (the value on success, null otherwise).
runAction:
- marks
key(or a shared default slot whenkeyis omitted) running for the call's duration, refusing a second call under the same key while the first is still in flight (answersActionRefusedwithout running the action again); - on a
ValidationException, paints its errors onto the host when the controller also mixes inValidatesRequests; on a host that does not, there is no error bag to paint it onto, so it falls back to the same feedback the generic-failure case gets; - on any other failure, calls
onFailure(error)when given, so the caller owns the feedback (its own toast, a silent cooldown, nothing), and otherwise logs the exception and shows a toast titledtrans('common.error_occurred')withfailureMessage(or that same translated fallback, never the exception's own text, which may carry a raw transport message) as the body; - answers
ActionFailedon either failure, andActionSucceededwith the action's own result on success.
isRunning([key]) reads whether a key (or the shared unkeyed slot) is currently running, for gating a button's isLoading:
WButton(
isLoading: controller.isRunning(monitor.id),
onTap: () => controller.pause(monitor.id),
)
Failure Modes#
What handle does |
What runAction does |
|---|---|
Throws ValidationException, host is ValidatesRequests |
Paints field errors via setErrorsFromMap; answers ActionFailed |
Throws ValidationException, host is not ValidatesRequests |
Falls back to the generic-failure toast below; answers ActionFailed |
| Throws anything else | Logs the exception, shows an error toast titled and bodied with trans('common.error_occurred') (or failureMessage as the body when given); answers ActionFailed |
| Runs to completion | Answers ActionSucceeded with the action's own result |
| Called again under a key already running | The second call is a no-op; answers ActionRefused without invoking handle |
common.error_occurred ships in every fresh app's assets/lang/en.json (from the magic:install stub); override the key in your own catalogue to change the wording.
ActionRequestFailed is the exception a resource action's handle throws for a refused write that is not a 422: the backend answered non-2xx, or an ORM write's save()/delete() answered false with no field errors to raise as a ValidationException. Its static ActionRequestFailed.refusalOf(action, errors, [response]) picks the right exception for you: a ValidationException holding the first message per field when errors is non-empty, otherwise an ActionRequestFailed carrying response.
class PauseMonitor extends MagicAction<String, void> {
const PauseMonitor();
@override
Future<void> handle(String monitorId) async {
final response = await Http.update('monitors', monitorId, {'status': 'paused'});
if (!response.successful) {
throw ActionRequestFailed.refusalOf('pause monitor', response.errors, response);
}
}
}
An ORM write consumes its own response, so pass the model's lastRemoteResponse as the third argument (the make:action --kind stubs do):
final Monitor monitor = Monitor()..fill(fields, strict: true);
if (await monitor.save()) return monitor;
throw ActionRequestFailed.refusalOf(
'create',
monitor.validationErrors,
monitor.lastRemoteResponse,
);
ActionRequestFailed exposes statusCode read off response; message, the JSON body's own message (a message given through ActionRequestFailed.withMessage wins); and retryAfterSeconds, read from a 429 body's retry_after_seconds, falling back to 1 when the body carries no usable value so a button waiting on it still recovers. message never returns the text the driver writes on the response (its "The connection errored: ..." or its paragraph about a non-JSON error page): that is not the backend's word, so it stays in toString() for the log line and out of a toast.
isTransportFailure is true when the response is the driver's status 0: no readable answer, whether the connection failed, timed out, hit a cross-origin error page, or returned a 2xx the client could not decode. Nothing judged the input, so "check the form" is the wrong copy. The write may or may not have landed, so a create is worth a refresh before the operator presses again. With no response at all, isTransportFailure is false: unknown is not proof.
Swapping an Action in Tests#
MagicAction.bind<A>(factory) registers the implementation MagicAction.resolve returns for A; MagicAction.flush() clears every override and belongs in tearDown, because the bindings are a static map that MagicTest.init() does not reset:
setUp(() {
MagicAction.bind<PauseMonitor>(() => _FakePauseMonitor());
});
tearDown(() {
MagicAction.flush();
});
Generating an Action#
dart run magic:dowel make:action PauseMonitor # -> lib/app/actions/pause_monitor.dart
dart run magic:dowel make:action Monitors/PauseMonitor # Nested path support
dart run magic:dowel make:action PauseMonitor --force # Overwrite existing file
Gotchas#
| Mistake | Fix |
|---|---|
MagicAction.bind(() => FakePauseMonitor()) with no type argument |
Name the type: MagicAction.bind<PauseMonitor>(...). Dart infers the fake's own type from the closure, so resolve never finds the binding and the override silently never applies |
| Constructing the action directly at the call site | Go through MagicAction.resolve(PauseMonitor.new), otherwise a test cannot swap it |
Treating a non-null result as success |
runAction answers ActionRefused for a same-key re-entry: check outcome.succeeded |
Two buttons calling runAction with no key |
Unkeyed calls share one slot and refuse each other; pass a key per target |
A ValidationException produces a toast instead of field errors |
The controller must also mix in ValidatesRequests; without it the error bag does not exist and the generic-failure feedback runs |
| Bindings from one test leak into the next | Call MagicAction.flush() in tearDown; MagicTest.init() does not clear them |
Related Documentation#
- Controllers
- Validation
- Testing Getting Started
- Magic CLI:
make:action.