diff --git a/.github/workflows/banking_proxy_release.yml b/.github/workflows/banking_proxy_release.yml index 6a6fafe5e..664c80a97 100644 --- a/.github/workflows/banking_proxy_release.yml +++ b/.github/workflows/banking_proxy_release.yml @@ -19,7 +19,7 @@ jobs: - id: setup-elixir uses: erlef/setup-elixir@v1 with: - otp-version: "25.0.4" + otp-version: "25.3.2.7" elixir-version: "1.14.0" - name: Setup the Elixir project diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f96d523e7..c8bbd1502 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,7 +15,7 @@ jobs: - id: setup-elixir uses: erlef/setup-elixir@v1 with: - otp-version: "25.0.4" + otp-version: "25.3.2.7" elixir-version: "1.14.0" - name: Setup the Elixir project diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e0bb974e..574e46d51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - id: setup-elixir uses: erlef/setup-elixir@v1 with: - otp-version: "25.0.4" + otp-version: "25.3.2.7" elixir-version: "1.14.0" - name: Setup the Elixir project diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e20e37cb4..4dbb79dcb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - id: setup-elixir uses: erlef/setup-elixir@v1 with: - otp-version: "25.0.4" + otp-version: "25.3.2.7" elixir-version: "1.14.0" - name: Setup the Elixir project diff --git a/.tool-versions b/.tool-versions index b39e1c180..3fa6184d2 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -erlang 25.0.4 +erlang 25.3.2.7 elixir 1.14.0-otp-25 nodejs 18.19.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index d0f43fd34..a44241c74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,6 @@ "elixir": "html", "phoenix-heex": "html" }, - "editor.tabCompletion": "on" + "editor.tabCompletion": "on", + "elixirLS.mixEnv": "dev" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 27dfec786..8ab59d40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## \#2 unreleased +* Added: Project breadcrumbs for easy navigation and hierarchy overview. * Changed: Format of the filenames in Storages. Also no folders used anymore. This has impact on Data Donation studies. * Changed: Assignment does not have a Storage association anymore. Projects can have one Storage that is shared between all the project items. * Added: Storage project item (BuiltIn and Yoda) diff --git a/Makefile b/Makefile index 345ba7b2c..ec9dc5f56 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ prepare: test format compile credo .PHONY: dialyzer dialyzer: FORCE - cd core && mix dialyzer --force-check + cd core && mix dialyzer --force-check --format short .PHONY: test test: ${MIX_PROJECTS:%=test/%} diff --git a/core/.dialyzer_ignore.exs b/core/.dialyzer_ignore.exs index b0b229680..735ef86e0 100644 --- a/core/.dialyzer_ignore.exs +++ b/core/.dialyzer_ignore.exs @@ -1,5 +1,7 @@ [ # https://github.com/phoenixframework/phoenix/issues/5437, fixed in Phoenix 1.7.3 or higher {"systems/benchmark/export_controller.ex", :no_return}, - {"systems/benchmark/export_controller.ex", :call} + {"systems/benchmark/export_controller.ex", :call}, + # https://elixirforum.com/t/dialyzer-listed-not-implemented-protocols-as-unknown-functions/2099/12 + ~r/.*:unknown_function Function .*__impl__\/1 does not exist.*/ ] diff --git a/core/assets/js/app.js b/core/assets/js/app.js index 6e0bbafc4..154e6d4df 100644 --- a/core/assets/js/app.js +++ b/core/assets/js/app.js @@ -18,7 +18,7 @@ import { decode } from "blurhash"; import { urlBase64ToUint8Array } from "./tools"; import { registerAPNSDeviceToken } from "./apns"; import "./100vh-fix"; -import { ViewportResize } from "./viewport_resize"; +import { Viewport } from "./viewport"; import { SidePanel } from "./side_panel"; import { Toggle } from "./toggle"; import { Cell } from "./cell"; @@ -36,6 +36,7 @@ window.registerAPNSDeviceToken = registerAPNSDeviceToken; window.addEventListener("phx:page-loading-stop", (info) => { if (info.detail.kind == "initial") { TimeZone.sendToServer(); + Viewport.sendToServer(); } }); @@ -108,7 +109,7 @@ let Hooks = { Tabbar, TabbarItem, TabbarFooterItem, - ViewportResize, + Viewport, Wysiwyg, AutoSubmit, Sticky, diff --git a/core/assets/js/tabbar.js b/core/assets/js/tabbar.js index eff8758db..355fcd436 100644 --- a/core/assets/js/tabbar.js +++ b/core/assets/js/tabbar.js @@ -2,6 +2,7 @@ let tabbarId = ""; export const Tabbar = { mounted() { + console.log("[Tabbar] mounted"); tabbarId = this.el.id; var initialTabId = this.el.dataset.initialTab @@ -21,6 +22,7 @@ export const Tabbar = { }, updated() { + console.log("[Tabbar] updated"); var savedTabId = this.loadActiveTab(); this.show(savedTabId, false); }, @@ -39,22 +41,36 @@ export const Tabbar = { }, saveActiveTab(tabId) { - console.info("saveActiveTab ", tabId); + console.info("[Tabbar] saveActiveTab ", tabId); window.localStorage.setItem(this.getActiveTabKey(), tabId); }, + getTabs() { + return document.querySelectorAll('[id^="tab_"]'); + }, + getFirstTab() { - var firstTab = document.querySelectorAll('[id^="tab_"]')[0]; - return firstTab.id; + var tabs = this.getTabs(); + console.log("tabs", tabs); + if (tabs == undefined) { + return undefined; + } else { + return tabs[0].id; + } }, show(nextTabId, scrollToTop) { + console.log("[Tabbar] nextTabId", nextTabId); + if (nextTabId == undefined) { + return; + } + this.saveActiveTab(nextTabId); var tabs = Array.from(document.querySelectorAll('[id^="tab_"]')); // Skip unknown tab if (!tabs.some((tab) => tab.id === nextTabId)) { - console.warn("Skip unknown tab", nextTabId); + console.warn("[Tabbar] Skip unknown tab", nextTabId); return; } diff --git a/core/assets/js/viewport.js b/core/assets/js/viewport.js new file mode 100644 index 000000000..a4b108e61 --- /dev/null +++ b/core/assets/js/viewport.js @@ -0,0 +1,63 @@ +import _ from "lodash"; + +let resizeHandler; + +export const Viewport = { + mounted() { + // // Direct push of current window size to properly update view + // this.pushChangeEvent(); + + window.addEventListener("resize", (event) => { + this.pushChangeEvent(); + }); + }, + + updated() { + console.log("[Viewport] updated"); + // this.pushChangeEvent(); + }, + + pushChangeEvent() { + console.log("[Viewport] push update event"); + this.pushEvent("viewport_changed", { + width: window.innerWidth, + height: window.innerHeight, + }); + }, + + turbolinksDisconnected() { + window.removeEventListener("resize", resizeHandler); + }, + + sendToServer() { + const viewport = { + width: window.innerWidth, + height: window.innerHeight, + }; + + console.log("[Viewport]", viewport); + + let csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content"); + + if (typeof window.localStorage != "undefined") { + try { + var xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/viewport", true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.setRequestHeader("x-csrf-token", csrfToken); + xhr.onreadystatechange = function () { + console.log( + "[Veiwport] POST onreadystatechange", + this.status, + this.readyState + ); + }; + xhr.send(`{"viewport": "${viewport}"}`); + } catch (e) { + console.log("[Viewport] Error while sending viewport to server", e); + } + } + }, +}; diff --git a/core/assets/js/viewport_resize.js b/core/assets/js/viewport_resize.js deleted file mode 100644 index 25470bb95..000000000 --- a/core/assets/js/viewport_resize.js +++ /dev/null @@ -1,26 +0,0 @@ -import _ from "lodash"; - -let resizeHandler; - -export const ViewportResize = { - mounted() { - // Direct push of current window size to properly update view - this.pushResizeEvent(); - - window.addEventListener("resize", (event) => { - this.pushResizeEvent(); - }); - }, - - pushResizeEvent() { - console.log("pushResizeEvent"); - this.pushEvent("viewport_resize", { - width: window.innerWidth, - height: window.innerHeight, - }); - }, - - turbolinksDisconnected() { - window.removeEventListener("resize", resizeHandler); - }, -}; diff --git a/core/bundles/next/lib/account/participant_signin_view.ex b/core/bundles/next/lib/account/participant_signin_view.ex index e831d09e1..573a60dae 100644 --- a/core/bundles/next/lib/account/participant_signin_view.ex +++ b/core/bundles/next/lib/account/participant_signin_view.ex @@ -33,7 +33,7 @@ defmodule Next.Account.ParticipantSigninView do
<%= for block <- @blocks do %> <%= if block == :google do %> - <.google_signin /> + <.google_signin creator?={false} /> <% end %> <%= if block == :password do %> <.password_signin for={@password_form} user_type={:participant}/> diff --git a/core/bundles/next/lib/account/signin_page.ex b/core/bundles/next/lib/account/signin_page.ex index c7eea8369..dd1636c5b 100644 --- a/core/bundles/next/lib/account/signin_page.ex +++ b/core/bundles/next/lib/account/signin_page.ex @@ -1,11 +1,19 @@ defmodule Next.Account.SigninPage do use CoreWeb, :live_view + + on_mount({CoreWeb.Live.Hook.Base, __MODULE__}) + on_mount({CoreWeb.Live.Hook.User, __MODULE__}) + on_mount({CoreWeb.Live.Hook.Uri, __MODULE__}) + on_mount({Frameworks.GreenLight.LiveHook, __MODULE__}) + on_mount({Frameworks.Fabric.LiveHook, __MODULE__}) + import CoreWeb.Layouts.Stripped.Html import CoreWeb.Layouts.Stripped.Composer + import CoreWeb.Menus + import Frameworks.Pixel.Line alias Frameworks.Pixel.Tabbar - alias Next.Account.SigninPageBuilder @impl true @@ -29,11 +37,16 @@ defmodule Next.Account.SigninPage do } end - defp update_view_model(socket) do + def update_view_model(socket) do vm = SigninPageBuilder.view_model(nil, socket.assigns) assign(socket, vm: vm) end + def update_menus(%{assigns: %{current_user: user, uri: uri}} = socket) do + menus = build_menus(stripped_menus_config(), user, uri) + assign(socket, menus: menus) + end + @impl true def render(assigns) do ~H""" diff --git a/core/bundles/self/lib/account/signin_page.ex b/core/bundles/self/lib/account/signin_page.ex index a0df80394..f91024e9e 100644 --- a/core/bundles/self/lib/account/signin_page.ex +++ b/core/bundles/self/lib/account/signin_page.ex @@ -1,11 +1,20 @@ defmodule Self.Account.SigninPage do use CoreWeb, :live_view + + on_mount({CoreWeb.Live.Hook.Base, __MODULE__}) + on_mount({CoreWeb.Live.Hook.User, __MODULE__}) + on_mount({CoreWeb.Live.Hook.Uri, __MODULE__}) + on_mount({Frameworks.GreenLight.LiveHook, __MODULE__}) + on_mount({Frameworks.Fabric.LiveHook, __MODULE__}) + import CoreWeb.Layouts.Stripped.Html import CoreWeb.Layouts.Stripped.Composer + import CoreWeb.Menus alias Systems.Account.User alias Systems.Account.UserForm + @impl true def mount(params, _session, socket) do require_feature(:password_sign_in) @@ -18,6 +27,11 @@ defmodule Self.Account.SigninPage do } end + def update_menus(%{assigns: %{current_user: user, uri: uri}} = socket) do + menus = build_menus(stripped_menus_config(), user, uri) + assign(socket, menus: menus) + end + defp update_form(%{assigns: %{email: nil}} = socket) do assign(socket, :form, to_form(%{})) end diff --git a/core/config/config.exs b/core/config/config.exs index 14b17167b..1f1d5aa59 100644 --- a/core/config/config.exs +++ b/core/config/config.exs @@ -42,11 +42,10 @@ config :plug, :statuses, %{ 404 => "Page not found" } -config :core, :naming, handlers: [Systems.Project.Public] - config :core, CoreWeb.FileUploader, max_file_size: 100_000_000 config :core, + greenlight_auth_module: Core.Authorization, image_catalog: Core.ImageCatalog.Unsplash, banking_backend: Systems.Banking.Dummy diff --git a/core/frameworks/concept/branch.ex b/core/frameworks/concept/branch.ex new file mode 100644 index 000000000..c7161a7d6 --- /dev/null +++ b/core/frameworks/concept/branch.ex @@ -0,0 +1,11 @@ +defprotocol Frameworks.Concept.Branch do + # FIXME: add possibility to resolve dependencies to leafs (siblings) + + @type scope :: :self | :parent + + @spec name(t, scope) :: binary + def name(_t, _scope) + + @spec hierarchy(t) :: list + def hierarchy(_t) +end diff --git a/core/frameworks/concept/context.ex b/core/frameworks/concept/context.ex deleted file mode 100644 index 06d4e18f1..000000000 --- a/core/frameworks/concept/context.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Frameworks.Concept.Context do - defmodule Handler do - @type scope :: :self | :parent - @type model :: struct() - @callback name(scope, model) :: {:ok, binary()} | {:error, atom()} - end - - def name(scope, model, default) when is_struct(model) and is_binary(default) do - Enum.reduce(handlers(), default, fn handler, acc -> - case handler.name(scope, model) do - {:ok, name} -> name - {:error, _} -> acc - end - end) - end - - defp handlers() do - Access.get(settings(), :handlers, []) - end - - defp settings() do - Application.fetch_env!(:core, :naming) - end -end diff --git a/core/frameworks/concept/leaf.ex b/core/frameworks/concept/leaf.ex new file mode 100644 index 000000000..e133c1032 --- /dev/null +++ b/core/frameworks/concept/leaf.ex @@ -0,0 +1,20 @@ +defprotocol Frameworks.Concept.Leaf do + @spec resource_id(t) :: binary() + def resource_id(_t) + + @spec tag(t) :: binary() + def tag(_t) + + @spec info(t, timezone :: binary()) :: list(binary()) + def info(_t, _timezone) + + @spec status(t) :: Frameworks.Concept.Leaf.Status.t() + def status(_t) +end + +defmodule Frameworks.Concept.Leaf.Status do + @type t :: %__MODULE__{value: :concept | :online | :offline | :idle} + defstruct [:value] + + def values(), do: [:concept, :online, :offline, :idle] +end diff --git a/core/frameworks/concept/live_hook.ex b/core/frameworks/concept/live_hook.ex new file mode 100644 index 000000000..42910d522 --- /dev/null +++ b/core/frameworks/concept/live_hook.ex @@ -0,0 +1,28 @@ +defmodule Frameworks.Concept.LiveHook do + @type live_view_module :: atom() + @type params :: map() + @type session :: map() + @type socket :: Phoenix.LiveView.Socket.t() + + @callback on_mount(live_view_module(), params(), session(), socket()) :: + {:cont | :halt, socket()} + + defmacro __using__(_opts) do + quote do + @behaviour Frameworks.Concept.LiveHook + + import Phoenix.LiveView, + only: [attach_hook: 4, connected?: 1, get_connect_params: 1, redirect: 2] + + import Phoenix.Component, only: [assign: 2] + + def optional_apply(socket, live_view_module, function) do + Frameworks.Utility.Module.optional_apply(live_view_module, function, [socket], socket) + end + + def optional_apply(socket, live_view_module, function, args) when is_list(args) do + Frameworks.Utility.Module.optional_apply(live_view_module, function, args, socket) + end + end + end +end diff --git a/core/frameworks/concept/special.ex b/core/frameworks/concept/special.ex new file mode 100644 index 000000000..d12163b57 --- /dev/null +++ b/core/frameworks/concept/special.ex @@ -0,0 +1,75 @@ +defmodule Frameworks.Concept.Special do + import Ecto.Changeset + + def field_value(model, special_fields) do + if field = field(model, special_fields) do + Map.get(model, field) + else + nil + end + end + + def field_id(model, special_fields) do + if field = field(model, special_fields) do + map_to_field_id(field) + else + nil + end + end + + def field(model, special_fields) do + Enum.reduce(special_fields, nil, fn field, acc -> + field_id = map_to_field_id(field) + + if Map.get(model, field_id) != nil do + field + else + acc + end + end) + end + + def change(changeset, special_field, special, special_fields) when is_atom(special_field) do + specials = + Enum.map( + special_fields, + &{&1, + if &1 == special_field do + special + else + nil + end} + ) + + changeset + |> then( + &Enum.reduce(specials, &1, fn {field, value}, changeset -> + put_assoc(changeset, field, value) + end) + ) + end + + defp map_to_field_id(field), do: String.to_existing_atom("#{field}_id") + + defmacro __using__(special_fields) do + quote do + alias Frameworks.Concept.Special + + def special(model) do + Special.field_value(model, unquote(special_fields)) + end + + def special_field_id(model) do + Special.field_id(model, unquote(special_fields)) + end + + def special_field(model) do + Special.field(model, unquote(special_fields)) + end + + def change_special(changeset, special_field, special) do + Special.change(changeset, special_field, special, unquote(special_fields)) + end + end + end +end diff --git a/core/frameworks/fabric/live_hook.ex b/core/frameworks/fabric/live_hook.ex new file mode 100644 index 000000000..fd0af06ea --- /dev/null +++ b/core/frameworks/fabric/live_hook.ex @@ -0,0 +1,9 @@ +defmodule Frameworks.Fabric.LiveHook do + import Phoenix.Component, only: [assign: 2] + + def on_mount(_live_view_module, _params, _session, socket) do + self = %Fabric.LiveView.RefModel{pid: self()} + fabric = %Fabric.Model{parent: nil, self: self, children: nil} + {:cont, socket |> assign(fabric: fabric)} + end +end diff --git a/core/frameworks/fabric/live_view_mount_plug.ex b/core/frameworks/fabric/live_view_mount_plug.ex deleted file mode 100644 index aabe2fad5..000000000 --- a/core/frameworks/fabric/live_view_mount_plug.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Frameworks.Fabric.LiveViewMountPlug do - defmacro __using__(_) do - quote do - @before_compile Frameworks.Fabric.LiveViewMountPlug - end - end - - defmacro __before_compile__(_env) do - quote do - defoverridable mount: 3 - - @doc """ - Automatically assigns Fabric to the socket on mount - """ - def mount(params, session, socket) do - self = %Fabric.LiveView.RefModel{pid: self()} - fabric = %Fabric.Model{parent: nil, self: self, children: nil} - super(params, session, socket |> Phoenix.Component.assign(:fabric, fabric)) - end - end - end -end diff --git a/core/frameworks/green_light/_live_feature.ex b/core/frameworks/green_light/_live_feature.ex new file mode 100644 index 000000000..725e599cc --- /dev/null +++ b/core/frameworks/green_light/_live_feature.ex @@ -0,0 +1,23 @@ +defmodule Frameworks.GreenLight.LiveFeature do + @callback get_authorization_context( + Phoenix.LiveView.unsigned_params() | :not_mounted_at_router, + session :: map, + socket :: Phoenix.Socket.t() + ) :: integer | struct + + @optional_callbacks get_authorization_context: 3 + + defmacro __using__(_opts) do + quote do + @behaviour Frameworks.GreenLight.LiveFeature + + def mount(params, session, %{assigns: %{authorization_failed: true}} = socket) do + {:ok, socket} + end + + def render(%{authorization_failed: true}) do + raise Frameworks.GreenLight.AccessDeniedError, "Authorization failed for #{__MODULE__}" + end + end + end +end diff --git a/core/frameworks/green_light/_live_hook.ex b/core/frameworks/green_light/_live_hook.ex new file mode 100644 index 000000000..214045758 --- /dev/null +++ b/core/frameworks/green_light/_live_hook.ex @@ -0,0 +1,40 @@ +defmodule Frameworks.GreenLight.LiveHook do + @moduledoc """ + Live Hook that enables automatic authorization checks. + """ + use Frameworks.Concept.LiveHook + use CoreWeb, :verified_routes + require Logger + + @impl true + def on_mount(live_view_module, params, session, socket) do + if access_allowed?(live_view_module, params, session, socket) do + {:cont, socket} + else + {:halt, redirect(socket, to: ~p"/access_denied")} + end + end + + defp access_allowed?(live_view_module, params, session, socket) do + user = Map.get(socket.assigns, :current_user) + + if function_exported?(live_view_module, :get_authorization_context, 3) do + can_access? = + auth_module().can_access?( + user, + live_view_module.get_authorization_context(params, session, socket) + |> Core.Authorization.print_roles(), + live_view_module + ) + + user && Logger.notice("User #{user.id} can_access? #{live_view_module}: #{can_access?}") + can_access? + else + auth_module().can_access?(user, live_view_module) + end + end + + defp auth_module() do + Application.get_env(:core, :greenlight_auth_module) + end +end diff --git a/core/frameworks/green_light/live.ex b/core/frameworks/green_light/live.ex deleted file mode 100644 index 95d8fda5f..000000000 --- a/core/frameworks/green_light/live.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule Frameworks.GreenLight.Live do - require Logger - - @moduledoc """ - The Live module enables automatic authorization checks for LiveViews. - """ - @callback get_authorization_context( - Phoenix.LiveView.unsigned_params() | :not_mounted_at_router, - session :: map, - socket :: Phoenix.Socket.t() - ) :: integer | struct - @optional_callbacks get_authorization_context: 3 - - defmacro __using__(auth_module) do - quote do - @greenlight_authmodule unquote(auth_module) - @behaviour Frameworks.GreenLight.Live - @before_compile Frameworks.GreenLight.Live - import Phoenix.LiveView.Helpers - - def render(%{authorization_failed: true}) do - raise Frameworks.GreenLight.AccessDeniedError, "Authorization failed for #{__MODULE__}" - end - end - end - - defmacro __before_compile__(_env) do - quote do - if Module.defines?(__MODULE__, {:get_authorization_context, 3}) do - defp access_allowed?(params, session, socket) do - user = Map.get(socket.assigns, :current_user) - - can_access? = - @greenlight_authmodule.can_access?( - socket, - get_authorization_context(params, session, socket) - |> Core.Authorization.print_roles(), - __MODULE__ - ) - - Logger.notice("User #{user.id} can_access? #{__MODULE__}: #{can_access?}") - can_access? - end - else - defp access_allowed?(_params, session, socket) do - @greenlight_authmodule.can_access?(socket, __MODULE__) - end - end - - defoverridable mount: 3 - - def mount(params, session, socket) do - if access_allowed?(params, session, socket) do - super(params, session, socket) - else - {:ok, assign(socket, authorization_failed: true)} - end - end - end - end -end diff --git a/core/frameworks/pixel.ex b/core/frameworks/pixel.ex index bd6388fe3..a2adbd722 100644 --- a/core/frameworks/pixel.ex +++ b/core/frameworks/pixel.ex @@ -8,6 +8,7 @@ defmodule Frameworks.Pixel do alias Frameworks.Pixel.Button alias Frameworks.Pixel.Text alias Frameworks.Pixel.Icon + alias Frameworks.Pixel.Separator end end end diff --git a/core/frameworks/pixel/components/breadcrumbs.ex b/core/frameworks/pixel/components/breadcrumbs.ex new file mode 100644 index 000000000..6fe4236ef --- /dev/null +++ b/core/frameworks/pixel/components/breadcrumbs.ex @@ -0,0 +1,75 @@ +defmodule Frameworks.Pixel.Breadcrumbs do + use CoreWeb, :live_component + + @impl true + def update(%{elements: elements}, %{assigns: %{}} = socket) do + { + :ok, + socket + |> assign(elements: elements) + |> update_blocks() + } + end + + defp update_blocks(%{assigns: %{elements: nil}} = socket) do + assign(socket, blocks: []) + end + + defp update_blocks(%{assigns: %{elements: elements}} = socket) do + count = Enum.count(elements) + + blocks = + elements + |> Enum.with_index() + |> Enum.map(fn {element, index} -> map_to_block(element, index + 1 == count) end) + |> Enum.intersperse({:separator, %{type: :forward}}) + + assign(socket, blocks: blocks) + end + + defp map_to_block(%{label: label, path: path}, last?) do + { + :button, + %{ + face: %{ + type: :plain, + label: label, + text_color: + if last? do + "text-primary" + else + "text-grey2" + end + }, + action: %{type: :send, event: "handle_click", item: path} + } + } + end + + @impl true + def handle_event("handle_click", %{"item" => path}, socket) do + {:noreply, socket |> push_navigate(to: path)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ <%= for {type, value} <- @blocks do %> + <%= if type == :separator do %> +
+ +
+ <% end %> + <%= if type == :button do %> +
+ +
+ <% end %> + <% end %> +
+
+ """ + end +end diff --git a/core/frameworks/pixel/components/button_face.ex b/core/frameworks/pixel/components/button_face.ex index b843d860b..42ff058f8 100644 --- a/core/frameworks/pixel/components/button_face.ex +++ b/core/frameworks/pixel/components/button_face.ex @@ -100,10 +100,10 @@ defmodule Frameworks.Pixel.Button.Face do def plain(assigns) do ~H""" -
-
-
-
+
+
+
+
<%= @label %>
diff --git a/core/frameworks/pixel/components/navigation.ex b/core/frameworks/pixel/components/navigation.ex index d6efa30e3..25d9d54d9 100644 --- a/core/frameworks/pixel/components/navigation.ex +++ b/core/frameworks/pixel/components/navigation.ex @@ -5,6 +5,7 @@ defmodule Frameworks.Pixel.Navigation do alias Frameworks.Pixel.Button alias Frameworks.Pixel.Menu alias Frameworks.Pixel.Align + alias Frameworks.Pixel.Breadcrumbs import Frameworks.Pixel.Line @@ -60,6 +61,7 @@ defmodule Frameworks.Pixel.Navigation do """ end + attr(:breadcrumbs, :list, required: true) attr(:right_bar_buttons, :list, default: []) attr(:more_buttons, :list, default: []) attr(:hide_seperator, :boolean, default: true) @@ -76,7 +78,17 @@ defmodule Frameworks.Pixel.Navigation do -
+
+
@@ -84,17 +96,9 @@ defmodule Frameworks.Pixel.Navigation do <%= render_slot(@inner_block) %>
<%= if @has_right_bar_buttons do %> - <%= if not @hide_seperator do %> -
- -
- <% end %> -
-
- <%= for button <- @right_bar_buttons do %> - - <% end %> -
+
+
+
<% end %>
diff --git a/core/frameworks/pixel/components/separator.ex b/core/frameworks/pixel/components/separator.ex new file mode 100644 index 000000000..d56f80aaa --- /dev/null +++ b/core/frameworks/pixel/components/separator.ex @@ -0,0 +1,21 @@ +defmodule Frameworks.Pixel.Separator do + use CoreWeb, :html + + attr(:type, :atom, required: true) + + def dynamic(assigns) do + ~H""" + <%= if @type == :forward do %> + <.forward /> + <% end %> + """ + end + + def forward(assigns) do + ~H""" +
+ +
+ """ + end +end diff --git a/core/frameworks/pixel/components/tabbar.ex b/core/frameworks/pixel/components/tabbar.ex index 0754d85b2..407469f30 100644 --- a/core/frameworks/pixel/components/tabbar.ex +++ b/core/frameworks/pixel/components/tabbar.ex @@ -28,17 +28,17 @@ defmodule Frameworks.Pixel.Tabbar do def container(assigns) do ~H""" -
- <%= if @size == :full do %> - <.container_full type={@type} tabs={@tabs} /> - <% end %> - <%= if @size == :wide do %> - <.container_wide type={@type} tabs={@tabs} /> - <% end %> - <%= if @size == :narrow do %> - <.container_narrow tabs={@tabs} /> - <% end %> -
+
+ <%= if @size == :full do %> + <.container_full type={@type} tabs={@tabs} /> + <% end %> + <%= if @size == :wide do %> + <.container_wide type={@type} tabs={@tabs} /> + <% end %> + <%= if @size == :narrow do %> + <.container_narrow tabs={@tabs} /> + <% end %> +
""" end @@ -123,6 +123,7 @@ defmodule Frameworks.Pixel.Tabbar do ~H"""
<%= if @include_top_margin do %> +