From 029dd7b429b9576ecfbab0f2b440ead1d6cc3e0d Mon Sep 17 00:00:00 2001 From: Danny Collier <294724+dcollie2@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:52:11 -0400 Subject: [PATCH] Group administrative sections behind an Admin menu The header listed People, Prescriptions, Medications, Medication types and Medication forms as five equal links. The last three are reference data - nobody using the app day to day adds a medication or invents a dosage form - so giving them equal billing with the work the app exists for was misleading. They now sit behind one Admin link leading to /admin, which lists them, and a sub-nav band follows you into each one so they read as part of an area rather than as top-level sections. The resources keep their own top-level paths and controllers; this is grouping in the navigation, not an Admin:: namespace. ApplicationHelper#admin_sections is the single list of what counts as admin, so the header link, the sub-nav and the landing page cannot disagree. When these pages stop being open to everyone, AdminController carries a note on where the gate goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T1LkcbAaEZigyffSKycUNy --- app/assets/stylesheets/application.css | 113 ++++++++++++++++++++++ app/controllers/admin_controller.rb | 12 +++ app/helpers/application_helper.rb | 32 ++++++ app/views/admin/index.html.erb | 23 +++++ app/views/layouts/application.html.erb | 16 +-- app/views/shared/_admin_nav.html.erb | 20 ++++ config/routes.rb | 6 ++ test/controllers/admin_controller_test.rb | 43 ++++++++ 8 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/views/admin/index.html.erb create mode 100644 app/views/shared/_admin_nav.html.erb create mode 100644 test/controllers/admin_controller_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 6353bab..a42eb9d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -233,6 +233,119 @@ code, pre { font-family: var(--font-mono); font-size: 0.9375em; } .site-main { padding-block: var(--space-5) var(--space-6); } +/* ---------------------------------------------------------------- admin --- */ +/* + * The administrative area - the lists of medications, types and forms that + * are set up once rather than used daily. It is not a separate app: same + * header, same palette. The one signal that you have stepped sideways into + * setup is this second nav band, on the muted grey the sheet already uses for + * table headings and field captions. + * + * These sections still live at /medications and friends, so the band is what + * ties them together. ApplicationHelper#admin_area? decides when it shows. + */ + +.admin-nav { + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted)); + font-size: 0.9375rem; +} + +.admin-nav__inner { + max-width: var(--content-width); + margin-inline: auto; + padding-inline: var(--space-3); + padding-block: var(--space-2); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-3); +} + +/* Names the band rather than labelling any one link, so the row still reads + as "Admin: Overview, Medications, ..." when the header is scrolled away. + The nav's aria-label says the same thing for a screen reader. */ +.admin-nav__label { + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.admin-nav a { + color: hsl(var(--muted-foreground)); + text-decoration: none; + padding-block: var(--space-1); +} + +.admin-nav a:hover { color: hsl(var(--foreground)); } + +.admin-nav a[aria-current="page"] { + color: hsl(var(--primary)); + font-weight: 600; +} + +/* The opening sentence on /admin. Wider than body text would allow it to be + emphatic, so it stays the same size and only lightens. */ +.lede { + max-width: 46ch; + color: hsl(var(--muted-foreground)); + margin-bottom: var(--space-4); +} + +.admin-cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-3); + /* One column on a phone, two once there is room for two readable ones. */ + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); +} + +.admin-card { + position: relative; + padding: var(--space-3) var(--space-4); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); +} + +.admin-card:hover { border-color: hsl(var(--primary) / 0.5); } + +.admin-card__title { + font-size: 1.0625rem; + margin-bottom: var(--space-1); +} + +/* Stretching the one link over the whole card makes the card a click target + without adding a second link to the same place - which a screen reader would + read out twice and a keyboard user would have to tab through twice. */ +.admin-card__link { text-decoration: none; } + +.admin-card__link::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; +} + +/* With the pseudo-element covering the card, the focus ring would otherwise + draw around the words alone while the whole card is what activates. */ +.admin-card__link:focus-visible { outline: none; } + +.admin-card:has(.admin-card__link:focus-visible) { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + +.admin-card__description { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.9375rem; +} + .site-footer { border-top: 1px solid hsl(var(--border)); color: hsl(var(--muted-foreground)); diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..8e0354b --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,12 @@ +# The landing page for the administrative area. It has no model of its own: the +# sections it lists live in ApplicationHelper#admin_sections, and each is still +# served by its own top-level controller. +# +# When these pages stop being open to everyone, this is the seam - the +# before_action that gates the area goes here and in the three reference-data +# controllers, or they all move under an Admin:: namespace inheriting from a +# base controller that carries it. +class AdminController < ApplicationController + def index + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8b7bc9c..ec88b20 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,4 +19,36 @@ def or_dash(value) def aria_current_section(path) "page" if section_current?(path) end + + # The sections that maintain reference data rather than a person's own + # records. Nobody using the app day to day adds a medication or invents a new + # dosage form; someone setting the app up does, once. + # + # This is the only list of them. The header's Admin link, the sub-nav under + # it and the cards on /admin all read from here, so a fourth lookup table + # gets added in one place and cannot appear in two of the three. + def admin_sections + [ + { name: "Medications", + path: medications_path, + description: "The catalogue of drugs a prescription can point at, " \ + "with the side effects to warn about." }, + { name: "Medication types", + path: medication_types_path, + description: "How medications are grouped - the categories offered " \ + "when adding one." }, + { name: "Medication forms", + path: medication_forms_path, + description: "How a medication is taken: tablet, capsule, liquid, " \ + "patch, and the rest." } + ] + end + + # True anywhere in the administrative area: its landing page, or inside any + # section the landing page lists. Those sections keep their own top-level + # paths, so there is no /admin prefix to match on - the list is the test. + def admin_area? + section_current?(admin_path) || + admin_sections.any? { |section| section_current?(section[:path]) } + end end diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb new file mode 100644 index 0000000..17ac541 --- /dev/null +++ b/app/views/admin/index.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, "Admin" %> + +

Admin

+ +

+ The reference data behind prescriptions. These lists are set up once and + edited rarely - the people using the app day to day pick from them rather + than add to them. +

+ + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5d87a3d..b6cad65 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -48,12 +48,14 @@ aria: { current: aria_current_section(people_path) } %> <%= link_to "Prescriptions", prescriptions_path, aria: { current: aria_current_section(prescriptions_path) } %> - <%= link_to "Medications", medications_path, - aria: { current: aria_current_section(medications_path) } %> - <%= link_to "Medication types", medication_types_path, - aria: { current: aria_current_section(medication_types_path) } %> - <%= link_to "Medication forms", medication_forms_path, - aria: { current: aria_current_section(medication_forms_path) } %> + + <%# One link for the whole administrative area. The lookup tables it + covers - medications, types, forms - are listed on /admin and in + the sub-nav below, which is where they belong: they are the app's + setup, not the work it exists for, and five flat links gave them + equal billing with People and Prescriptions. %> + <%= link_to "Admin", admin_path, + aria: { current: ("page" if admin_area?) } %> <%# Devise signs out via DELETE, so this needs Turbo to issue the verb. button_to would work without it, but it would land in the @@ -73,6 +75,8 @@ + <%= render "shared/admin_nav" if admin_area? %> +
<%= yield %>
diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb new file mode 100644 index 0000000..7024434 --- /dev/null +++ b/app/views/shared/_admin_nav.html.erb @@ -0,0 +1,20 @@ +<%# The second nav band, shown by the layout on every page of the + administrative area. It is what makes /medications feel like part of a + place rather than another top-level section: the header's Admin link stays + lit, and this row says which admin section you are in. + + "Overview" is here rather than as a heading link because it is one of the + destinations - from a medication form, /admin is somewhere you go back to. %> + diff --git a/config/routes.rb b/config/routes.rb index 48205eb..683683e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,12 @@ # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # The administrative area. The reference-data resources it covers keep their + # own top-level paths - this is only the landing page that gathers them, so + # the header can carry one Admin link instead of one per lookup table. See + # ApplicationHelper#admin_sections for the list it shows. + get "admin" => "admin#index", as: :admin + # Defines the root path route ("/") root "home#index" get "world" => "home#world" diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb new file mode 100644 index 0000000..6b075db --- /dev/null +++ b/test/controllers/admin_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class AdminControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get admin_url + assert_response :success + end + + # The landing page is generated from ApplicationHelper#admin_sections, so + # this fails if a section is added to the list without a working path helper. + test "index links to every admin section" do + get admin_url + + assert_select "a[href=?]", medications_path + assert_select "a[href=?]", medication_types_path + assert_select "a[href=?]", medication_forms_path + end + + # The point of the exercise: the reference-data sections are behind the one + # Admin link, not sitting in the main nav beside People and Prescriptions. + test "main nav carries a single admin link" do + get root_url + + assert_select "nav.site-nav a[href=?]", admin_path + assert_select "nav.site-nav a[href=?]", medications_path, count: 0 + assert_select "nav.admin-nav", count: 0 + end + + # The sub-nav is what makes /medications read as part of the admin area + # rather than another top-level section, so it has to follow you into one. + test "admin sub-nav shows inside an admin section" do + get medications_url + + assert_select "nav.admin-nav a[href=?][aria-current=page]", medications_path + assert_select "nav.site-nav a[href=?][aria-current=page]", admin_path + end + + test "admin sub-nav stays hidden outside the admin area" do + get people_url + + assert_select "nav.admin-nav", count: 0 + end +end