MishkaCmsCore.LanguageEditions (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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.

Summary

Types

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.

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.

Functions

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

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.

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

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

Whether a language filter is currently set.

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

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

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

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.

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.

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

Whether the table is currently showing search results.

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

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.

Which sibling relationship this viewer reads through.

Types

original()

@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()

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

Functions

cleared(map)

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

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

collapsed?(state)

@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(record, extra \\ fn _variant -> %{} end)

@spec editions(map(), (map() -> 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?(state)

@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?(arg1)

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

Whether a language filter is currently set.

load_variants(query, state, loads \\ [])

@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(failure, state, socket)

@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(map, socket)

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(resource, refused, actor)

@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(failure)

@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(query, state)

@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?(arg1)

@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(map)

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

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

take_translations(refusal, actor)

@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(arg1)

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

Which sibling relationship this viewer reads through.