From 26dda820b0c87d3776b63e2e30d49b925d230fa8 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Tue, 13 Oct 2026 09:53:12 +0200 Subject: [PATCH] Customize the utility navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme now owns the #utility_nav customization per namespace: a server clock, the restored current-user item, and logout rendered as a masked icon — each opt-outable, with consumer-defined items always winning. The clock's Ruby backend embeds the server instant in the item label (the documented wall-clock string, plus epoch + IANA zone when Time.zone is set) and server_clock.js ticks it client-side, following the server through its own DST transitions via Intl. Opt-outs: current user via ActiveAdminTheme.add_current_user = false; logout via AA's per-namespace config.logout_link_path = nil. The logout icon is scoped to the theme's own anchor and named via aria-label. The JS survives Turbo/Turbolinks body swaps, renders nothing without server data (a client clock must not masquerade as the server's), never touches consumer-owned menu items, tolerates malformed sources, and writes textContent only. Menu callbacks register once per namespace, including namespaces created after the single production after_load (Namespace::RegisterEvent); a nil Time.zone degrades gracefully; a late ActiveAdmin load logs a warning instead of silently no-oping. npm prepare rebuilds src/ with a portable node one-liner so git installs work on Windows. Also: the header stays viewport-wide and on-screen so wide tables don't overlap the utility nav, and index pagination stays under the table when the filter sidebar is tall. A Capybara integration test (test/dummy) pins the server contract; README documents loading the script and both opt-outs. --- .github/workflows/ci.yml | 12 ++ .gitignore | 5 + Gemfile | 17 ++ README.md | 46 +++++ Rakefile | 13 ++ app/assets/javascripts/wigu/server_clock.js | 171 ++++++++++++++++++ .../stylesheets/wigu/active_admin_theme.scss | 77 +++++++- img/clock.png | Bin 0 -> 7779 bytes lib/active_admin_theme.rb | 128 ++++++++++++- package.json | 2 +- test/capybara_boot.rb | 35 ++++ test/clock_test.rb | 53 ++++++ test/dummy/app/admin/dashboard.rb | 7 + test/dummy/app/assets/config/manifest.js | 2 + .../app/assets/javascripts/active_admin.js | 3 + .../app/assets/stylesheets/active_admin.scss | 3 + .../app/controllers/application_controller.rb | 2 + test/dummy/config/application.rb | 37 ++++ test/dummy/config/database.yml | 11 ++ test/dummy/config/environment.rb | 7 + .../dummy/config/initializers/active_admin.rb | 23 +++ test/dummy/config/routes.rb | 4 + test/screenshot.rb | 20 ++ 23 files changed, 672 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 app/assets/javascripts/wigu/server_clock.js create mode 100644 img/clock.png create mode 100644 test/capybara_boot.rb create mode 100644 test/clock_test.rb create mode 100644 test/dummy/app/admin/dashboard.rb create mode 100644 test/dummy/app/assets/config/manifest.js create mode 100644 test/dummy/app/assets/javascripts/active_admin.js create mode 100644 test/dummy/app/assets/stylesheets/active_admin.scss create mode 100644 test/dummy/app/controllers/application_controller.rb create mode 100644 test/dummy/config/application.rb create mode 100644 test/dummy/config/database.yml create mode 100644 test/dummy/config/environment.rb create mode 100644 test/dummy/config/initializers/active_admin.rb create mode 100644 test/dummy/config/routes.rb create mode 100644 test/screenshot.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d8f04..b451c68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,15 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake css + + # Capybara integration test for the utility-nav server clock. Needs headless + # Chrome (preinstalled on ubuntu-latest) and Rails 8 (Ruby >= 3.2). + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rake test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce9cfc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/Gemfile.lock +/pkg/ +/src/ +/test/dummy/tmp/ +/test/dummy/log/ diff --git a/Gemfile b/Gemfile index b02e4a8..49e2c88 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,20 @@ group :development, :test do gem 'rake' gem 'sassc' end + +# Dummy ActiveAdmin app (test/dummy) that hosts the Capybara integration test +# (`rake test`) and sources the neutral README screenshot. Sprockets + +# sassc-rails is the pipeline AA 3.x targets (it emits `//= require` directives +# and SCSS). Rails 8 needs Ruby >= 3.2, so keep these out of the 3.1 bundle — +# `rake css` still runs on 3.1 without them. +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2') + group :development, :test do + gem 'rails', '~> 8.0' + gem 'sprockets-rails' + gem 'sassc-rails' + gem 'sqlite3' + gem 'puma' + gem 'capybara' + gem 'cuprite' + end +end diff --git a/README.md b/README.md index 00f5399..1b7149b 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,52 @@ The control is a link with no destination, so Tab reaches it and Enter and Space operate it. Changing the theme in one tab applies it in the others. +### Disabling dark mode + +To ship light-only, two manual steps (there is no build flag): + +- Don't include `wigu/theme_toggle` in your JS manifest. That removes the switch + and the automatic `data-theme` switching it drives. +- Pin the document to light: add `data-theme="light"` to the `` element + (e.g. by overriding the ActiveAdmin layout), so the + `@media (prefers-color-scheme: dark)` styles never apply. + +### Server clock + +The `#utility_nav` now carries a ticking server clock — two lines, time + zone +over the date — rendered by the theme's `server_clock.js` and injected per +namespace by its Ruby backend (`ActiveAdminTheme.install!`), with the logout +button beside it. + +The backend injects only the markup; load the script the same way as the +theme switch, or the nav shows a static one-line label and never ticks: + +```js +// app/assets/javascripts/active_admin.js +//= require wigu/server_clock + +// or, as an npm module +import "@activeadmin-plugins/active_admin_theme/src/server_clock"; +``` + +[![Two-line server clock in the utility nav](./img/clock.png)](./img/clock.png) + +The theme also keeps a logout item in the nav. If your app has no logout route +(custom auth) or you deliberately run a logout-free nav, use ActiveAdmin's own +per-namespace switch — the theme honours it: + +```ruby +# config/initializers/active_admin.rb +config.logout_link_path = nil +``` + +The restored current-user item has a theme switch (ActiveAdmin offers none +short of unsetting `current_user_method`, which breaks authorization): + +```ruby +ActiveAdminTheme.add_current_user = false +``` + ### Upgrading #### 3.1.0 diff --git a/Rakefile b/Rakefile index 6ae6529..b090a1d 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,17 @@ task :css do ruby "test/css_check.rb" end +desc "Capybara integration test: the server clock renders + ticks (needs Chrome)" +task :test do + ruby "test/clock_test.rb" +end + +desc "Regenerate the neutral README screenshot img/clock.png (needs Chrome)" +task :screenshot do + ruby "test/screenshot.rb" +end + +# css only: :test needs the Rails/Capybara stack the Gemfile excludes on +# Ruby 3.1 (the gem's own minimum) plus a local Chrome. Run `rake test` +# explicitly where both are available; CI names its tasks per job anyway. task default: :css diff --git a/app/assets/javascripts/wigu/server_clock.js b/app/assets/javascripts/wigu/server_clock.js new file mode 100644 index 0000000..b7af4a5 --- /dev/null +++ b/app/assets/javascripts/wigu/server_clock.js @@ -0,0 +1,171 @@ +// Server clock for the utility navigation. Shows the server's time ticking once +// a second in li#servertime inside #utility_nav, where the stylesheet styles it. +// +// The instant comes from a [data-servertime] element the host app renders — the +// body in some apps, a footer element in others — holding +// "YYYY MM DD HH MM SS ZONE" (space-separated, exactly what Ruby's +// Time#strftime('%Y %m %d %H %M %S %Z') produces). The clock keeps that server +// instant and advances it by the client's own elapsed time, so a skewed browser +// clock does not throw it off. With no such element it renders nothing: a +// fabricated client clock would bypass every server-side suppression (anonymous +// visitors, hosts without current_user, consumer opt-outs) and show browser +// time masquerading as the server clock. +// +// Vanilla, no deps, mounts itself into #utility_nav. Turbo/Turbolinks swap the +// body without firing DOMContentLoaded, so the clock re-reads the fresh +// [data-servertime] on their load events and re-queries the DOM every tick +// instead of holding node references — a held reference goes detached on the +// first navigation and the visible clock freezes. +(function () { + "use strict"; + + var serverTime, clientTime, zone, fmt; + var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + function pad2(n) { + return (n < 10 ? "0" : "") + n; + } + + function ready(fn) { + if (document.readyState !== "loading") fn(); + else document.addEventListener("DOMContentLoaded", fn); + } + + // (Re)read the server instant from the current document. + function sync() { + // Prefer the theme's own span inside the nav: a host mid-migration may + // still render its old body[data-servertime] (possibly in another format), + // which would win on first-match-in-tree-order and shadow the fresh + // per-request instant. Fall back to anywhere in the document for hosts + // that only render the attribute on the body or a footer element. + var nav = document.getElementById("utility_nav"); + var source = (nav && nav.querySelector("[data-servertime]")) || + document.querySelector("[data-servertime]"); + + // Preferred source: an absolute instant plus the server's IANA zone + // (rendered by the gem's Ruby backend). Intl re-applies the zone's offset + // on every tick, so a long-lived page follows the server through its own + // DST transitions — the wall-clock path below keeps the offset and label + // frozen from page load. + fmt = null; + serverTime = undefined; // no source on this page: tick() renders nothing + var epoch = source && source.getAttribute("data-servertime-epoch"); + var tz = source && source.getAttribute("data-servertime-zone"); + if (epoch && tz && window.Intl && + Intl.DateTimeFormat.prototype.formatToParts) { + try { + fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, hour12: false, hourCycle: "h23", + year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + timeZoneName: "short" + }); + serverTime = epoch * 1000; + // A malformed epoch is truthy but NaN; formatToParts(new Date(NaN)) + // would throw on every tick. Drop to the wall-clock path instead. + if (!isFinite(serverTime)) fmt = null; + } catch (e) { + fmt = null; // unknown zone name: fall through to the wall-clock path + } + } + + var raw = !fmt && source && source.getAttribute("data-servertime"); + + if (raw) { + var bits = raw.split(" "); + // A fixed epoch via Date.UTC, NOT new Date(y,m,d,...): local-zone + // parsing shifts a server instant that falls into the client's DST gap, + // and a tab left open across a client transition jumps the display an + // hour while the server had none. The zone label is carried verbatim. + serverTime = Date.UTC(bits[0], bits[1] - 1, bits[2], bits[3], bits[4], bits[5]); + // No fabricated label: a missing %Z just renders no zone. + zone = bits[6] || ""; + } + clientTime = Date.now(); + tick(); + } + + // The two display lines for this li, created on first use and reused every + // later tick. The server-rendered inner span [data-servertime] is the data + // source for later sync() calls (turbo:load, bfcache restores) — never + // overwrite it; hide it and append the lines beside it. textContent only, + // no innerHTML: the zone token of a host-rendered attribute is untrusted + // text and must not be able to become markup — and skipping the per-second + // innerHTML re-parse stops the node churn too. + function lines(li) { + var time = li.querySelector(".clock-time"); + if (time) return { time: time, date: li.querySelector(".clock-date") }; + var data = li.querySelector("[data-servertime]"); + // Append target: the outer Arbre span when the gem rendered the item, the + // li's own span otherwise. No inline colour — the span inherits the nav's + // text colour from the stylesheet cascade. + var host = data ? data.parentNode : li.querySelector("span"); + if (!host) { + host = document.createElement("span"); + li.appendChild(host); + } + if (data) data.style.display = "none"; + time = document.createElement("span"); + time.className = "clock-time"; + var date = document.createElement("span"); + date.className = "clock-date"; + host.appendChild(time); + host.appendChild(date); + return { time: time, date: date }; + } + + function tick() { + var nav = document.getElementById("utility_nav"); + // isFinite, not an undefined check: a host-rendered data-servertime in an + // unexpected format parses to NaN, which must render nothing — not a + // ticking "NaN:NaN:NaN". + if (!nav || !isFinite(serverTime)) return; + + // Reuse an existing li (idempotent across re-renders); never duplicate. + var li = nav.querySelector("#servertime"); + if (!li) { + li = document.createElement("li"); + li.id = "servertime"; + // Mark it as ours, so the ownership check below keeps passing. + li.setAttribute("data-theme-clock", ""); + nav.appendChild(li); + } else if (!li.hasAttribute("data-theme-clock") && + !li.querySelector("[data-servertime]")) { + // The li belongs to the host — their own servertime menu item, e.g. + // rendered as a plain link. Appending display lines would show the + // host's label and a second clock side by side in one item. + return; + } + var out = lines(li); + + // Two lines, as in yeti-web: time + timezone on top (larger), year month + // day below (smaller). The stylesheet sizes .clock-time / .clock-date. + var now = new Date(serverTime + (Date.now() - clientTime)); + var time, date; + if (fmt) { + // serverTime is an absolute instant; Intl renders it in the server's + // zone, offset and abbreviation recomputed per tick. + var p = {}; + fmt.formatToParts(now).forEach(function (part) { p[part.type] = part.value; }); + time = p.hour + ":" + p.minute + ":" + p.second + " " + p.timeZoneName; + date = p.year + " " + p.month + " " + p.day; + } else { + // UTC getters only — serverTime is a fixed epoch holding a wall clock, + // so the client's zone and DST rules never touch the math. + time = pad2(now.getUTCHours()) + ":" + pad2(now.getUTCMinutes()) + ":" + + pad2(now.getUTCSeconds()) + (zone ? " " + zone : ""); + date = now.getUTCFullYear() + " " + MONTHS[now.getUTCMonth()] + " " + + pad2(now.getUTCDate()); + } + out.time.textContent = time; + out.date.textContent = date; + } + + ready(function () { + sync(); + setInterval(tick, 1000); + }); + document.addEventListener("turbo:load", sync); + document.addEventListener("turbolinks:load", sync); +})(); diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 60b11b5..d6194dc 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -249,6 +249,11 @@ $theme-icon-auto: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s $theme-icon-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='3.1'/%3E%3Cpath d='M8 .9v1.8M8 13.3v1.8M.9 8h1.8M13.3 8h1.8M2.9 2.9l1.3 1.3M11.8 11.8l1.3 1.3M13.1 2.9l-1.3 1.3M4.2 11.8l-1.3 1.3'/%3E%3C/svg%3E")!default; $theme-icon-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M13.4 9.6A5.9 5.9 0 0 1 6.4 2.6a5.9 5.9 0 1 0 7 7z'/%3E%3C/svg%3E")!default; +// Logout drawn as a sign-out glyph instead of the word "Logout", so it matches +// the icon toggle beside it. Masked like the theme icons above, so it takes +// currentColor and follows $skinMenuTextColor; a consumer can override the var. +$logout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 2.5H3.2v11h3.3'/%3E%3Cpath d='M12.8 8H6'/%3E%3Cpath d='M9.5 5 12.8 8l-3.3 3'/%3E%3C/svg%3E")!default; + // A wrong-typed override is legal SassScript, so it compiles without a word and // emits CSS the browser drops — `border-bottom: none solid #5ea3d3`, // `padding: 10 12px`. The rule silently vanishes and ActiveAdmin's own value @@ -435,6 +440,17 @@ body.active_admin { border: none; text-shadow: none; background-image: none; + + // A wide index table scrolls the WHOLE page sideways (#wrapper is + // display:table, min-width:100%). Keep the header pinned on screen and at the + // viewport width while that happens, so it doesn't stretch to the table's + // width (which spread the menu out under the utility nav and overlapped it). + // The header keeps ActiveAdmin's natural layout, so it still grows to fit a + // wrapped menu. + position: sticky; + left: 0; + width: 100vw; + box-sizing: border-box; h1.site_title { color: $skinMainSecondColor; font-size: 16px; @@ -465,16 +481,34 @@ body.active_admin { // tabs: drop the pill padding, and on hover highlight by text colour only — no // background block. The clock has no click action, so it gets no hover at all. #utility_nav { - // Pin to the viewport's right edge so the clock / theme toggle / logout - // stay reachable while a wide table scrolls the page sideways. - position: sticky; - right: 0; + // Sits at the top-right of the header; the header itself is pinned on screen + // (sticky, see #header above) so the clock / theme toggle / logout stay + // reachable while a wide table scrolls the page sideways. z-index: 2; + white-space: nowrap; > li > a { padding: 0 5px; } > li:hover > a, > li.current > a { background-color: transparent; } > li#servertime:hover > a { color: #ffffff; } + // Server clock (server_clock.js), rendered as two compact lines like + // yeti-web: time + timezone (larger) over year/month/day (smaller). A + // right-aligned flex column sized to the nav band (~24px) so both lines + // fit the header height unchanged. Monospace + tabular figures keep the + // ticking time a constant width, so the adjacent logout doesn't jitter. + > li#servertime > span { + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + vertical-align: middle; + height: 24px; + font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace; + font-variant-numeric: tabular-nums; + } + > li#servertime .clock-time { font-size: 12px; line-height: 1.1; } + > li#servertime .clock-date { font-size: 9px; line-height: 1.1; } + // The theme switch (app/assets/javascripts/wigu/theme_toggle.js). The // script only writes data-mode; the glyph is drawn here, so a project can // swap it for its own icon font without forking the script. Masked rather @@ -511,6 +545,35 @@ body.active_admin { -webkit-mask-image: $theme-icon-dark; mask-image: $theme-icon-dark; } + + // Logout as an icon ($logout-icon), to match the toggle beside it. + // font-size:0 hides the "Logout" text visually; the accessible name is + // the aria-label the gem's Ruby puts on the anchor, because zero-sized + // text is pruned from the accessibility tree by some screen readers + // (the toggle gets its aria-label from theme_toggle.js the same way). + // The masked glyph follows the bar's text colour. The real logout + // link/method from add_logout_button_to_menu is untouched. + // Scoped to a.wigu-logout — the class the gem's Ruby puts on the item + // it adds — so a consumer-rendered logout keeps its own label instead + // of collapsing to an unlabeled glyph. + > li#logout:has(> a.wigu-logout) { display: inline-flex; align-items: center; vertical-align: middle; } + > li#logout > a.wigu-logout { + display: flex; + align-items: center; + font-size: 0; + opacity: 0.75; + + &:before { + content: ""; + display: block; + width: 16px; + height: 16px; + background-color: currentColor; + -webkit-mask: #{$logout-icon} center / contain no-repeat; + mask: #{$logout-icon} center / contain no-repeat; + } + &:hover, &:focus { opacity: 1; } + } } ul.tabs { li.has_nested { @@ -1716,6 +1779,12 @@ input[type='radio'] { color: var(--aa-text); } + // Give the content column its own block-formatting context so #index_footer's + // `clear: both` (below) only clears the pagination floats INSIDE the table + // area — not the floated #sidebar. Without this, a tall filter sidebar pushes + // the pagination far below a short table (it clears the sidebar's float too). + #main_content { display: flow-root; } + // Index footer (per-page selector, pagination, "displaying…" info, download // links): lay them out in one left-packed, vertically-centred row. The gem // floats them, so they sat top-aligned and, having different heights, looked diff --git a/img/clock.png b/img/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..332099bea4da3c6307a0fb6482a46e0027962165 GIT binary patch literal 7779 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 literal 0 HcmV?d00001 diff --git a/lib/active_admin_theme.rb b/lib/active_admin_theme.rb index aca44b7..5c1ead0 100644 --- a/lib/active_admin_theme.rb +++ b/lib/active_admin_theme.rb @@ -3,6 +3,132 @@ module ActiveAdminTheme module Rails class Engine < ::Rails::Engine + # Register the server clock once AA is available. server_clock.js (shipped + # in this gem) ticks li#servertime in #utility_nav and reads the server + # instant from [data-servertime]; here we render both server-side, per + # namespace, so every consumer gets the clock without reimplementing it. + # after :load_config_initializers, or hosts loading AA from a config + # initializer (gem "activeadmin", require: false) fail the defined? + # check and silently get no clock — engine initializers otherwise run + # before the host's config/initializers. + initializer "active_admin_theme.server_clock", after: :load_config_initializers do + if defined?(::ActiveAdmin) + ActiveAdminTheme.install! + else + # Evaluated once; a host requiring activeadmin even later (routes.rb, + # an ENV-gated engine) gets no theme at all — say so instead of + # silently shipping a README-promised feature that never appears. + ::Rails.logger&.warn( + "active_admin_theme: ActiveAdmin was not loaded when the engine " \ + "initialized; the utility-nav clock and logout handling are " \ + "disabled. Require \"active_admin\" no later than config/initializers." + ) + end + end end end -end \ No newline at end of file + + # Opt-out for the restored current-user item. Unlike logout (which AA turns + # off per namespace via logout_link_path = nil), AA has no switch short of + # unsetting current_user_method — which also breaks authorization — and + # Menu has no removal API, so hosts that deliberately run a user-link-free + # header (e.g. display_name would leak an email) set this in an initializer: + # ActiveAdminTheme.add_current_user = false + class << self + attr_accessor :add_current_user + end + self.add_current_user = true + + # Add a utility-nav clock item (and ensure a logout item) to every AA namespace. + # + # data-servertime must be fresh on every request, but only a menu item's + # :label/:url/:if are evaluated per request — :html_options is captured once at + # build time. So the instant is embedded in the label as html_safe markup + # rather than an html_option. The label is "YYYY MM DD HH MM SS ZONE" (exactly + # what server_clock.js parses) on an inner span; the JS overwrites the visible + # text each tick. Timezone comes from the host app's Time.current/Time.zone. + def self.install! + # after_load fires on every ActiveAdmin.load! (each dev reload), and each + # build_menu call registers a before_build callback that MenuCollection#clear! + # never removes — so register once per namespace or callbacks pile up for + # the life of the process. Namespace objects and their MenuCollection + # survive unload! (it only clears the built menus), so once is enough. + seen = {}.compare_by_identity + register = lambda do |namespace| + next if seen[namespace] + seen[namespace] = true + namespace.build_menu(:utility_navigation) do |menu| + # A consumer item with the same id wins, but NOT through these + # guards: before_build unshifts, so the LAST registration runs + # first, and consumer hooks (config/initializers) subscribe before + # this engine's initializer — on a production first load THIS + # callback runs first against an empty menu and the guards all + # pass. What protects consumer overrides is Menu#_add replacing by + # id when their callback runs afterwards. The guards only matter + # from the second dev reload on, when consumer items are already + # present — do not tighten them into the primary mechanism. + unless menu["servertime"] + menu.add id: "servertime", + priority: 99_990, + url: -> {}, + # same guard both default utility items carry — the nav + # stays empty for anonymous visitors, as it was pre-theme + if: :current_active_admin_user?, + label: lambda { + now = Time.current + data = now.strftime("%Y %m %d %H %M %S %Z") + shown = now.strftime("%Y %b %d %H:%M:%S %Z") + # epoch + IANA zone let the JS re-apply the server + # zone's offset on every tick (DST included); the + # wall-clock attribute stays as the documented contract + # for host-rendered elements and older scripts. + # Time.zone is nil under Time.use_zone(nil) — the + # common around_action(user.time_zone) pattern with a + # nil column — while Time.current still works; skip the + # precise attributes then instead of 500ing the page. + tz = Time.zone + precise = tz ? %( data-servertime-epoch="#{now.to_i}" data-servertime-zone="#{tz.tzinfo.identifier}") : "" + %(#{shown}).html_safe + } + end + # Claiming :utility_navigation stops AA's build_default_utility_nav + # (it returns early once the menu exists), so restore the default + # current-user item it would have added. No-op when the host has no + # current_user_method, exactly like the default nav. + if ActiveAdminTheme.add_current_user && !menu["current_user"] + namespace.add_current_user_to_menu(menu) + end + # Opting out is ActiveAdmin's own per-namespace switch, not a theme + # flag: config.logout_link_path = nil makes add_logout_button_to_menu + # a no-op (AA guards on `if logout_link_path`). Hosts with custom + # auth and no logout route — where rendering the default item would + # public_send a missing helper and 500 — set exactly that. + # The wigu-logout class (lands on the — AA passes html_options to + # link_to) scopes the stylesheet's icon treatment to THIS item, so a + # consumer-rendered logout with its own label keeps its text. The + # aria-label is the accessible name: the stylesheet hides the link + # text with font-size: 0, which some screen readers prune from the + # accessibility tree. html_options are captured at menu-build time, so + # the label uses the locale active then — good enough for a nav icon. + unless menu["logout"] + namespace.add_logout_button_to_menu( + menu, 99_999, + class: "wigu-logout", "aria-label": I18n.t("active_admin.logout") + ) + end + end + end + + ::ActiveAdmin.after_load do |app| + app.namespaces.each(®ister) + end + # Production fires after_load exactly once (load! gates on @@loaded), so a + # namespace created later — e.g. another engine's later-subscribed hook + # registering a resource into a new namespace — would keep AA's stock + # default nav, inconsistently with its siblings. Namespace creation has + # its own notification; theme late arrivals through it. + ActiveSupport::Notifications.subscribe(::ActiveAdmin::Namespace::RegisterEvent) do |*args| + register.call(args.last[:active_admin_namespace]) + end + end +end diff --git a/package.json b/package.json index d53db33..0183ed2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,6 @@ "src/**/*" ], "scripts": { - "prepublishOnly": "rm -rf src && cp -R app/assets/stylesheets/wigu/ src && cp app/assets/javascripts/wigu/theme_toggle.js src/" + "prepare": "node -e \"const fs=require('fs');fs.rmSync('src',{recursive:true,force:true});fs.cpSync('app/assets/stylesheets/wigu','src',{recursive:true});for(const f of fs.readdirSync('app/assets/javascripts/wigu'))fs.copyFileSync('app/assets/javascripts/wigu/'+f,'src/'+f)\"" } } diff --git a/test/capybara_boot.rb b/test/capybara_boot.rb new file mode 100644 index 0000000..90a0de6 --- /dev/null +++ b/test/capybara_boot.rb @@ -0,0 +1,35 @@ +# Shared Capybara + cuprite setup for the clock integration test and the +# screenshot task. Boots the dummy ActiveAdmin app (test/dummy) and points +# Capybara at it, driven by headless system Chrome via cuprite/ferrum. +ENV["RAILS_ENV"] ||= "test" + +require_relative "dummy/config/environment" +require "capybara" +require "capybara/cuprite" + +Capybara.app = Dummy::Application +Capybara.server = :puma, { Silent: true } +Capybara.default_max_wait_time = 5 + +Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new( + app, + window_size: [1200, 300], + headless: true, + # CI runners need --no-sandbox; their small /dev/shm makes Chrome crash on + # launch unless --disable-dev-shm-usage is set, which surfaces as ferrum's + # "Browser did not produce websocket url within N seconds". --disable-gpu is + # the usual headless companion. All harmless locally. Ferrum finds the system + # Chrome on its own (macOS app bundle or the ubuntu-latest preinstall). + browser_options: { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil + }, + # First Chrome launch on a cold CI runner can exceed ferrum's 10s default. + process_timeout: 30 + ) +end + +Capybara.default_driver = :cuprite +Capybara.javascript_driver = :cuprite diff --git a/test/clock_test.rb b/test/clock_test.rb new file mode 100644 index 0000000..fa9bec0 --- /dev/null +++ b/test/clock_test.rb @@ -0,0 +1,53 @@ +# Integration test: the server clock renders as two ticking lines in the +# utility nav, and the logout item sits beside it. Proves the gem's JS +# (server_clock.js) + Ruby backend (install!) work end to end in a +# real browser against a real ActiveAdmin page. +require_relative "capybara_boot" +require "minitest/autorun" +require "capybara/minitest" + +class ClockTest < Minitest::Test + include Capybara::DSL + include Capybara::Minitest::Assertions + + def teardown + Capybara.reset_sessions! + end + + def test_clock_renders_two_ticking_lines_and_logout + visit "/admin" + + # 1. Two-line structure: li#servertime with a .clock-time and a .clock-date. + assert_selector "#utility_nav li#servertime", wait: 5 + assert_selector "#utility_nav li#servertime .clock-time", wait: 5 + assert_selector "#utility_nav li#servertime .clock-date", wait: 5 + + time_text = find("#utility_nav li#servertime .clock-time").text + date_text = find("#utility_nav li#servertime .clock-date").text + + # Shapes: "HH:MM:SS " over "YYYY DD". + assert_match(/\A\d{2}:\d{2}:\d{2}\s+\S+\z/, time_text, + "clock-time should read 'HH:MM:SS ', got #{time_text.inspect}") + assert_match(/\A\d{4}\s+[A-Z][a-z]{2}\s+\d{2}\z/, date_text, + "clock-date should read 'YYYY DD', got #{date_text.inspect}") + + # 2. Ticking: a retrying Capybara assertion, not a one-shot sample — a busy + # CI runner can delay the 1s interval past any fixed sleep, and tick() + # replaces the node each second, so re-querying until the text changes is + # the only race-free check. + assert_no_selector "#utility_nav li#servertime .clock-time", text: time_text, wait: 3 + + # 3. Logout lives in the utility nav too — specifically the THEME's item + # (a.wigu-logout), not AA's default, which would also appear if install! + # never ran. + assert_selector "#utility_nav li#logout > a.wigu-logout" + + # 4. The displayed clock is the SERVER's. Every assertion above would also + # pass against a client-side fallback (same selectors, same shapes, same + # ticking), so pin the data span the backend renders: the app's own zone + # and a fresh epoch. visible: :all — the JS hides it once mounted. + data = find("#utility_nav li#servertime [data-servertime-epoch]", visible: :all) + assert_equal Time.zone.tzinfo.identifier, data["data-servertime-zone"] + assert_in_delta Time.now.to_i, data["data-servertime-epoch"].to_i, 60 + end +end diff --git a/test/dummy/app/admin/dashboard.rb b/test/dummy/app/admin/dashboard.rb new file mode 100644 index 0000000..93beae6 --- /dev/null +++ b/test/dummy/app/admin/dashboard.rb @@ -0,0 +1,7 @@ +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Welcome to the ActiveAdmin Theme Demo." + end +end diff --git a/test/dummy/app/assets/config/manifest.js b/test/dummy/app/assets/config/manifest.js new file mode 100644 index 0000000..694c926 --- /dev/null +++ b/test/dummy/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link active_admin.css +//= link active_admin.js diff --git a/test/dummy/app/assets/javascripts/active_admin.js b/test/dummy/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..6780be4 --- /dev/null +++ b/test/dummy/app/assets/javascripts/active_admin.js @@ -0,0 +1,3 @@ +//= require active_admin/base +//= require wigu/server_clock +//= require wigu/theme_toggle diff --git a/test/dummy/app/assets/stylesheets/active_admin.scss b/test/dummy/app/assets/stylesheets/active_admin.scss new file mode 100644 index 0000000..c0252fb --- /dev/null +++ b/test/dummy/app/assets/stylesheets/active_admin.scss @@ -0,0 +1,3 @@ +@import "active_admin/mixins"; +@import "active_admin/base"; +@import "wigu/active_admin_theme"; diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb new file mode 100644 index 0000000..513502b --- /dev/null +++ b/test/dummy/config/application.rb @@ -0,0 +1,37 @@ +# Minimal ActiveAdmin host app. Its only jobs are to render an AA page with the +# theme active so the Capybara test can prove the server clock renders + ticks, +# and to be the source for the neutral README screenshot. Deliberately no DB +# schema: AA comments are off and there are no resources, so nothing queries. +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "sprockets/railtie" + +require "active_admin" +# The gem under test. Requiring it registers ActiveAdminTheme::Rails::Engine, +# whose initializer calls install! — that injects li#servertime (with the +# data-servertime instant) and the logout item into every namespace. +require "active_admin_theme" + +module Dummy + class Application < Rails::Application + # Booted by requiring this file directly (not from test/dummy as cwd), so + # pin the root or Rails looks for config/database.yml in the repo root. + config.root = File.expand_path("..", __dir__) + config.load_defaults 7.1 + config.eager_load = false + config.consider_all_requests_local = true + config.secret_key_base = "dummy-secret-key-base-for-tests-only" + config.hosts.clear + + config.logger = Logger.new($stdout) + config.log_level = :warn + + # Sprockets: compile on the fly, no digests — simplest thing that serves + # active_admin.css / active_admin.js in dev/test. + config.assets.compile = true + config.assets.debug = false + config.public_file_server.enabled = true + end +end diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml new file mode 100644 index 0000000..3de036b --- /dev/null +++ b/test/dummy/config/database.yml @@ -0,0 +1,11 @@ +# In-memory SQLite for every env — there is no schema to persist. +default: &default + adapter: sqlite3 + database: ":memory:" + +development: + <<: *default +test: + <<: *default +production: + <<: *default diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb new file mode 100644 index 0000000..332a7f4 --- /dev/null +++ b/test/dummy/config/environment.rb @@ -0,0 +1,7 @@ +require_relative "application" + +# In-memory SQLite: a connection exists if anything asks, but there is no schema +# to maintain (AA comments are disabled and there are no resources). +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +Dummy::Application.initialize! diff --git a/test/dummy/config/initializers/active_admin.rb b/test/dummy/config/initializers/active_admin.rb new file mode 100644 index 0000000..63370d1 --- /dev/null +++ b/test/dummy/config/initializers/active_admin.rb @@ -0,0 +1,23 @@ +ActiveAdmin.setup do |config| + config.site_title = "ActiveAdmin Theme Demo" + + # No devise / no auth: the dummy exists only to render a themed page. + config.authentication_method = false + config.current_user_method = false + config.logout_link_path = "/admin" + + # No ActiveRecord-backed comments, so the app needs no schema. + config.comments = false +end + +# Auth is off (no login wall), but the theme's logout item is guarded by AA's +# `current_active_admin_user?`, which is false with current_user_method = false. +# Stub it true in the dummy so the theme-added li#logout actually renders — the +# whole point being to show the clock and logout sitting together in the nav. +Rails.application.config.to_prepare do + ActiveAdmin::BaseController.class_eval do + def current_active_admin_user? + true + end + end +end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb new file mode 100644 index 0000000..e6277d6 --- /dev/null +++ b/test/dummy/config/routes.rb @@ -0,0 +1,4 @@ +Rails.application.routes.draw do + ActiveAdmin.routes(self) + root to: redirect("/admin") +end diff --git a/test/screenshot.rb b/test/screenshot.rb new file mode 100644 index 0000000..ec5020b --- /dev/null +++ b/test/screenshot.rb @@ -0,0 +1,20 @@ +# Regenerates img/clock.png — the neutral README/PR screenshot of the two-line +# server clock sitting in the utility nav. Same dummy app and driver the test +# uses; crops to the AA header so the shot is just the top bar (site title +# "ActiveAdmin Theme Demo", dashboard menu, clock, logout) — no real content. +require_relative "capybara_boot" +require "capybara/dsl" + +include Capybara::DSL + +OUT = File.expand_path("../img/clock.png", __dir__) + +visit "/admin" +# Let the clock tick once so the two lines are populated by the JS. +find("#utility_nav li#servertime .clock-time", wait: 5) +sleep 1.1 + +page.driver.save_screenshot(OUT, selector: "#header") +Capybara.reset_sessions! + +puts "wrote #{OUT} (#{File.size(OUT)} bytes)"