search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

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

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, carrying value (the action's own result), when handle ran to completion;
  • ActionFailed, carrying error (the exception handle threw), on either failure mode below;
  • ActionRefused, when key (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 when key is omitted) running for the call's duration, refusing a second call under the same key while the first is still in flight (answers ActionRefused without running the action again);
  • on a ValidationException, paints its errors onto the host when the controller also mixes in ValidatesRequests; 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 titled trans('common.error_occurred') with failureMessage (or that same translated fallback, never the exception's own text, which may carry a raw transport message) as the body;
  • answers ActionFailed on either failure, and ActionSucceeded with 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