# `MishkaCmsCore.LanguageEditions`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/language_editions.ex#L5)

How an admin list of translatable records shows one row per document rather than one per translation.

A translated record is several rows of the same resource sharing a `language_group_id`, and the
group's key *is* the master's own id — so the master is the row where `language_group_id == id`, and
a record with no group at all is trivially its own master. Listing every row means listing
"Mishka CMS" beside "میشکا سی ام اس", which is the same document twice. The list shows masters; the
card's editions row reaches the rest.

Searching lifts the collapse. Someone typing Persian is looking for the Persian edition, and hiding
it behind its English master would mean their search found nothing — so a search sees every row and
each result opens its own language.

Applied through a table's `on_load` hook rather than its read action, because only the hook runs late
enough to see whether a search is active — `Ash.Query.for_read/3` happens before the table's filters
are on the query.

The archive is never collapsed. A master can be archived only once its translations are, so an
archive of masters would hide every archived translation behind a row with no chip for it — none of
them could be restored or deleted by anyone who did not know to search for it.

Which is also why a whole group is restored, or deleted for good, in one selection there —
`MishkaCmsCore.LanguageEditions.BulkActionHandler` hands that selection's masters over first, and
`MishkaCmsCore.Runtime.Resources.Changes.PreventOrphanedTranslationRestore` refuses a translation
whose master did not come back with it.

A live update is the other way a row reaches the list. The table asks its own read — this hook and
the list's filters included — whether the row that changed belongs, so a translation arriving on
its own is left out; `on_realtime/2` redraws its master instead, so the master's chips hold it.

## Usage

    hooks do
      on_load fn query, state ->
        {:cont,
         query
         |> MishkaCmsCore.LanguageEditions.scope_to_masters(state)
         |> MishkaCmsCore.LanguageEditions.load_variants(state)}
      end

      on_realtime fn notification, socket ->
        MishkaCmsCore.LanguageEditions.on_realtime(notification, socket)
      end
    end

    events do
      bulk_action MishkaCmsCore.LanguageEditions.BulkActionHandler
    end

The resource must declare both sides of the sibling relationship — `:language_variants` for a tenant
and `:master_language_variants` for a master, the latter reading through a tenancy-bypassing action.

## A delete refused for live translations

`MishkaCmsCore.Runtime.Resources.Validations.PreventMasterLanguageDeletion` refuses to archive, or
delete for good, an original whose translations are live. `on_delete_refused/3` turns that refusal
into an alert with a button that archives — or deletes for good — those translations at once
(`MishkaCmsCoreWeb.Live.Admin.Components.TranslationsRefusal`). Every table of a translated resource
declares it, the tables whose list is not collapsed included:

    hooks do
      on_row_action_error [:delete, :permanent_destroy],
                          &MishkaCmsCore.LanguageEditions.on_delete_refused/3

      on_bulk_action_error [:archive, :permanent_destroy],
                           &MishkaCmsCore.LanguageEditions.on_delete_refused/3

      on_bulk_action_success [:archive, :permanent_destroy],
                             &MishkaCmsCore.LanguageEditions.on_delete_refused/3
    end

`refusal/3` reads what the alert shows and `take_translations/2` does what its button does, both as
the signed-in admin, with the actions the resource's admin table itself reads and destroys with.

Used by `MishkaDocument.Workspace` (via `MishkaDocument.WorkspaceScope`), `MishkaBlog.Collection`
and `MishkaCmsCore.Runtime.Page`.
See `MishkaCmsCore.LanguageAccent` for the colour a language's chip wears.

# `original`

```elixir
@type original() :: %{
  id: String.t(),
  site_id: String.t() | nil,
  title: String.t() | nil,
  kind: :archive | :destroy | nil,
  message: String.t(),
  translations: [struct()],
  allowed?: boolean()
}
```

An original refused for its live translations, as `actor` sees it: `translations` are the live
ones `actor` can read, and `allowed?` says `actor` may archive — or, for an archived original,
delete for good — every one of them. `translations` is empty once none is live any more. `title`
is nil for an original `actor` cannot read, whose `message` is then the refusal's own.

# `refusal`

```elixir
@type refusal() :: %{
  resource: module(),
  kind: :archive | :destroy | nil,
  originals: [original()],
  allowed?: boolean()
}
```

What the alert shows: `kind` is `:archive` for a live original, `:destroy` for an archived one, and
`allowed?` holds when `actor` may act on every live translation of every original, and at least
one is live.

# `cleared`

```elixir
@spec cleared(refusal()) :: [original()]
```

The originals of `refusal/3` read with no live translation left, which can be deleted now.

# `collapsed?`

```elixir
@spec collapsed?(map()) :: boolean()
```

Whether the list shows one row per document: the active list, while nobody is looking for a
translation (`expanded?/1`). The archive shows every row.

# `editions`

```elixir
@spec editions(map(), (map() -&gt; map())) :: [map()]
```

A record's sibling editions — the same document in other languages — nearest thing first.

The variants relationship is a self-join on `language_group_id`, so it returns the record itself, and
every ungrouped record when the group is nil. Both have to go before this is a list of siblings.
Sorted by language so a card's chips keep their order between renders.

`extra` builds whatever else a chip shows beyond its language — pass a function of the variant.

# `expanded?`

```elixir
@spec expanded?(map()) :: boolean()
```

Whether the reader has asked to see individual translations rather than one row per document.

Two things lift the collapse, and both mean the same thing — someone is looking *for* a translation:

- a search, because typing Persian and being shown only its English master finds nothing;
- a language filter, because "show me Arabic" collapsed to masters would return nothing at all,
  every master being its own group's original.

# `language_filtered?`

```elixir
@spec language_filtered?(map()) :: boolean()
```

Whether a language filter is currently set.

# `load_variants`

```elixir
@spec load_variants(Ash.Query.t(), map(), list()) :: Ash.Query.t()
```

Loads a record's sibling editions, with whatever each chip needs to read off them.

Which relationship holds the siblings depends on who is looking: a master reaches them through the
tenancy-bypassing one. `loads` is the nested load each variant needs — a workspace's chip reads its
`health_metrics` to say "FA · 76%", a collection's needs nothing beyond the record itself.

# `on_delete_refused`

```elixir
@spec on_delete_refused(term(), map(), Phoenix.LiveView.Socket.t()) ::
  Phoenix.LiveView.Socket.t() | {:halt, Phoenix.LiveView.Socket.t()}
```

The Gervaz hook that answers a delete refused for live translations with
`MishkaCmsCoreWeb.Live.Admin.Components.TranslationsRefusal`'s alert — see the moduledoc.

Takes a row action's failure or a bulk action's summary. Answers `{:halt, socket}`, so no error
flash repeats the alert, when every failure was such a refusal; the socket otherwise — a bulk that
partly went included, so its own message still says how many did.

# `on_realtime`

```elixir
@spec on_realtime(Ash.Notifier.Notification.t(), Phoenix.LiveView.Socket.t()) ::
  {:cont | :halt, Phoenix.LiveView.Socket.t()}
```

Keeps a collapsed list collapsed while rows change under it — the `on_realtime` hook.

The table puts a created or changed row in by reading that one row, so a new translation would take
a row of its own beside its master, and the master would keep the chips it was drawn with. While
the list is `collapsed?/1`, a change to a translation redraws its master instead — read again, so
its chips hold the change — and takes out any row the translation had. A master's own change, and
every change while the list is not collapsed, goes the table's way.

The `on_realtime` contract is `fn notification, socket -> {:cont, socket} | {:halt, socket}`.

# `refusal`

```elixir
@spec refusal(module(), [%{id: String.t(), message: String.t()}], map() | nil) ::
  refusal()
```

Reads `refused_originals/1`'s originals and their live translations as `actor`, through the actions
the resource's admin table reads with, and asks whether `actor` may act on each translation
(`Ash.can/3`, validations included) with the action the table's Delete would use. A master reads
every site; a site admin reads its own site only. With no `actor` nothing is read.

# `refused_originals`

```elixir
@spec refused_originals(term()) :: [%{id: String.t(), message: String.t()}]
```

The originals a failed destroy was refused for because their translations are live, each with the
refusal's own message — from an Ash error, a list of them, or a bulk action's summary.

    LanguageEditions.refused_originals(error)
    #=> [%{id: "0199…", message: "This is the original English version. …"}]

# `scope_to_masters`

```elixir
@spec scope_to_masters(Ash.Query.t(), map()) :: Ash.Query.t()
```

Narrows the list to master editions while it is `collapsed?/1`.

The `on_load` contract is `fn query, state -> query`.

# `searching?`

```elixir
@spec searching?(map()) :: boolean()
```

Whether the table is currently showing search results.

Reads whatever the table's own text filters hold rather than naming one, so the resource stays the
place a filter is declared.

# `still_refused`

```elixir
@spec still_refused(%{originals: [original()]}) :: [original()]
```

The originals of `refusal/3` that still have a live translation.

# `take_translations`

```elixir
@spec take_translations(refusal(), map()) :: :ok | {:error, String.t()}
```

Archives — or, for a `:destroy` refusal, deletes for good — the translations `refusal/3` showed, as
`actor`, with the action the resource's admin table deletes with. Only the ones still live in the
original's group on its site are touched, and never one the alert did not name.

Each original whose translations it took fires `runtime:state:language_group:changed` with
`%{op, key, resource, site_id, translations, languages, actor_id}`: `op` is
`:translations_archived` or `:translations_deleted`, `key` the original's id, and `translations`
the ids taken.

# `variants_relationship`

```elixir
@spec variants_relationship(map()) :: atom()
```

Which sibling relationship this viewer reads through.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
