๐Ÿช„ MishkaGervaz

A comprehensive, declarative UI library for the Ash Framework โ€” define admin tables, forms, and data-driven interfaces entirely through DSL. โœจ

Hex.pmHex DownloadsLicenseGitHub SponsorsBuy Me a Coffee


Warning

๐Ÿšง Status โ€” alpha. APIs are still evolving and the library is not yet recommended for production. Track progress on GitHub and the CHANGELOG.


๐Ÿ“– Table of contents


๐Ÿ’ญ Why MishkaGervaz?

Building admin UIs around an Ash resource is repetitive: list views, filters, sorting, pagination, edit forms, validation, multi-step wizards, file uploads, master / tenant access control. Each surface calls the same building blocks in slightly different ways.

MishkaGervaz collapses that into a DSL. Declare what your admin surface looks like โ€” fields, columns, filters, steps, uploads, access rules โ€” and the library builds the LiveView, wires events, runs queries, handles form state, and renders through a swappable UI adapter. Every component, behaviour, and adapter is overridable; nothing is hidden.

mishka_gervaz do
table do
identity do
name :posts
route "/admin/dashboard/blog/posts"
end
columns do
column :title do
sortable true
label fn -> dgettext("blog", "Title") end
end
column :status do
label fn -> dgettext("blog", "Status") end
end
end
filters do
filter :search, :text, fields: [:title, :slug]
filter :status, :select do
options [{"Published", :published}, {"Draft", :draft}]
end
end
end
form do
fields do
field :title do
required true
ui do
label fn -> dgettext("blog", "Title") end
end
end
field :status do
options [{"Draft", :draft}, {"Published", :published}]
end
end
end
end

That's the whole admin surface for a resource. Add a route, render the LiveComponent, and you have a working list page with create / edit forms, filters, sort, master / tenant access gates, and PubSub-powered real-time updates. ๐Ÿš€


โœจ Highlights

๐Ÿ“Š Tables

๐Ÿ“ Forms

๐ŸŒ Cross-cutting


๐Ÿš€ Installation

Add to your mix.exs:

def deps do
[
{:mishka_gervaz, "~> 0.0.1-alpha.1"},
{:ash, "~> 3.0"},
{:ash_phoenix, "~> 2.3"},
{:phoenix_live_view, "~> 1.0"}
]
end

Fetch and compile:

mix deps.get
mix compile

Add the extension to your domain โ€” set defaults that every resource in the domain inherits:

defmodule MyApp.Blog do
use Ash.Domain,
extensions: [MishkaGervaz.Domain]
mishka_gervaz do
table do
actor_key :current_user
ui_adapter MishkaGervaz.UIAdapters.Tailwind
pagination do
type :numbered
page_size 20
page_size_options [20, 50, 100]
end
realtime do
pubsub MyApp.PubSub
end
actions do
read {:master_read, :read}
get {:master_get, :read}
destroy {:master_destroy, :destroy}
end
archive do
read_action {:master_archived, :archived}
get_action {:master_get_archived, :get_archived}
restore_action {:master_unarchive, :unarchive}
destroy_action {:master_permanent_destroy, :permanent_destroy}
end
end
form do
actions do
create {:master_create, :create}
update {:master_update, :update}
read {:master_get, :read}
end
submit do
create label: fn -> dgettext("blog", "Create") end
update label: fn -> dgettext("blog", "Save Changes") end
cancel label: fn -> dgettext("blog", "Cancel") end
position :bottom
end
end
end
resources do
resource MyApp.Blog.Post
end
end

Then add the resource extension:

defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
extensions: [MishkaGervaz.Resource]
# ... your attributes / actions / relationships
end

๐ŸŽฏ Quick start

๐Ÿ“Š A table

mishka_gervaz do
table do
identity do
name :posts
route "/admin/dashboard/blog/posts"
end
source do
preload do
always [:site, :tag_count, :comment_count]
end
end
columns do
column :title do
sortable true
render fn value ->
assigns = %{title: value}
~H"<span class=\"font-semibold\">{@title}</span>"
end
end
column :site_id do
static true
requires [:site_id, :site]
label fn -> dgettext("blog", "Site") end
end
column :status do
sortable true
sort_field [:status]
end
column :inserted_at do
sortable true
end
end
filters do
filter :search, :text, fields: [:title, :slug, :excerpt]
filter :site_id, :relation do
mode :search_multi
display_field :name
search_field :name
restricted true
end
filter :status, :select do
options [
{"Published", :published},
{"Draft", :draft},
{"Archived", :archived}
]
end
end
row_actions do
action :edit do
type :edit
visible :active
ui do
icon "hero-pencil-square"
class "text-blue-600 hover:bg-blue-50"
end
end
action :archive do
type :destroy
confirm "Archive this post?"
visible :active
end
action :unarchive do
type :unarchive
confirm "Restore this post?"
visible :archived
end
end
bulk_actions do
action :archive, type: :destroy, confirm: "Archive selected posts?"
action :unarchive,
type: :unarchive,
confirm: "Restore selected posts?",
visible: :archived
end
url_sync do
mode :bidirectional
params [:filters, :sort, :page, :search]
prefix "posts"
end
end
end

๐ŸŽฌ Mount it:

<.live_component
module={MishkaGervaz.Table.Web.Live}
id="posts-table"
resource={MyApp.Blog.Post}
current_user={@current_user}
/>

๐Ÿ“ A form

mishka_gervaz do
form do
identity do
name :post_form
end
source do
preload do
master [:master_collections, :master_tags]
tenant [:tenant_collections, :tenant_tags]
end
end
fields do
field :title do
required true
ui do
label fn -> dgettext("blog", "Title") end
placeholder "Post title"
end
end
field :status do
options [
{"Draft", :draft},
{"Published", :published},
{"Scheduled", :scheduled}
]
end
field :language, :combobox do
options fn ->
MyApp.Repo.distinct_languages()
|> Enum.map(fn lang -> {String.upcase(lang), lang} end)
end
ui do
label fn -> dgettext("blog", "Language") end
placeholder "en, fa, ar, ..."
end
end
field :site_id, :relation do
mode :search
display_field :name
search_field :name
restricted true
show_on :create
ui do
label fn -> dgettext("blog", "Site") end
end
end
field :tag_ids, :relation do
virtual true
resource MyApp.Blog.Tag
mode :search_multi
display_field :name
search_field :name
depends_on :site_id
load fn query, state ->
site_id =
if state.master_user?,
do: Map.get(state.field_values, :site_id),
else: state.current_user.site_id
Ash.Query.filter_input(query, %{site_id: site_id})
end
end
field :body, :textarea do
required true
ui do
label fn -> dgettext("blog", "Body") end
end
end
end
groups do
group :basic_info do
fields [:title, :status, :language]
ui do
label fn -> dgettext("blog", "Basics") end
end
end
group :relationships do
fields [:site_id, :tag_ids]
ui do
label fn -> dgettext("blog", "Relationships") end
end
end
group :content do
fields [:body]
ui do
label fn -> dgettext("blog", "Content") end
end
end
end
uploads do
upload :cover do
accept "image/*"
max_file_size 5_000_000
end
end
end
end

๐ŸŽฌ Mount it:

<.live_component
module={MishkaGervaz.Form.Web.Live}
id="post-form"
resource={MyApp.Blog.Post}
current_user={@current_user}
record_id={@post_id}
/>

๐Ÿชœ A wizard or tabbed multi-step form is a one-block change:

layout do
mode :wizard # or :tabs
step :basics do
groups [:basic_info]
end
step :metadata do
groups [:relationships]
end
step :content do
groups [:content]
end
step :review do
summary true
end
end

๐Ÿ”ง Customization & overrides

Three layers, each independent.

1๏ธโƒฃ Per-callback override via use

defmodule MyApp.Form.SubmitHandler do
use MishkaGervaz.Form.Web.Events.SubmitHandler
def transform_params(state, params) do
params
|> super(state)
|> Map.put("ingested_at", DateTime.utc_now())
end
end

super falls through to the default. Every callback in every sub-builder is defoverridable.

2๏ธโƒฃ Wire your override via DSL

mishka_gervaz do
form do
events do
submit MyApp.Form.SubmitHandler
validation MyApp.Form.ValidationHandler
end
state do
field MyApp.Form.FieldBuilder
end
data_loader do
relation MyApp.Form.RelationLoader
end
end
end

The DSL config is read at runtime by the orchestrator โ€” no recompiling the macro tree.

3๏ธโƒฃ Replace an entire subsystem module

mishka_gervaz do
form do
events MyApp.CustomFormEvents
state module: MyApp.CustomState
end
end

๐Ÿ“š See the moduledocs of MishkaGervaz.Form.Web.State, MishkaGervaz.Form.Web.Events, MishkaGervaz.Form.Web.DataLoader, and the table-side counterparts for the full override surface.


๐Ÿ—๏ธ Architecture

+----------------------------+
| Phoenix.LiveComponent |
| (Form.Web.Live / |
| Table.Web.Live) |
+--------------+-------------+
|
+-----------+-----------+-----------+-----------+
| | | | |
v v v v v
+-------+ +-------+ +-------+ +-------+ +-------+
| State | | Events| |DataLdr| |Render | |Adapter|
+-------+ +-------+ +-------+ +-------+ +-------+
| | | | |
v v v v v
sub-builders sub-handlers sub-builders templates components
(5) (7) (4) (Standard) (Tailwind / yours)

๐Ÿ”Œ Compatibility

DependencyRequired version
Elixir~> 1.17
Ash~> 3.0
AshPhoenix~> 2.3
Phoenix LiveView~> 1.0 (optional)
Spark~> 2.6
Gettext~> 1.0
Jason~> 1.0

๐Ÿ“š Documentation


๐Ÿ›ฃ๏ธ Status & roadmap

AreaStatus
Table DSL + LiveView๐ŸŸก Alpha โ€” feature-complete; API may change
Form DSL + LiveView๐ŸŸก Alpha โ€” feature-complete; API may change
Tailwind UI adapter๐ŸŸก Alpha
Multi-tenancy & access gates๐ŸŸข Stable in scope
Test coverage๐ŸŸข 3,600+ tests, growing
GuardedStruct integration๐Ÿ”ต Planned for the field-types layer
Docs site๐Ÿ”ต Planned

Breaking changes will be flagged in the CHANGELOG.


๐Ÿค Contributing

Issues, PRs, and design discussions are welcome.

git clone https://github.com/mishka-group/mishka_gervaz.git
cd mishka_gervaz
mix deps.get
mix test

Before opening a PR:

For larger feature work, please open an issue first so we can align on the design. ๐Ÿ’ฌ


๐Ÿ’– Funding & sponsorship

MishkaGervaz is open-source software developed by Mishka Group. If your team or company benefits from this work, please consider supporting continued development:

GitHub Sponsors ย ย ย  Buy Me a Coffee

โ˜• Donate / sponsor:github.com/sponsors/mishka-group ยท buymeacoffee.com/mishkagroup

Sponsorship directly funds maintenance, new features, and documentation. Thank you. ๐Ÿ’š


๐Ÿ“œ License

Apache License 2.0 โ€” see LICENSE.

Copyright ยฉ Mishka Group and contributors.