# 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.

- [Introduction](#introduction)
- [Writing an Action](#writing-an-action)
- [Running an Action from a Controller](#running-an-action-from-a-controller)
- [Failure Modes](#failure-modes)
- [Swapping an Action in Tests](#swapping-an-action-in-tests)
- [Generating an Action](#generating-an-action)
- [Gotchas](#gotchas)
- [Related Documentation](#related-documentation)

## 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`:

```dart
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:

```dart
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:

```dart
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>`, carrying `value` (the action's own result), when `handle` ran to completion;
- `ActionFailed<O>`, carrying `error` (the exception `handle` threw), on either failure mode below;
- `ActionRefused<O>`, 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`:

```dart
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`.

```dart
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):

```dart
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:

```dart
setUp(() {
  MagicAction.bind<PauseMonitor>(() => _FakePauseMonitor());
});

tearDown(() {
  MagicAction.flush();
});
```

## Generating an Action

```bash
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](https://anilcancakir.com/magic/docs/basics/controllers.md)
- [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md)
- [Testing Getting Started](https://anilcancakir.com/magic/docs/testing/getting-started.md)
- [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:action`.
