Improve member view table behavior+style, fix config settings (#493)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
## Description of the implemented changes The changes were: - [x] Bugfixing - [x] New Feature - [ ] Breaking Change - [x] Refactoring This PR standardizes interactive table behavior and improves settings robustness. It makes the new hover/focus-visible row highlight the default for clickable tables, keeps sticky first-column behavior configurable (and optimized for member selection UX), and tightens SMTP source-of-truth handling so ENV-based and UI-based configuration do not conflict. ## What has been changed? - Refactored `CoreComponents.table` to expose interaction state via `data-row-interactive` and moved default row hover/focus styling to CSS. - Made the new row highlight behavior (`hover` + `:has(:focus-visible)`) the default for clickable zebra tables. - Kept sticky-first-column as an explicit table option and preserved sticky-specific selection accent behavior. - Updated member overview table usage to the sticky-first-column mode and refined scrolling behavior (table scrollbar within container, not page-coupled). - Adjusted table-related tests to validate the new interaction contract (attribute/CSS-driven behavior instead of legacy ring classes). - Improved SMTP config handling: - clearer ENV-vs-Settings behavior (ENV-only mode when host env is set), - read-only and warning behavior in global settings UI when required env keys are missing, - updated related config/tests/docs. - Updated docs and changelog (`CHANGELOG.md`, `DESIGN_GUIDELINES.md`, `CODE_GUIDELINES.md`, SMTP concept docs). - Updated gettext catalogs (`default.pot`, `en`, `de`) for new/changed UI strings. ## Definition of Done ### Code Quality - [x] No new technical depths - [x] Linting passed - [x] Documentation is added were needed ### Accessibility - [x] New elements are properly defined with html-tags - [x] Colour contrast follows WCAG criteria - [x] Aria labels are added when needed - [x] Everything is accessible by keyboard - [x] Tab-Order is comprehensible - [x] All interactive elements have a visible focus ### Testing - [x] Tests for new code are written - [x] All tests pass - [ ] axe-core dev tools show no critical or major issues ## Additional Notes - Branch includes 4 commits: - `fix: make sure smtp can be set either via env or ui` - `fix: make horizontal scrollbars sticky to bottom` - `docs: update changelog` - `feat: make checkbox column in member view sticky` - Full fast suite passed (`mix test --exclude slow --exclude ui`): 2017 tests, 0 failures (plus expected non-failing warning logs in test output). - Reviewer focus areas: 1. **Cross-table UX consistency** after moving row interaction styling to component/CSS contract. 2. **Sticky table behavior** (selection accent stripe, zebra background, keyboard focus visibility). 3. **SMTP precedence and UI constraints** in global settings when ENV mode is active. 4. **Regression risk in tests** that previously asserted ring-based row classes. - No breaking API changes expected; behavior change is primarily visual/interaction-level and intentional. Reviewed-on: #493 Co-authored-by: Simon <s.thiessen@local-it.org> Co-committed-by: Simon <s.thiessen@local-it.org>
This commit is contained in:
parent
2bb01bd201
commit
a12888de2f
16 changed files with 635 additions and 258 deletions
|
|
@ -938,6 +938,16 @@ defmodule MvWeb.CoreComponents do
|
|||
doc:
|
||||
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
|
||||
|
||||
attr :wrapper_overflow_class, :string,
|
||||
default: "overflow-x-auto",
|
||||
doc:
|
||||
"overflow class for the table wrapper; set to overflow-visible when outer container owns scrolling"
|
||||
|
||||
attr :sticky_first_col, :boolean,
|
||||
default: false,
|
||||
doc:
|
||||
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
|
|
@ -974,15 +984,19 @@ defmodule MvWeb.CoreComponents do
|
|||
~H"""
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class="overflow-auto"
|
||||
class={@wrapper_overflow_class}
|
||||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:for={col <- @col}
|
||||
class={table_th_class(col, @sticky_header)}
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
class={[
|
||||
table_th_class(col, @sticky_header),
|
||||
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
|
||||
]}
|
||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||
>
|
||||
{col[:label]}
|
||||
|
|
@ -1006,7 +1020,13 @@ defmodule MvWeb.CoreComponents do
|
|||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||
class={[
|
||||
table_row_tr_class(
|
||||
table_row_selected?(assigns, row),
|
||||
@sticky_first_col
|
||||
)
|
||||
]}
|
||||
data-row-interactive={@row_click && "true"}
|
||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||
title={@row_click && @row_tooltip}
|
||||
>
|
||||
|
|
@ -1026,6 +1046,13 @@ defmodule MvWeb.CoreComponents do
|
|||
has_click = col[:col_click] || @row_click
|
||||
classes = ["max-w-xs"]
|
||||
|
||||
classes =
|
||||
if @sticky_first_col && col_idx == 0 do
|
||||
["sticky-first-col-cell sticky left-0 z-20" | classes]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
|
||||
["truncate" | classes]
|
||||
|
|
@ -1040,7 +1067,7 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
# WCAG: no focus ring on the cell itself; sticky zebra rows show keyboard focus via CSS :has(:focus-visible)
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
|
|
@ -1111,30 +1138,11 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||
# and stronger selected outline when selected (WCAG: not color-only).
|
||||
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||
defp table_row_tr_class(row_click, selected?) do
|
||||
has_row_click? = not is_nil(row_click)
|
||||
base = []
|
||||
|
||||
base =
|
||||
if has_row_click? and not selected?,
|
||||
do:
|
||||
base ++
|
||||
[
|
||||
"hover:ring-2",
|
||||
"hover:ring-inset",
|
||||
"hover:ring-base-content/10",
|
||||
"focus-within:ring-2",
|
||||
"focus-within:ring-inset",
|
||||
"focus-within:ring-base-content/10"
|
||||
],
|
||||
else: base
|
||||
|
||||
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||
Enum.join(base, " ")
|
||||
end
|
||||
# Returns CSS classes for table row selection styles.
|
||||
# Hover/focus row highlighting is CSS-driven via [data-row-interactive] selectors in app.css.
|
||||
# Sticky-first-column zebra tables use CSS accents and omit selected row ring classes.
|
||||
defp table_row_tr_class(true, false), do: "ring-2 ring-inset ring-primary"
|
||||
defp table_row_tr_class(_, _), do: ""
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
|
|
|||
|
|
@ -85,14 +85,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||
|> assign(:registration_enabled, settings.registration_enabled != false)
|
||||
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|
||||
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|
||||
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
|
||||
|> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
|
||||
|> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
|
||||
|> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
|
||||
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||
|> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?())
|
||||
|> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys())
|
||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||
|> assign(:smtp_test_result, nil)
|
||||
|
|
@ -321,12 +315,25 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.form_section>
|
||||
<%!-- SMTP / E-Mail Section --%>
|
||||
<.form_section title={gettext("SMTP / E-Mail")}>
|
||||
<%= if @smtp_env_configured do %>
|
||||
<%= if @smtp_env_mode do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
{gettext(
|
||||
"SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if @smtp_env_mode and @smtp_missing_required_env_keys != [] do %>
|
||||
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext("SMTP environment configuration appears incomplete. Missing: %{keys}",
|
||||
keys: Enum.join(@smtp_missing_required_env_keys, ", ")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @environment == :prod and not @smtp_configured do %>
|
||||
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
|
||||
|
|
@ -345,32 +352,26 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
field={@form[:smtp_host]}
|
||||
type="text"
|
||||
label={gettext("Host")}
|
||||
disabled={@smtp_host_env_set}
|
||||
placeholder={
|
||||
if(@smtp_host_env_set,
|
||||
do: gettext("From SMTP_HOST"),
|
||||
else: "smtp.example.com"
|
||||
)
|
||||
}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_port]}
|
||||
type="number"
|
||||
label={gettext("Port")}
|
||||
disabled={@smtp_port_env_set}
|
||||
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="587"
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_ssl]}
|
||||
type="select"
|
||||
label={gettext("TLS/SSL")}
|
||||
disabled={@smtp_ssl_env_set}
|
||||
disabled={@smtp_env_mode}
|
||||
options={[
|
||||
{gettext("TLS (port 587, recommended)"), "tls"},
|
||||
{gettext("SSL (port 465)"), "ssl"},
|
||||
{gettext("None (port 25, insecure)"), "none"}
|
||||
]}
|
||||
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -379,28 +380,20 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
field={@form[:smtp_username]}
|
||||
type="text"
|
||||
label={gettext("Username")}
|
||||
disabled={@smtp_username_env_set}
|
||||
placeholder={
|
||||
if(@smtp_username_env_set,
|
||||
do: gettext("From SMTP_USERNAME"),
|
||||
else: "user@example.com"
|
||||
)
|
||||
}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_password]}
|
||||
type="password"
|
||||
label={gettext("Password")}
|
||||
disabled={@smtp_password_env_set}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder={
|
||||
if(@smtp_password_env_set,
|
||||
do: gettext("From SMTP_PASSWORD"),
|
||||
else:
|
||||
if(@smtp_password_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
if @smtp_env_mode do
|
||||
gettext("From SMTP_PASSWORD")
|
||||
else
|
||||
if @smtp_password_set, do: gettext("Leave blank to keep current"), else: nil
|
||||
end
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -410,22 +403,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
field={@form[:smtp_from_email]}
|
||||
type="email"
|
||||
label={gettext("Sender email (From)")}
|
||||
disabled={@smtp_from_email_env_set}
|
||||
placeholder={
|
||||
if(@smtp_from_email_env_set,
|
||||
do: gettext("From MAIL_FROM_EMAIL"),
|
||||
else: "noreply@example.com"
|
||||
)
|
||||
}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<.input
|
||||
field={@form[:smtp_from_name]}
|
||||
type="text"
|
||||
label={gettext("Sender name (From)")}
|
||||
disabled={@smtp_from_name_env_set}
|
||||
placeholder={
|
||||
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
|
||||
}
|
||||
disabled={@smtp_env_mode}
|
||||
placeholder="Mila"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -435,11 +421,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
:if={
|
||||
not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
|
||||
@smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
|
||||
@smtp_from_name_env_set)
|
||||
}
|
||||
:if={not @smtp_env_mode}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
|
|
@ -925,9 +907,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||
|> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?())
|
||||
|> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys())
|
||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||
|> assign(:vereinfacht_test_result, test_result)
|
||||
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
|
@ -1267,25 +1249,17 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
|
||||
defp merge_smtp_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|
||||
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|
||||
|> put_if_env_set(
|
||||
:smtp_username,
|
||||
Mv.Config.smtp_username_env_set?(),
|
||||
Mv.Config.smtp_username()
|
||||
)
|
||||
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|
||||
|> put_if_env_set(
|
||||
:smtp_from_email,
|
||||
Mv.Config.mail_from_email_env_set?(),
|
||||
Mv.Config.mail_from_email()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:smtp_from_name,
|
||||
Mv.Config.mail_from_name_env_set?(),
|
||||
Mv.Config.mail_from_name()
|
||||
)
|
||||
if Mv.Config.smtp_env_mode?() do
|
||||
s
|
||||
|> Map.put(:smtp_host, Mv.Config.smtp_host())
|
||||
|> Map.put(:smtp_port, Mv.Config.smtp_port())
|
||||
|> Map.put(:smtp_username, Mv.Config.smtp_username())
|
||||
|> Map.put(:smtp_ssl, Mv.Config.smtp_ssl())
|
||||
|> Map.put(:smtp_from_email, Mv.Config.mail_from_email())
|
||||
|> Map.put(:smtp_from_name, Mv.Config.mail_from_name())
|
||||
else
|
||||
s
|
||||
end
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
wrapper_overflow_class="overflow-visible"
|
||||
sticky_header={true}
|
||||
sticky_first_col={true}
|
||||
row_id={fn member -> "row-#{member.id}" end}
|
||||
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||
row_tooltip={gettext("Click for member details")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue