Actions
MagicAction 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.
- Introduction
- Writing an Action
- Running an Action from a Controller
- Failure Modes
- Swapping an Action in Tests
- Generating an Action
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 with the input and output types the write needs, and implement handle:
class PauseMonitor extends MagicAction {
const PauseMonitor();
@override
Future 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 pause(String id) async {
final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id);
if (!outcome.succeeded) return;
}
}
runAction answers an ActionOutcome, 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, carryingvalue(the action's own result), whenhandleran to completion;ActionFailed, carryingerror(the exceptionhandlethrew), on either failure mode below;ActionRefused, 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 {
const PauseMonitor();
@override
Future 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(factory) registers the implementation MagicAction.resolve returns for A; MagicAction.flush() clears every override and belongs in tearDown (or is covered by MagicTest.init()'s own reset):
setUp(() {
MagicAction.bind(() => _FakePauseMonitor());
});
tearDown(() {
MagicAction.flush();
});
Generating an Action
dart run magic:artisan make:action PauseMonitor # -> lib/app/actions/pause_monitor.dart
dart run magic:artisan make:action Monitors/PauseMonitor # Nested path support
dart run magic:artisan make:action PauseMonitor --force # Overwrite existing file