Skip to content

Conversation

@zoldar
Copy link
Contributor

@zoldar zoldar commented Dec 3, 2025

Changes

This PR introduces basics for supporting gradual deployment of LiveView site dashboard elements.

  • Add a forked phoenix_live_view dependency with added switch allowing disabling pushState calls (the branch is based off v1.1.18 tag)
  • Configure liveSocket to disable pushState specifically for dashboard view and load user prefs from localStorage using meta tags; meta tags themselves are behind a newly introduced live_dashboard feature flag
  • Implement LiveDashboard hook allowing to install various "widgets". The main widget, "dashboard-root" implements delegation of navigation to and from reactor-dom-router; respective event dispatching and handlers are added on React end in navigation/use-app-navigate.tsx and in dashboard/index.tsx
  • A Live.Dashboard LV is now embedded in dashboard stats view, behind a feature flag
  • A scaffolding of pages breakdown is implemented, swapped with <Pages /> react component via LiveViewPortal component, also behind a flag
  • Implement basic tile and tab components with optimistic loading of tab selections and persisting tab choice client-side using localStorage
  • Add a very rudimentary LV test case

Tests

  • Automated tests have been added
  • This PR does not require tests

@zoldar zoldar force-pushed the lv-dashboard-prep branch from 9d4c9c6 to 5a39247 Compare December 3, 2025 09:59
@zoldar zoldar force-pushed the lv-dashboard-prep branch from 5a39247 to 05f72e2 Compare December 4, 2025 13:12
@zoldar zoldar added the preview label Dec 5, 2025
@github-actions
Copy link

github-actions bot commented Dec 5, 2025

Preview environment👷🏼‍♀️🏗️
PR-5930

@zoldar zoldar requested review from a team, RobertJoonas and ukutaht December 5, 2025 12:21
@zoldar zoldar marked this pull request as ready for review December 5, 2025 12:21
Copy link
Contributor

@RobertJoonas RobertJoonas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive work! LGTM 👏

@zoldar zoldar enabled auto-merge December 8, 2025 11:42
@zoldar zoldar added this pull request to the merge queue Dec 8, 2025
Merged via the queue into master with commit 16f1eb3 Dec 8, 2025
16 checks passed
@zoldar zoldar deleted the lv-dashboard-prep branch December 8, 2025 12:03
Copy link
Contributor

@ukutaht ukutaht left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me!

Please add me as a reviewer in the future to these foundational PRs that establish liveview patterns for the dashboard.

* Defines various widgets to use by various dashboard specific components.
*/

const WIDGETS = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'm not a fan of introducing this new concept (widget). It took a bit of jumping around and reading the code to understand it's just a liveview hook. Why not call them what they are - hooks - and register them directly instead of via data-widget? This way anyone who's familiar with liveview (and the concept of hooks) understands immediately what's going on without having to learn and get used to an intermediary layer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way - I do like the shared structure for adding/removing listeners and cleaning them up. In Prima I have the same problem where all the components duplicate some amount of this structure.

I wonder if this can be solved via some kind of mixin or inheritance instead. I haven't really experimented with sharing behaviour between hooks yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry was about having to add multitude of hooks, majority of them concerned with dashboard specific stuff. I'm not saying it's rational 😅 And you are right, it's not worth introducing cognitive load of a new concept I came up with "on the go". I'll see what can we do about structure sharing.

Copy link
Contributor

@ukutaht ukutaht Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry was about having to add multitude of hooks, majority of them concerned with dashboard specific stuff. I'm not saying it's rational

Haha yeah I don't think there's a problem with having many hooks. Surely hooks is just a map in liveview that looks them up in the same way as WIDGETS[this.widget].

It is a bit annoying to have to manually import and register them all when initializing a liveSocket. But I think there's also value in being explicit rather than magical. For namespacing and organization perhaps we can make something like this:

// live_dashboard.js

import HookA from "./hooks/hook_a.js"
import HookB from "./hooks/hook_b.js"
import HookC from "./hooks/hook_c.js"

export default {
  HookA,
  HookB,
  HookC
}

// live_socket.js

import LiveDashboard from './live_dashboard.js

let Hooks = { Modal, Dropdown, LiveDashboard } // these are given to LiveSocket during initialization

// dashboard_component.ex

<div phx-hook="LiveDashboard.HookA">

There's also a new ColocatedHook feature in Phoenix. I haven't tried it, not sure if/how it would work if you want to import some shared helpers or dependencies in these co-located hooks for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about the approach taken in #5937 ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

~H"""
<.link
data-type="dashboard-link"
href={@url}
Copy link
Contributor

@ukutaht ukutaht Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll probably want to change this to patch over href eventually to get pushstate routing instead of http-based routing. Is there something preventing us from using patch from the get-go?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I got the impression patch is heavily tied to pushState - I'll double check that assumption though, maybe I have misinterpreted something. Either way, keeping things link dashboard links abstracted should let as replace href with patch where needed during the final migration phase.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way, keeping things link dashboard links abstracted should let as replace href with patch where needed during the final migration phase.

True, feel free to ignore this for now. I don't have much experience with live navigation but my impression is that:

  1. patch calls handle_params, keeps liveview mounted and socket connected
  2. navigate mounts another liveview while keeping socket connected
  3. href does a full http cycle so socket is re-connected and any liveviews have to remount

As I understand, href should only be used when navigating to a dead view or external resource.

But as you say, this can be easily changed when we switch navigation over to liveview completely at the end.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed it to patch (#5937) as it currently does not really matter - href is rendered in the markup either way and the click is "hijacked" by the navigation hooks, so it will still work.

:if={@tabs != []}
id={@id <> "-tabs"}
phx-update={@update_mode}
phx-hook="LiveDashboard"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optimistic loading is currently implemented via custom JS in the hook. I wonder if this could be achieved via Phoenix.LiveView.JS{} commands instead and whether that would be any cleaner in the end (requires trying it out).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll play around with this a bit

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some ideas but I don't think it's important to focus on this right now. I'll implement these ideas in an upcoming Prima.Tabs component separately and plan to use it here as well.

Copy link
Contributor

@ukutaht ukutaht Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One idea might be prudent to talk about now.

As a general principle I've started preferring to style different states via html attribute rather than shuffling classes around. Quite common with tailwind. Here's what I mean:

Currently when a tab active state changes, the css classes are changed as well via:

   def tab(assigns) do
    assigns =
      assign(assigns,
        active_classes:
          "text-indigo-600 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-600 dark:decoration-indigo-500",
        inactive_classes: "hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer"
      )

    ~H"""
    <button
      class="rounded-sm truncate text-left transition-colors duration-150"
      data-tab={@value}
      data-label={@label}
      data-storage-key="pageTab"
      data-active-classes={@active_classes}
      data-inactive-classes={@inactive_classes}
      phx-click="set-tab"
      phx-value-tab={@value}
      phx-target={@target}
    >
      <span class={if(@value == @active, do: @active_classes, else: @inactive_classes)}>
        {@label}
      </span>
    </button>
    """
  end

vs my preferred approach where the class list is static and styles the component via data-attribute:

 def tab(assigns) do
    ~H"""
    <button
      class="rounded-sm truncate text-left transition-colors duration-150"
      data-active={@active} // <- this is used for styling 
      data-tab={@value}
      data-label={@label}
      data-storage-key="pageTab"
      phx-click="set-tab"
      phx-value-tab={@value}
      phx-target={@target}
    >
      <-- active classes are prefixed with `data-active:` -->
      <span class="hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer
  data-active:text-indigo-600 data-active:dark:text-indigo-500 data-active:font-bold 
  data-active:underline data-active:decoration-2 data-active:decoration-indigo-600 
  data-active:dark:decoration-indigo-500"> 
        {@label}
      </span>
    </button>
    """
  end

It doesn't work exactly the same because the base/inactive classes are not removed when state changes. So for example you might need to also override data-active:cursor-normal to get the original cursor styling for active state.

For me when working with styling I do prefer to have a static list like this to work with. It's especially helpful when debugging styling with devtools in the browser. Also using the data-attribute to announce different states is quite useful for testing and debugging.

@sanne-san WDYT?

slot :inner_block, required: true

def tile(assigns) do
assigns = assign(assigns, :update_mode, if(assigns.connected?, do: "ignore", else: "replace"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why we ignore LV patches but what's the purpose of using phx-update="replace" when socket is not connected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace is the default (there's no empty value). Though, now that I think of it, dead view most likely does not care about phx-update attribute at all, so it can be always set to ignore. I'll change that, thanks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's exactly what I was thinking 👍

def render(assigns) do
~H"""
<div id="live-dashboard-container" phx-hook="LiveDashboard" data-widget="dashboard-root">
<.portal_wrapper id="pages-breakdown-live-container" target="#pages-breakdown-live">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever!


~H"""
<div data-tile id={@id}>
<div data-tile class="w-full flex justify-between h-full">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the duplicated and nested data-tile attributes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a brainfart, I'll fix it.

attr :id, :string, required: true
slot :inner_block, required: true

def tabs(assigns) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's dead code from earlier iteration, I'll remove it, thanks.

@zoldar
Copy link
Contributor Author

zoldar commented Dec 8, 2025

Please add me as a reviewer in the future to these foundational PRs that establish liveview patterns for the dashboard.

I think I have added you as a reviewer - I haven't waited for your review though - sorry about that. Either way, things here are going to undergo ongoing improvements as we go anyway.

@ukutaht
Copy link
Contributor

ukutaht commented Dec 9, 2025

I think I have added you as a reviewer - I haven't waited for your review though - sorry about that. Either way, things here are going to undergo ongoing improvements as we go anyway.

Oh you did, I just didn't review in time :) No worries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants