From 582854a08a6c9d58a33b78b64950ebb34b86ff50 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 11 Aug 2026 11:58:47 -0400 Subject: [PATCH 1/9] Add admin-editable Features & tips page (/features) A login-gated, filterable "Features & tips" page so facilitators and admins can see what the portal can do. DB-backed Feature model (rich WYSIWYG description for screenshots, external doc link, audience/display status, area, pro tips, release date), edited in-app by super-admins. config/features.yml is the starter seed an admin "Import from seed" button hydrates (create-missing-only, never clobbers in-app edits). Client-side search + area/audience dropdowns + date range + sort. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/features_controller.rb | 76 +++++++++ app/decorators/feature_decorator.rb | 66 ++++++++ .../controllers/feature_list_controller.js | 106 ++++++++++++ app/frontend/javascript/controllers/index.js | 3 + app/models/feature.rb | 59 +++++++ app/policies/feature_policy.rb | 22 +++ app/services/feature_catalog.rb | 47 ++++++ app/views/features/_feature.html.erb | 57 +++++++ app/views/features/_form.html.erb | 49 ++++++ app/views/features/edit.html.erb | 9 ++ app/views/features/index.html.erb | 130 +++++++++++++++ app/views/features/new.html.erb | 8 + app/views/features/show.html.erb | 64 ++++++++ app/views/shared/_navbar_menu.html.erb | 8 + app/views/shared/_navbar_menu_mobile.html.erb | 8 + config/features.yml | 152 ++++++++++++++++++ config/routes.rb | 5 + db/migrate/20260811155205_create_features.rb | 20 +++ db/schema.rb | 16 ++ spec/views/page_bg_class_alignment_spec.rb | 4 + 20 files changed, 909 insertions(+) create mode 100644 app/controllers/features_controller.rb create mode 100644 app/decorators/feature_decorator.rb create mode 100644 app/frontend/javascript/controllers/feature_list_controller.js create mode 100644 app/models/feature.rb create mode 100644 app/policies/feature_policy.rb create mode 100644 app/services/feature_catalog.rb create mode 100644 app/views/features/_feature.html.erb create mode 100644 app/views/features/_form.html.erb create mode 100644 app/views/features/edit.html.erb create mode 100644 app/views/features/index.html.erb create mode 100644 app/views/features/new.html.erb create mode 100644 app/views/features/show.html.erb create mode 100644 config/features.yml create mode 100644 db/migrate/20260811155205_create_features.rb diff --git a/app/controllers/features_controller.rb b/app/controllers/features_controller.rb new file mode 100644 index 0000000000..45b136f60b --- /dev/null +++ b/app/controllers/features_controller.rb @@ -0,0 +1,76 @@ +class FeaturesController < ApplicationController + before_action :set_feature, only: %i[ show edit update destroy ] + + def index + authorize! Feature + @features = authorized_scope(Feature.all).by_release.decorate + # Only offer filter options the viewer can actually see something under. + present_areas = @features.map(&:area).uniq + present_statuses = @features.map(&:display_status).uniq + @areas = Feature::AREAS.select { |area| present_areas.include?(area[:key]) } + @statuses = Feature::DISPLAY_STATUSES.slice(*present_statuses) + end + + def show + authorize! @feature + @feature = @feature.decorate + end + + def new + @feature = Feature.new(display_status: "user_facing", released_on: Date.current) + authorize! @feature + end + + def edit + authorize! @feature + end + + def create + @feature = Feature.new(feature_params) + authorize! @feature + + if @feature.save + redirect_to @feature, notice: "Feature was successfully created." + else + render :new, status: :unprocessable_content + end + end + + def update + authorize! @feature + + if @feature.update(feature_params) + redirect_to @feature, notice: "Feature was successfully updated.", status: :see_other + else + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize! @feature + @feature.destroy! + redirect_to features_path, notice: "Feature was successfully deleted.", status: :see_other + end + + # Admin-only "Import from seed" button: pull any features from config/features.yml + # not already in the database (never overwrites existing ones). + def import + authorize! Feature, to: :create? + created = FeatureCatalog.new.import! + notice = created.zero? ? "All seed features are already imported." : "Imported #{created} #{'feature'.pluralize(created)} from the seed file." + redirect_to features_path, notice: notice + end + + private + + def set_feature + @feature = Feature.find(params[:id]) + end + + def feature_params + params.require(:feature).permit( + :name, :area, :display_status, :summary, :pro_tips, + :external_url, :released_on, :published, :rhino_description + ) + end +end diff --git a/app/decorators/feature_decorator.rb b/app/decorators/feature_decorator.rb new file mode 100644 index 0000000000..20ca6e69ac --- /dev/null +++ b/app/decorators/feature_decorator.rb @@ -0,0 +1,66 @@ +class FeatureDecorator < ApplicationDecorator + delegate_all + + def area_meta + Feature::AREAS_BY_KEY.fetch(area, DEFAULT_AREA) + end + + def area_label + area_meta[:label] + end + + def area_icon + area_meta[:icon] + end + + def status_meta + Feature::DISPLAY_STATUSES.fetch(display_status, DEFAULT_STATUS) + end + + def status_label + status_meta[:label] + end + + def status_icon + status_meta[:icon] + end + + # e.g. "Aug 9, 2026" — plain, friendly, no ordinal. + def released_label + released_on&.strftime("%b %-d, %Y") + end + + # ISO date (yyyy-mm-dd) for the client-side date-range filter and sort. Because + # it's zero-padded, lexical string comparison in JS orders chronologically. + def released_iso + released_on&.iso8601 + end + + # Lowercased haystack the page's search box matches against — name, summary, + # pro tips, area, and audience label. + def search_text + [ name, summary, *pro_tips_list, area_label, status_label ].join(" ").downcase + end + + # Area badge (icon + label), tinted with the area's theme colour. + def area_badge + badge(area_icon, area_label, area_meta[:color]) + end + + # Audience badge (icon + label), tinted with the audience's colour. + def status_badge + badge(status_icon, status_label, status_meta[:color]) + end + + private + + DEFAULT_AREA = { key: "other", label: "More", icon: "fa-star", color: "gray" }.freeze + DEFAULT_STATUS = { label: "Feature", icon: "fa-star", color: "gray" }.freeze + + def badge(icon, label, color) + classes = h.badge_classes("bg-#{color}-100 text-#{color}-800 border-#{color}-200") + h.content_tag(:span, class: classes) do + h.safe_join([ h.content_tag(:i, "", class: "fa-solid #{icon}"), label ], " ") + end + end +end diff --git a/app/frontend/javascript/controllers/feature_list_controller.js b/app/frontend/javascript/controllers/feature_list_controller.js new file mode 100644 index 0000000000..ce00fe5891 --- /dev/null +++ b/app/frontend/javascript/controllers/feature_list_controller.js @@ -0,0 +1,106 @@ +import { Controller } from "@hotwired/stimulus" + +// Client-side search, filter, and sort for the Features & tips page. The whole +// curated (already audience-scoped, server-side) list renders once; this narrows +// and reorders it in place — no server round-trip. +// +// Each card carries data-area, data-status, data-date (ISO yyyy-mm-dd, which +// sorts/compares correctly as a plain string), and data-text (lowercased +// searchable haystack). +export default class extends Controller { + static targets = ["card", "list", "count", "empty", "search", "area", "status", "from", "to", "order"] + static values = { + query: { type: String, default: "" }, + area: { type: String, default: "all" }, + status: { type: String, default: "all" }, + from: { type: String, default: "" }, + to: { type: String, default: "" }, + order: { type: String, default: "newest" } + } + + connect() { + this.refresh() + } + + search() { + this.queryValue = this.searchTarget.value.trim().toLowerCase() + } + + filterArea() { + this.areaValue = this.areaTarget.value + } + + filterStatus() { + this.statusValue = this.statusTarget.value + } + + setFrom() { + this.fromValue = this.fromTarget.value + } + + setTo() { + this.toValue = this.toTarget.value + } + + setOrder() { + this.orderValue = this.orderTarget.value + } + + clear() { + this.searchTarget.value = "" + this.areaTarget.value = "all" + this.statusTarget.value = "all" + this.fromTarget.value = "" + this.toTarget.value = "" + this.orderTarget.value = "newest" + this.queryValue = "" + this.areaValue = "all" + this.statusValue = "all" + this.fromValue = "" + this.toValue = "" + this.orderValue = "newest" + } + + queryValueChanged() { this.refresh() } + areaValueChanged() { this.refresh() } + statusValueChanged() { this.refresh() } + fromValueChanged() { this.refresh() } + toValueChanged() { this.refresh() } + orderValueChanged() { this.refresh() } + + refresh() { + if (!this.hasListTarget) return + this.sort() + this.filter() + } + + sort() { + const newest = this.orderValue !== "oldest" + const cards = [...this.cardTargets].sort((a, b) => { + const cmp = a.dataset.date.localeCompare(b.dataset.date) + return newest ? -cmp : cmp + }) + cards.forEach((card) => this.listTarget.appendChild(card)) + } + + filter() { + let visible = 0 + this.cardTargets.forEach((card) => { + const show = this.matches(card) + card.classList.toggle("hidden", !show) + if (show) visible++ + }) + if (this.hasCountTarget) this.countTarget.textContent = visible + if (this.hasEmptyTarget) this.emptyTarget.classList.toggle("hidden", visible > 0) + } + + matches(card) { + const { area, status, date, text } = card.dataset + if (this.areaValue !== "all" && area !== this.areaValue) return false + if (this.statusValue !== "all" && status !== this.statusValue) return false + if (this.queryValue && !text.includes(this.queryValue)) return false + if (this.fromValue && date < this.fromValue) return false + if (this.toValue && date > this.toValue) return false + return true + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 64be221b9e..b79a0d86dc 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -84,6 +84,9 @@ application.register("dropdown", DropdownController) import ExpandAllController from "./expand_all_controller" application.register("expand-all", ExpandAllController) +import FeatureListController from "./feature_list_controller" +application.register("feature-list", FeatureListController) + import FilePreviewController from "./file_preview_controller" application.register("file-preview", FilePreviewController) diff --git a/app/models/feature.rb b/app/models/feature.rb new file mode 100644 index 0000000000..1f4a09d2ea --- /dev/null +++ b/app/models/feature.rb @@ -0,0 +1,59 @@ +class Feature < ApplicationRecord + # Full, screenshot-friendly write-up edited in the Rhino WYSIWYG. The card + # summary is the short version; this is the long one. (`rhino_`-prefixed per + # the app's ActionText convention — see rhino_editor helper.) + has_rich_text :rhino_description + + # Ordered area taxonomy — the single source of truth for a feature's grouping. + # `color` is a Tailwind hue already safelisted in application.tailwind.css (via + # DomainTheme's @source inline block), so the tinted `bg--100` / + # `text--800` classes render. `icon` is a Font Awesome name. Presentation + # (labels/badges) lives on FeatureDecorator; this is just the data. + AREAS = [ + { key: "events", label: "Events & trainings", icon: "fa-calendar-days", color: "teal" }, + { key: "registration", label: "Registration & tickets", icon: "fa-ticket", color: "amber" }, + { key: "scholarships", label: "Scholarships & grants", icon: "fa-graduation-cap", color: "fuchsia" }, + { key: "payments", label: "Payments & billing", icon: "fa-money-check-dollar", color: "green" }, + { key: "people", label: "People & organizations", icon: "fa-user-group", color: "cyan" }, + { key: "content", label: "Workshops & resources", icon: "fa-palette", color: "indigo" }, + { key: "stories", label: "Stories & community", icon: "fa-book-open", color: "orange" }, + { key: "communications", label: "Communications", icon: "fa-bell", color: "sky" }, + { key: "reporting", label: "Reporting & admin", icon: "fa-chart-line", color: "slate" } + ].freeze + + AREAS_BY_KEY = AREAS.index_by { |area| area[:key] }.freeze + AREA_KEYS = AREAS.map { |area| area[:key] }.freeze + + # Audience of a feature — both a label shown on the page and the visibility + # gate. `admin_facing` is restricted to super-admins by FeaturePolicy; the + # others are visible to any signed-in user. Plain string column constrained by + # a constant + inclusion (no Rails enum), per CLAUDE.md. + DISPLAY_STATUSES = { + "public_facing" => { label: "Public-facing", icon: "fa-globe", color: "green" }, + "user_facing" => { label: "For facilitators", icon: "fa-user", color: "blue" }, + "admin_facing" => { label: "Admin-facing", icon: "fa-lock", color: "slate" } + }.freeze + DISPLAY_STATUS_KEYS = DISPLAY_STATUSES.keys.freeze + ADMIN_ONLY_STATUS = "admin_facing".freeze + + validates :name, presence: true, length: { maximum: 150 } + validates :summary, presence: true, length: { maximum: 300 } + validates :area, inclusion: { in: AREA_KEYS } + validates :display_status, inclusion: { in: DISPLAY_STATUS_KEYS } + validates :released_on, presence: true + validates :external_url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), + message: "must start with http:// or https://" }, allow_blank: true + + scope :published, -> { where(published: true) } + scope :readable_by_non_admins, -> { where.not(display_status: ADMIN_ONLY_STATUS) } + scope :by_release, -> { order(released_on: :desc, name: :asc) } + + # Pro tips are stored one-per-line in a text column and rendered as a list. + def pro_tips_list + pro_tips.to_s.split("\n").map(&:strip).reject(&:blank?) + end + + def admin_only? + display_status == ADMIN_ONLY_STATUS + end +end diff --git a/app/policies/feature_policy.rb b/app/policies/feature_policy.rb new file mode 100644 index 0000000000..cd72f54918 --- /dev/null +++ b/app/policies/feature_policy.rb @@ -0,0 +1,22 @@ +class FeaturePolicy < ApplicationPolicy + # The page is for signed-in users; creating/editing/deleting stays admin-only + # via the inherited `manage?` default rule. + def index? + authenticated? + end + + # Admin-facing features are visible to super-admins only. Everyone signed in + # can see published public-/user-facing features. + def show? + return false unless authenticated? + + admin? || (record.published? && !record.admin_only?) + end + + relation_scope do |relation| + next relation.none unless authenticated? + next relation if admin? + + relation.published.readable_by_non_admins + end +end diff --git a/app/services/feature_catalog.rb b/app/services/feature_catalog.rb new file mode 100644 index 0000000000..3b85cd7723 --- /dev/null +++ b/app/services/feature_catalog.rb @@ -0,0 +1,47 @@ +require "yaml" + +# Imports the checked-in feature seed (config/features.yml) into the database +# that backs the public "Features & tips" page (/features). The page itself is +# admin-editable in-app; this is only the starter content plus a safe way to pull +# in newly-shipped features an AI/dev appended to the YAML. +# +# `import!` is CREATE-MISSING-ONLY (matched by name): it never overwrites an +# existing Feature, so hydrating from the seed can't clobber an admin's in-app +# edits. See CLAUDE.md "Features & tips page". +class FeatureCatalog + DATA_PATH = Rails.root.join("config/features.yml") + + def initialize(path: DATA_PATH) + @path = path + end + + # Creates any seed feature not already in the database. Returns the number + # created (0 when everything is already present). + def import! + created = 0 + entries.each do |entry| + name = entry.fetch("name") + next if Feature.exists?(name: name) + + Feature.create!( + name: name, + area: entry.fetch("area"), + display_status: entry.fetch("display_status"), + summary: entry.fetch("summary").to_s.strip, + released_on: entry.fetch("released_on").to_date, + pro_tips: Array(entry["pro_tips"]).map { |tip| tip.to_s.strip }.join("\n"), + external_url: entry["external_url"].presence, + published: entry.fetch("published", true), + rhino_description: entry["description"].presence + ) + created += 1 + end + created + end + + # Raw seed entries (array of hashes). Public so specs can assert the seed is + # well-formed without touching the database. + def entries + YAML.safe_load_file(@path, permitted_classes: [ Date ]) || [] + end +end diff --git a/app/views/features/_feature.html.erb b/app/views/features/_feature.html.erb new file mode 100644 index 0000000000..5f506b2e85 --- /dev/null +++ b/app/views/features/_feature.html.erb @@ -0,0 +1,57 @@ +
+ + + +
+
+
+ <%= feature.area_badge %> + <%= feature.status_badge %> + <% unless feature.published? %> + "> + Draft + + <% end %> +
+ + <%= feature.released_label %> + +
+ +

+ <%= link_to feature.name, feature_path(feature), class: "hover:text-primary" %> +

+

<%= feature.summary %>

+ + <% if feature.pro_tips_list.any? %> +
+

+ <%= "Pro tip".pluralize(feature.pro_tips_list.size) %> +

+
    + <% feature.pro_tips_list.each do |tip| %> +
  • <%= tip %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= link_to feature_path(feature), class: "text-primary font-medium hover:underline" do %> + Details + <% end %> + <% if feature.external_url.present? %> + <%= link_to safe_url(feature.external_url), target: "_blank", rel: "noopener noreferrer", + class: "text-gray-500 hover:text-gray-700" do %> + Full guide + <% end %> + <% end %> +
+
+
diff --git a/app/views/features/_form.html.erb b/app/views/features/_form.html.erb new file mode 100644 index 0000000000..4a41b93103 --- /dev/null +++ b/app/views/features/_form.html.erb @@ -0,0 +1,49 @@ +<%= simple_form_for(feature, html: { class: "space-y-6" }) do |f| %> + <%= render "shared/errors", resource: feature if feature.errors.any? %> + +
+ <%= f.input :name, input_html: { class: "w-full", maxlength: 150 } %> + <%= f.input :released_on, as: :string, + input_html: { type: "date", value: feature.released_on, class: "w-full" } %> +
+ +
+ <%= f.input :area, + collection: Feature::AREAS.map { |area| [ area[:label], area[:key] ] }, + include_blank: false, + input_html: { class: "w-full" } %> + <%= f.input :display_status, label: "Audience", + collection: Feature::DISPLAY_STATUSES.map { |key, meta| [ meta[:label], key ] }, + include_blank: false, + hint: "Admin-facing features are visible to super-admins only.", + input_html: { class: "w-full" } %> +
+ + <%= f.input :summary, as: :text, + hint: "One or two plain-language sentences shown on the card.", + input_html: { rows: 2, class: "w-full", maxlength: 300 } %> + + <%= f.input :pro_tips, as: :text, + hint: "One tip per line.", + input_html: { rows: 3, class: "w-full" } %> + +
+ <%= rhino_editor(f, :description, + label: "Full description", + hint: "Add a step-by-step walkthrough and screenshots.") %> +
+ + <%= f.input :external_url, label: "External guide link", + hint: "Optional link to a full write-up (e.g. a Google or Word doc).", + input_html: { class: "w-full", placeholder: "https://…" } %> + +
+ <%= f.input :published, as: :boolean, + hint: "Uncheck to keep a draft hidden from everyone but super-admins." %> +
+ +
+ <%= f.button :submit, class: "btn btn-primary" %> + <%= link_to "Cancel", features_path, class: "btn btn-secondary-outline" %> +
+<% end %> diff --git a/app/views/features/edit.html.erb b/app/views/features/edit.html.erb new file mode 100644 index 0000000000..5252d393d7 --- /dev/null +++ b/app/views/features/edit.html.erb @@ -0,0 +1,9 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Features & tips", features_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "View", feature_path(@feature), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+

Edit feature

+ <%= render "form", feature: @feature %> +
diff --git a/app/views/features/index.html.erb b/app/views/features/index.html.erb new file mode 100644 index 0000000000..fdb0863de2 --- /dev/null +++ b/app/views/features/index.html.erb @@ -0,0 +1,130 @@ +<% content_for(:page_bg_class, "admin-or-auth") %> + +
+ + +
+
+

Features & tips

+

+ Everything the portal can do, newest first. Search by name or tip, narrow by area, + audience, or date — then open a feature for the full walkthrough and pro tips. +

+
+ + <% if allowed_to?(:create?, Feature) %> +
+ <%= link_to new_feature_path, class: "btn btn-primary-outline" do %> + New feature + <% end %> + <%= button_to import_features_path, + class: "btn btn-primary-outline", + form: { data: { turbo_confirm: "Import any features from the seed file that aren't here yet? Existing features are left untouched." } } do %> + Import from seed + <% end %> +
+ <% end %> +
+ + +
+
+
+ +
+ + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +

+ Showing <%= @features.size %> of <%= @features.size %> + <%= "feature".pluralize(@features.size) %> +

+ + +
+ <%= render partial: "feature", collection: @features, as: :feature %> +
+ + + <% if @features.empty? %> +
+ +

No features yet.

+ <% if allowed_to?(:create?, Feature) %> +

Add one, or import the starter set from the seed file.

+ <% end %> +
+ <% end %> + + +
diff --git a/app/views/features/new.html.erb b/app/views/features/new.html.erb new file mode 100644 index 0000000000..a50d17c700 --- /dev/null +++ b/app/views/features/new.html.erb @@ -0,0 +1,8 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Features & tips", features_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+

New feature

+ <%= render "form", feature: @feature %> +
diff --git a/app/views/features/show.html.erb b/app/views/features/show.html.erb new file mode 100644 index 0000000000..e03a00fc3c --- /dev/null +++ b/app/views/features/show.html.erb @@ -0,0 +1,64 @@ +<% content_for(:page_bg_class, "admin-or-authpublished") %> + +
+
+ <%= link_to features_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> + Features & tips + <% end %> +
+ +
+
+ <%= @feature.area_badge %> + <%= @feature.status_badge %> + + <%= @feature.released_label %> + + <% unless @feature.published? %> + "> + Draft + + <% end %> +
+ +

<%= @feature.name %>

+

<%= @feature.summary %>

+ + <% if @feature.pro_tips_list.any? %> +
+

+ <%= "Pro tip".pluralize(@feature.pro_tips_list.size) %> +

+
    + <% @feature.pro_tips_list.each do |tip| %> +
  • <%= tip %>
  • + <% end %> +
+
+ <% end %> + + <% if @feature.rhino_description.present? %> +
+ <%= @feature.rhino_description %> +
+ <% end %> + + <% if @feature.external_url.present? %> +
+ <%= link_to safe_url(@feature.external_url), target: "_blank", rel: "noopener noreferrer", + class: "btn btn-primary-outline" do %> + Open the full guide + <% end %> +
+ <% end %> + + <% if allowed_to?(:update?, @feature) %> +
+ <%= link_to "Edit", edit_feature_path(@feature), class: "btn btn-primary-outline" %> + <%= button_to "Delete", feature_path(@feature), method: :delete, + class: "btn btn-danger-outline", + form: { data: { turbo_confirm: "Delete this feature? This can't be undone." } } %> +
+ <% end %> +
+
diff --git a/app/views/shared/_navbar_menu.html.erb b/app/views/shared/_navbar_menu.html.erb index 5e86ebccce..f48f26db7c 100644 --- a/app/views/shared/_navbar_menu.html.erb +++ b/app/views/shared/_navbar_menu.html.erb @@ -214,6 +214,14 @@ FAQs <% end %> + <% if user_signed_in? %> + <%= link_to features_path, + class: "flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %> + + Features & tips + <% end %> + <% end %> + <%= link_to contact_us_path, class: "flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %> diff --git a/app/views/shared/_navbar_menu_mobile.html.erb b/app/views/shared/_navbar_menu_mobile.html.erb index abf4f09430..47a0e32475 100644 --- a/app/views/shared/_navbar_menu_mobile.html.erb +++ b/app/views/shared/_navbar_menu_mobile.html.erb @@ -104,6 +104,14 @@ FAQs <% end %> + <% if user_signed_in? %> + <%= link_to features_path, class: "flex items-center px-4 py-2 text-sm text-white + hover:text-gray-700 hover:bg-gray-100 w-full space-x-2" do %> + + Features & tips + <% end %> + <% end %> + <%= link_to contact_us_path, class: "flex items-center px-4 py-2 text-sm text-white hover:text-gray-700 hover:bg-gray-100 w-full space-x-2" do %> diff --git a/config/features.yml b/config/features.yml new file mode 100644 index 0000000000..61cbf47235 --- /dev/null +++ b/config/features.yml @@ -0,0 +1,152 @@ +# Seed for the "Features & tips" page (/features). +# +# This file is the STARTER content only. The live page is database-backed and +# edited in-app by super-admins (rich descriptions with screenshots, external +# doc links, etc.). An admin clicks "Import from seed" on /features to pull any +# entries here that aren't in the database yet — it NEVER overwrites an existing +# feature (matched by name), so admin edits are safe. See CLAUDE.md +# "Features & tips page" and FeatureCatalog#import!. +# +# WHEN YOU SHIP A USER-FACING FEATURE, add an entry here: +# name: Short, sentence-case name. +# area: One of Feature::AREA_KEYS (events, registration, scholarships, +# payments, people, content, stories, communications, reporting). +# display_status: Who it's for AND who may see it on the page: +# public_facing | user_facing | admin_facing +# (admin_facing is visible to super-admins only). +# summary: One or two plain-language sentences (shown on the card). +# released_on: Ship date (YYYY-MM-DD). +# pro_tips: Optional list of short, practical tips (0–2 is plenty). +# description: Optional longer write-up (plain text or HTML). Admins usually +# expand this in the WYSIWYG with screenshots. +# external_url: Optional link to a full process doc (e.g. a Google/Word doc). + +- name: "Auto-filled organization details when linking a registration" + area: people + display_status: admin_facing + released_on: 2026-08-11 + summary: >- + Linking a registrant to an organization now fills in the org's type, website, + and address from what the registrant typed, and records exactly what changed. + pro_tips: + - "The linking page lists every value that was filled in, so you can double-check before moving on." + - "Existing org details are never overwritten — the portal only fills blanks and flags anything that conflicts." + +- name: "Cross-event reports and a new events subnavigation" + area: reporting + display_status: admin_facing + released_on: 2026-08-10 + summary: >- + Events have a subnavigation linking revenue, participation, and scholarship + reports across every training, with breakdowns that filter themselves as you + drill into a sector, age group, or location. + pro_tips: + - "Click any slice of a breakdown to see just the people behind that number." + +- name: "Comments and communications on story screens" + area: stories + display_status: admin_facing + released_on: 2026-08-10 + summary: >- + Story and story-idea edit screens show the comments and email communications + tied to that story, so the whole conversation lives in one place. + +- name: "Public Story Share portal" + area: stories + display_status: public_facing + released_on: 2026-08-09 + summary: >- + A public, shareable Story Share portal that mirrors the awbw.org styling, with + admin-managed slots, hover-zoom segments, and a "most read" ordering. + pro_tips: + - "Admins can reorder the featured slots and pick a color for each from the drag menu." + +- name: "Shared registrant filter bar" + area: events + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + A single, consistent filter bar across the registrant lists lets you narrow by + more criteria, tuck advanced options under "More filters", and prefill a bulk + email to exactly the people you filtered to. + +- name: "Payment method badge and filter on the roster" + area: registration + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + The registrants roster shows each person's payment method as a badge and lets + you filter the list by it. + +- name: "Certificate slider on the registrants roster" + area: registration + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Mark a registrant's certificate as sent right from the roster with a slider — + it stays in sync with the certificate date on their registration. + +- name: "Scholarship recipients grouped by funder" + area: scholarships + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + The scholarship recipients page groups awards by funder and links each + recipient's age chips back to their registration, so it's easy to see who + funded whom. + +- name: "Feature a recipient shout-out" + area: scholarships + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Highlight a scholarship recipient as a shout-out straight from the recipients + page. + +- name: "Meet-the-staff section on the event form" + area: events + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Add and manage the staff shown on an event's "Meet the staff" roster directly + from the edit-event form. + +- name: "Archived list for old and unpublished events" + area: events + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Unpublished and long-past events collapse into a tidy archive list on the + events index, so admins see current events first without losing the old ones. + +- name: "Per-registrant drilldowns on the revenue report" + area: reporting + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Every figure on the events revenue report opens up to the individual + registrants behind it. + +- name: "Add a subscriber without leaving the form" + area: communications + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + When adding a topic subscription for someone who isn't in the system yet, you + can create their person record inline instead of starting over. + +- name: "Admin emails attributed to the sender" + area: communications + display_status: admin_facing + released_on: 2026-08-09 + summary: >- + Invitations, reminders, and resends show who sent them, and are recorded + against the sending person rather than a generic account. + +- name: "\"Someone else will pay\" toggle on registration" + area: registration + display_status: public_facing + released_on: 2026-08-09 + summary: >- + Registrants can indicate that someone else will pay for them, and that answer + is carried onto their registration for admins to see. diff --git a/config/routes.rb b/config/routes.rb index ba71f55ff7..bfa5e99b96 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -211,6 +211,11 @@ resources :memberships, only: [ :index, :new, :create ] end resources :faqs + resources :features do + collection do + post :import + end + end resources :other_responses, only: [ :index, :update ] do collection do post :promote diff --git a/db/migrate/20260811155205_create_features.rb b/db/migrate/20260811155205_create_features.rb new file mode 100644 index 0000000000..e17f137327 --- /dev/null +++ b/db/migrate/20260811155205_create_features.rb @@ -0,0 +1,20 @@ +class CreateFeatures < ActiveRecord::Migration[8.1] + def change + create_table :features do |t| + t.string :name, null: false + t.string :area, null: false + t.string :display_status, null: false, default: "user_facing" + t.string :summary, null: false + t.text :pro_tips + t.string :external_url + t.date :released_on, null: false + t.boolean :published, null: false, default: true + + t.timestamps + end + + add_index :features, :released_on + add_index :features, :area + add_index :features, :display_status + end +end diff --git a/db/schema.rb b/db/schema.rb index 2ee74f8a96..bc43b0d56a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -594,6 +594,22 @@ t.index ["published"], name: "index_faqs_on_published" end + create_table "features", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "area", null: false + t.datetime "created_at", null: false + t.string "display_status", default: "user_facing", null: false + t.string "external_url" + t.string "name", null: false + t.text "pro_tips" + t.boolean "published", default: true, null: false + t.date "released_on", null: false + t.string "summary", null: false + t.datetime "updated_at", null: false + t.index ["area"], name: "index_features_on_area" + t.index ["display_status"], name: "index_features_on_display_status" + t.index ["released_on"], name: "index_features_on_released_on" + end + create_table "footers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "adult_program" t.string "children_program" diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 81b86df708..2285fb2ae9 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -43,6 +43,7 @@ "app/views/organizations/index.html.erb" => "admin-or-auth", "app/views/people/index.html.erb" => "admin-or-auth", "app/views/workshop_logs/index.html.erb" => "admin-or-auth", + "app/views/features/index.html.erb" => "admin-or-auth", "app/views/story_ideas/new.html.erb" => "admin-or-auth", "app/views/story_shares/new.html.erb" => "admin-or-auth", @@ -67,6 +68,7 @@ "app/views/story_shares/show.html.erb" => "admin-or-public-or-authpublished", "app/views/organizations/populations_served.html.erb" => "admin-or-authpublished", + "app/views/features/show.html.erb" => "admin-or-authpublished", # ─── admin-or-owner (policy: admin? || owner?) ─── "app/views/quotes/show.html.erb" => "admin-or-owner", @@ -176,6 +178,7 @@ "app/views/event_registrations/new.html.erb" => "admin-only bg-blue-100", "app/views/events/new.html.erb" => "admin-only bg-blue-100", "app/views/faqs/new.html.erb" => "admin-only bg-blue-100", + "app/views/features/new.html.erb" => "admin-only bg-blue-100", "app/views/forms/new.html.erb" => "admin-only bg-blue-100", "app/views/organizations/new.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/new.html.erb" => "admin-only bg-blue-100", @@ -207,6 +210,7 @@ "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100", "app/views/forms/smart_form_settings.html.erb" => "admin-only bg-blue-100", "app/views/faqs/edit.html.erb" => "admin-only bg-blue-100", + "app/views/features/edit.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/edit.html.erb" => "admin-only bg-blue-100", "app/views/quotes/edit.html.erb" => "admin-only bg-blue-100", "app/views/resources/edit.html.erb" => "admin-only bg-blue-100", From ae0dc5d2431084df868e0b60d184fb540cd55f03 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 11 Aug 2026 12:04:33 -0400 Subject: [PATCH 2/9] Specs + AI docs for the Features & tips page Model/decorator/service/policy/request/routing specs, factory, and page_bg_class mappings. Document the seed-append workflow in CLAUDE.md, AGENTS.md, and the Copilot instructions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/copilot-instructions.md | 23 +++++ AGENTS.md | 7 +- CLAUDE.md | 29 ++++++ spec/decorators/feature_decorator_spec.rb | 53 +++++++++++ spec/factories/features.rb | 34 +++++++ spec/models/feature_spec.rb | 68 ++++++++++++++ spec/policies/feature_policy_spec.rb | 74 +++++++++++++++ spec/requests/features_spec.rb | 109 ++++++++++++++++++++++ spec/routing/features_routing_spec.rb | 37 ++++++++ spec/services/feature_catalog_spec.rb | 94 +++++++++++++++++++ 10 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 spec/decorators/feature_decorator_spec.rb create mode 100644 spec/factories/features.rb create mode 100644 spec/models/feature_spec.rb create mode 100644 spec/policies/feature_policy_spec.rb create mode 100644 spec/requests/features_spec.rb create mode 100644 spec/routing/features_routing_spec.rb create mode 100644 spec/services/feature_catalog_spec.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6e30c211e9..6eac1870b5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,6 +40,7 @@ When changing a model or controller, check whether these related files need upda | Decorator | Decorator spec | | Mailer (add/remove) | Mailer spec, mailer preview (follow existing patterns) | | Add/remove model, concern, service, or gem | AGENTS.md | +| Ship a user-facing feature | `config/features.yml` (the Features & tips seed — see below) | ## Code Style @@ -225,6 +226,28 @@ this). Match the existing pattern: form's `data-turbo-frame`, request-spec `Turbo-Frame` headers, and `turbo-frame#…` view-spec selectors. Only the filename and render target change. +## Features & tips page (`/features`) + +The login-gated **Features & tips** page lists shipped, user-facing features +(newest first, filterable by area/audience/date). It is **database-backed** +(`Feature` model) and edited in-app by super-admins — the rich `description` uses +the Rhino WYSIWYG (for screenshots), plus an optional external process-doc link. + +**Keep it current as you ship.** When you add a user-facing feature, append an +entry to `config/features.yml` (the checked-in **seed**): + +- Fields: `name`, `area` (a `Feature::AREA_KEYS` value), `display_status` + (`public_facing` / `user_facing` / `admin_facing`), `summary` (1–2 plain + sentences), `released_on` (ship date), plus optional `pro_tips` (list), + `description`, and `external_url`. +- **Sentence case, plain language** — read by facilitators, not devs. +- `admin_facing` features are visible to super-admins only (`FeaturePolicy`). + +Admins click **Import from seed** on `/features` to pull new seed entries into the +database. Import (`FeatureCatalog#import!`) is **create-missing-only** (matched by +`name`) — it never overwrites in-app edits, so appending to the seed is safe. New +area → add it to `Feature::AREAS` (label + FA icon + safelisted Tailwind hue). + ## JavaScript - ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`) diff --git a/AGENTS.md b/AGENTS.md index 79461d4afc..4961d4e635 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -112,6 +112,7 @@ This codebase (Rails 8.1) | `TopicSubscriptionType` | Admin-editable list of subscribable topics (`TopicSubscriptionTypesController` CRUD). Editable `name` + immutable derived `key` slug (stable for code lookups like `interested_in_more` → `INTERESTED_IN_MORE_KEY`); `archived_at` retires a topic without deleting it (`active`/`archived` scopes) since types in use can't be destroyed (`restrict_with_error`). Seeded (all envs) from `CANONICAL` — facilitator_trainings/news/resources | | `Report` | STI base class for MonthlyReport | | `WorkshopLog` | Standalone model for workshop log submissions (attendance, form fields) | +| `Feature` | One shipped, user-facing capability shown on the login-gated **Features & tips** page (`/features`): `name`, `area` (`AREAS`/`AREA_KEYS`), `display_status` (public/user/admin-facing — a string+constant audience gate, `admin_facing` = super-admins only via `FeaturePolicy`), `summary`, `pro_tips` (newline text → `pro_tips_list`), rich `rhino_description` (Rhino WYSIWYG, screenshots), `external_url`, `released_on`, `published`. Admin-editable in-app; `config/features.yml` is the checked-in seed hydrated by `FeatureCatalog#import!`. Presentation on `FeatureDecorator` | ### STI Models @@ -218,6 +219,7 @@ action, or `authorize! :workshop, to: :summary?`). - `RichTextMigrator` — Rich text migration utility - `StoryImporter` — Imports stories from a WordPress Posts Export CSV. Every row becomes a Story (published per the WP Status); a non-AWBW author's story also gets a promoted StoryIdea. Resolves the author Person from the facilitator name (unresolvable names kept as a Comment), converts content via wpautop, translates Categories/Tags/User Categories/who_is_your_story_about into Sectors + Categories via `config/story_import_sector_mapping.yml`, resolves orgs via `config/story_import_organization_mapping.yml`, links grant-tagged stories through the author's Scholarship, enqueues a `StoryAssetImportJob` per story to download its "Image URL" images in the background, and returns a row-by-row preview for the dry-run interstitial - `AssetUrlImporter` — Downloads a remote file URL and attaches the bytes to ActiveStorage on the given owner as an Asset (open-uri → attach); the subclass's content-type validation still applies +- `FeatureCatalog` — Imports the checked-in feature seed (`config/features.yml`) into the `Feature` table behind the `/features` page. `#import!` is create-missing-only (matched by `name`), so hydrating from the seed never overwrites an admin's in-app edits - `DisplayImagePresenter` — Image display logic - `ScholarshipsGrouping` (presenter) — Groups scholarships into the index's funder → grant → recipient hierarchy; grant-free awards collect under a trailing "Unfunded" group - `RegistrantCityBreakdown` (presenter) — Groups an event's registrants by the city of the org linked on their registration, counting registrants + scholarship recipients per city; drives the shared "Registrants by city" card inside `events/_registrant_breakdowns` on all three people-pages — per-event roster, cross-event attendees index, and scholarship recipients (fed plucked data by `EventDashboard` or `AttendeesBreakdowns`) @@ -282,7 +284,7 @@ All inherit from `ApplicationDecorator` which provides: - `display_image` — selects primary/gallery/downloadable asset intelligently - `link_target` — polymorphic path generation -Key decorators: WorkshopDecorator, StoryDecorator, ResourceDecorator, PersonDecorator, OrganizationDecorator, UserDecorator, EventDecorator, ReportDecorator, GrantDecorator, ScholarshipDecorator (derives the scholarship index's program/location/training/status columns), CommentDecorator (source chip label/link/theme + author + timestamp for the aggregated person-comments feed). +Key decorators: WorkshopDecorator, StoryDecorator, ResourceDecorator, PersonDecorator, OrganizationDecorator, UserDecorator, EventDecorator, ReportDecorator, GrantDecorator, ScholarshipDecorator (derives the scholarship index's program/location/training/status columns), CommentDecorator (source chip label/link/theme + author + timestamp for the aggregated person-comments feed), FeatureDecorator (area/audience badges, release-date labels, and the search haystack for the Features & tips page). ## Policies (ActionPolicy) @@ -345,6 +347,7 @@ end - `dropdown` — Dropdown menus with keyboard/click-outside handling - `edit_toggle` — Inline view/edit toggle for the comments and communications boxes (configurable view/edit CSS classes) - `event_staff_bio` — Loads a selected person's read-only profile bio (with edit link) alongside the editable event-specific bio on the staff form +- `feature_list` — Client-side search + area/audience dropdown filters + release-date range + newest/oldest sort over the Features & tips cards (no server round-trip; cards carry `data-area`/`data-status`/`data-date`/`data-text`) - `file_preview` — File upload preview - `conditional_fields` — Shows/hides fields based on a source ` + class="w-full bg-white border border-gray-300 rounded-lg px-3 py-2 pr-10 + focus:ring-blue-500 focus:border-blue-500"> + + + @@ -89,9 +90,8 @@
-
diff --git a/app/views/features/show.html.erb b/app/views/features/show.html.erb index dc7478c0ae..d91669b07d 100644 --- a/app/views/features/show.html.erb +++ b/app/views/features/show.html.erb @@ -41,7 +41,7 @@
<% if @feature.action_path.present? %> - <%= link_to @feature.action_path, class: "btn btn-primary" do %> + <%= link_to @feature.resolved_action_url, class: "btn btn-primary" do %> Check out this feature <% end %> <% end %> diff --git a/config/features.yml b/config/features.yml index 8929a4dab9..c9ccb530ca 100644 --- a/config/features.yml +++ b/config/features.yml @@ -29,7 +29,7 @@ area: registration display_status: public_facing released_on: 2026-08-11 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 2173 summary: >- Events can set a payment due date, and it shows on the registration ticket so @@ -39,7 +39,7 @@ area: communications display_status: admin_facing released_on: 2026-08-11 - action_path: "/communications" + action_path: "/events/1/preview_reminder" pr_number: 2168 summary: >- Switch the bulk reminder tool into invite mode to send portal login invitations @@ -50,7 +50,7 @@ area: stories display_status: admin_facing released_on: 2026-08-11 - action_path: "/stories" + action_path: "/stories/import/new" pr_number: 2163 summary: >- Bring stories over from a WordPress export, translating the old categories, tags, @@ -63,7 +63,7 @@ area: people display_status: admin_facing released_on: 2026-08-11 - action_path: "/people" + action_path: "/events/1/registrants" pr_number: 2140 summary: >- Linking a registrant to an organization now fills in the org's type, website, @@ -89,7 +89,7 @@ area: stories display_status: admin_facing released_on: 2026-08-10 - action_path: "/stories" + action_path: "/stories/1" pr_number: 2166 summary: >- Story and story-idea edit screens show the comments and email communications @@ -99,7 +99,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-08 - action_path: "/events/reports" + action_path: "/events/attendees" pr_number: 2101 summary: >- A single attendees page lists everyone who registered across all trainings, with @@ -110,7 +110,7 @@ area: stories display_status: public_facing released_on: 2026-08-09 - action_path: "/stories" + action_path: "/story_share" pr_number: 2148 summary: >- A public, shareable Story Share portal that mirrors the awbw.org styling, with @@ -122,7 +122,7 @@ area: events display_status: admin_facing released_on: 2026-08-09 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 2143 summary: >- A single, consistent filter bar across the registrant lists lets you narrow by @@ -133,7 +133,7 @@ area: registration display_status: admin_facing released_on: 2026-08-09 - action_path: "/events" + action_path: "/events/1/roster" pr_number: 2138 summary: >- The registrants roster shows each person's payment method as a badge and lets @@ -143,7 +143,7 @@ area: registration display_status: admin_facing released_on: 2026-08-09 - action_path: "/events" + action_path: "/events/1/roster" pr_number: 2107 summary: >- Mark a registrant's certificate as sent right from the roster with a slider — @@ -164,7 +164,7 @@ area: scholarships display_status: admin_facing released_on: 2026-08-09 - action_path: "/scholarships" + action_path: "/events/1/recipients" pr_number: 2134 summary: >- Highlight a scholarship recipient as a shout-out straight from the recipients @@ -174,7 +174,7 @@ area: events display_status: admin_facing released_on: 2026-08-09 - action_path: "/events" + action_path: "/events/1/staff" pr_number: 2130 summary: >- Add and manage the staff shown on an event's "Meet the staff" roster directly @@ -194,7 +194,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-09 - action_path: "/events/reports" + action_path: "/events/revenue" pr_number: 2124 summary: >- Every figure on the events revenue report opens up to the individual @@ -204,7 +204,7 @@ area: communications display_status: admin_facing released_on: 2026-08-09 - action_path: "/communications" + action_path: "/topic_subscriptions/new" pr_number: 2132 summary: >- When adding a topic subscription for someone who isn't in the system yet, you @@ -224,7 +224,7 @@ area: registration display_status: public_facing released_on: 2026-08-09 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 2139 summary: >- Registrants can indicate that someone else will pay for them, and that answer @@ -234,7 +234,7 @@ area: people display_status: admin_facing released_on: 2026-08-06 - action_path: "/people" + action_path: "/people/1/all_comments" pr_number: 2059 summary: >- One page gathers every comment connected to a person — their profile, event @@ -245,7 +245,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-06 - action_path: "/events/reports" + action_path: "/events/scholarships" pr_number: 2053 summary: >- A cross-event scholarship report totals award dollars and counts by funding @@ -255,16 +255,16 @@ area: payments display_status: user_facing released_on: 2026-08-05 - action_path: "/payments" + action_path: "/people/1/checkout" pr_number: 2094 summary: >- Members can sign up for a recurring membership and pay dues through the portal. - name: "Topic subscriptions" area: communications - display_status: user_facing + display_status: admin_facing released_on: 2026-08-04 - action_path: "/communications" + action_path: "/topic_subscriptions" pr_number: 2070 summary: >- Facilitators can subscribe to the topics they want to hear about — upcoming @@ -285,7 +285,7 @@ area: registration display_status: admin_facing released_on: 2026-08-04 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 2081 summary: >- A clean, one-page printable certificate of completion for a training registrant. @@ -294,7 +294,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-02 - action_path: "/events/reports" + action_path: "/events/1/dashboard" pr_number: 2037 summary: >- The per-event dashboard shows how many registrants actually attended alongside @@ -304,7 +304,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-02 - action_path: "/events/reports" + action_path: "/events/participation" pr_number: 2036 summary: >- A cross-event participation report counts unique people trained versus seats @@ -314,7 +314,7 @@ area: reporting display_status: admin_facing released_on: 2026-08-02 - action_path: "/events/reports" + action_path: "/events/1/roster" summary: >- Rosters and the attendees page include a US map coloring states by registrant count, plus city and location breakdowns. @@ -323,7 +323,7 @@ area: people display_status: admin_facing released_on: 2026-08-03 - action_path: "/people" + action_path: "/organizations/1" pr_number: 2076 summary: >- Mark an organization as high-profile so it stands out in lists. @@ -334,7 +334,7 @@ area: events display_status: admin_facing released_on: 2026-07-15 - action_path: "/events" + action_path: "/events/1" pr_number: 1995 summary: >- Give an event a short abbreviation that shows in tight spaces like cards and @@ -344,7 +344,7 @@ area: people display_status: admin_facing released_on: 2026-07-14 - action_path: "/people" + action_path: "/other_responses" pr_number: 1939 summary: >- Free-text "Other" answers people type on forms (like a sector that isn't @@ -356,7 +356,7 @@ area: scholarships display_status: public_facing released_on: 2026-07-14 - action_path: "/scholarships" + action_path: "/events" pr_number: 1794 summary: >- Recipients accept a scholarship agreement before the award is shown as granted, @@ -376,14 +376,14 @@ area: registration display_status: admin_facing released_on: 2026-07-06 - action_path: "/events" + action_path: "/form_submissions/1" pr_number: 1943 summary: >- Admins can review the raw answers a registrant submitted on any form. - name: "Notifications renamed to Communications" area: communications - display_status: user_facing + display_status: admin_facing released_on: 2026-07-04 action_path: "/communications" pr_number: 1941 @@ -395,7 +395,7 @@ area: reporting display_status: admin_facing released_on: 2026-07-04 - action_path: "/events/reports" + action_path: "/events/revenue" pr_number: 1817 summary: >- A chart of event revenue over time, breaking money in from organization @@ -405,7 +405,7 @@ area: people display_status: admin_facing released_on: 2026-07-04 - action_path: "/people" + action_path: "/people/1" pr_number: 1872 summary: >- Pick a person's primary and additional age ranges with a chip editor, matching @@ -425,14 +425,14 @@ area: stories display_status: public_facing released_on: 2026-07-08 - action_path: "/stories" + action_path: "/stories/1" pr_number: 1951 summary: >- Every story has a friendly, shareable slug URL. - name: "Continuing education registration" area: registration - display_status: user_facing + display_status: public_facing released_on: 2026-06-29 action_path: "/events" pr_number: 1916 @@ -444,7 +444,7 @@ area: scholarships display_status: admin_facing released_on: 2026-06-29 - action_path: "/scholarships" + action_path: "/grants" pr_number: 1920 summary: >- Browse grants in a fast, filterable list with scholarship theming. @@ -453,7 +453,7 @@ area: content display_status: user_facing released_on: 2026-07-13 - action_path: "/workshops" + action_path: "/resources" pr_number: 1893 summary: >- PDF resources open in the browser's built-in viewer instead of forcing a @@ -494,7 +494,7 @@ area: registration display_status: admin_facing released_on: 2026-06-21 - action_path: "/events" + action_path: "/events/1/onboarding" pr_number: 1788 summary: >- A per-event Onboarding tab tracks each registrant through their setup steps @@ -504,7 +504,7 @@ area: registration display_status: admin_facing released_on: 2026-06-21 - action_path: "/events" + action_path: "/events/1" pr_number: 1812 summary: >- Built-in and custom registration ticket callouts are edited together in one @@ -514,7 +514,7 @@ area: people display_status: admin_facing released_on: 2026-06-21 - action_path: "/people" + action_path: "/people/1" pr_number: 1809 summary: >- Registering or linking an org automatically creates the person's job title @@ -524,7 +524,7 @@ area: people display_status: admin_facing released_on: 2026-06-21 - action_path: "/people" + action_path: "/people/1" pr_number: 1818 summary: >- Point a person's affiliation at one of the organization's specific addresses. @@ -533,7 +533,7 @@ area: registration display_status: admin_facing released_on: 2026-06-21 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 1825 summary: >- Returning registrants are recognized even when they swap a nickname for a legal @@ -552,7 +552,7 @@ area: registration display_status: admin_facing released_on: 2026-06-17 - action_path: "/events" + action_path: "/events/1" pr_number: 1721 summary: >- Admins add call-out cards to an event's registration ticket — for payment, @@ -562,7 +562,7 @@ area: payments display_status: public_facing released_on: 2026-06-17 - action_path: "/payments" + action_path: "/events" pr_number: 1709 summary: >- A registration generates an invoice reflecting the balance due, downloadable as @@ -572,7 +572,7 @@ area: payments display_status: public_facing released_on: 2026-07-04 - action_path: "/payments" + action_path: "/events" pr_number: 1830 summary: >- A receipt is available once a registration is paid in full. @@ -581,7 +581,7 @@ area: payments display_status: public_facing released_on: 2026-06-17 - action_path: "/payments" + action_path: "/events" pr_number: 1707 summary: >- Registrants can download AWBW's W-9 from the payment callout on their ticket. @@ -590,7 +590,7 @@ area: payments display_status: public_facing released_on: 2026-06-18 - action_path: "/payments" + action_path: "/events" pr_number: 1733 summary: >- Payers can enter a custom amount at checkout rather than only a fixed price. @@ -599,7 +599,7 @@ area: payments display_status: public_facing released_on: 2026-06-08 - action_path: "/payments" + action_path: "/events" pr_number: 1600 summary: >- A bulk-payment ticket lets one payer cover several participants at once and @@ -618,7 +618,7 @@ area: scholarships display_status: admin_facing released_on: 2026-06-07 - action_path: "/scholarships" + action_path: "/grants" pr_number: 1594 summary: >- Grants hold a funder, amount, eligibility, and deadlines, and scholarships draw @@ -638,7 +638,7 @@ area: reporting display_status: admin_facing released_on: 2026-06-06 - action_path: "/events/reports" + action_path: "/events/1/dashboard" pr_number: 1565 summary: >- Each event has a dashboard summarizing registrants, organizations, sectors, @@ -648,7 +648,7 @@ area: registration display_status: admin_facing released_on: 2026-06-06 - action_path: "/events" + action_path: "/forms" pr_number: 1563 summary: >- Build registration, scholarship, and other forms from composable sections and @@ -658,7 +658,7 @@ area: events display_status: admin_facing released_on: 2026-06-11 - action_path: "/events" + action_path: "/events/1/staff" pr_number: 1616 summary: >- Attach staff to an event with titles and an expected-to-attend flag, driving the @@ -668,7 +668,7 @@ area: events display_status: admin_facing released_on: 2026-06-13 - action_path: "/events" + action_path: "/events/1/staff" pr_number: 1664 summary: >- Give a staff member a bio written just for one event, alongside their profile @@ -678,7 +678,7 @@ area: events display_status: admin_facing released_on: 2026-06-11 - action_path: "/events" + action_path: "/events/1/registrants" pr_number: 1630 summary: >- Sort the registrants table by clicking its column headers. @@ -687,7 +687,7 @@ area: communications display_status: admin_facing released_on: 2026-06-18 - action_path: "/communications" + action_path: "/events/1/preview_reminder" pr_number: 1734 summary: >- Send reminder emails to a filtered set of registrants, with a confirm step @@ -697,7 +697,7 @@ area: communications display_status: admin_facing released_on: 2026-06-18 - action_path: "/communications" + action_path: "/events/1/preview_reminder" pr_number: 1727 summary: >- Personalize the message and subject on a bulk reminder, with a live preview as @@ -707,7 +707,7 @@ area: people display_status: admin_facing released_on: 2026-06-16 - action_path: "/people" + action_path: "/event_registrations/1/link_organization" pr_number: 1669 summary: >- Manage a registrant's organizations and affiliations from one editor, resolving @@ -717,7 +717,7 @@ area: people display_status: admin_facing released_on: 2026-06-23 - action_path: "/people" + action_path: "/people/1" pr_number: 1882 summary: >- Contact info, organization details, mailing consent, and racial/ethnic identity @@ -727,7 +727,7 @@ area: people display_status: admin_facing released_on: 2026-06-23 - action_path: "/people" + action_path: "/people/1" pr_number: 1883 summary: >- Capture and edit a person's racial and ethnic identity as structured profile @@ -737,7 +737,7 @@ area: payments display_status: admin_facing released_on: 2026-06-20 - action_path: "/payments" + action_path: "/allocations/new" pr_number: 1722 summary: >- Choose who paid — a person or organization — and designate additional payers on @@ -747,7 +747,7 @@ area: events display_status: admin_facing released_on: 2026-06-19 - action_path: "/events" + action_path: "/events/1/sample_ticket" pr_number: 1793 summary: >- Preview an event's registration ticket as a typical registrant would see it, @@ -759,7 +759,7 @@ area: events display_status: admin_facing released_on: 2026-06-13 - action_path: "/events" + action_path: "/events/1" pr_number: 1666 summary: >- Pin a favorite event for a one-click shortcut to it in the nav. @@ -768,7 +768,7 @@ area: payments display_status: admin_facing released_on: 2026-06-16 - action_path: "/payments" + action_path: "/events/1/bulk_payments" pr_number: 1679 summary: >- Allocate a bulk payment across unpaid registrants with a clear allocation UI. @@ -789,7 +789,7 @@ area: communications display_status: admin_facing released_on: 2026-04-05 - action_path: "/communications" + action_path: "/events/1/preview_reminder" pr_number: 1433 summary: >- Send reminder emails to an event's registrants from the management screens. @@ -798,7 +798,7 @@ area: reporting display_status: admin_facing released_on: 2026-04-04 - action_path: "/events/reports" + action_path: "/admin/activities/charts" pr_number: 1462 summary: >- Charts of portal usage and tagging activity across the admin analytics pages. @@ -807,7 +807,7 @@ area: reporting display_status: admin_facing released_on: 2026-01-18 - action_path: "/events/reports" + action_path: "/admin/activities/events" summary: >- A searchable, filterable feed of visits and events across the portal for admins, powered by activity tracking. @@ -816,7 +816,7 @@ area: reporting display_status: admin_facing released_on: 2026-02-16 - action_path: "/events/reports" + action_path: "/queries" pr_number: 999 summary: >- Admins can run ad-hoc SQL reports through Blazer. @@ -825,7 +825,7 @@ area: people display_status: admin_facing released_on: 2026-03-01 - action_path: "/people" + action_path: "/people/1" pr_number: 1277 summary: >- Track account changes — email changes, password resets, confirmations — with @@ -835,7 +835,7 @@ area: communications display_status: admin_facing released_on: 2026-03-01 - action_path: "/communications" + action_path: "/users" pr_number: 1278 summary: >- Email-change and confirmation workflows guide admins through clear interstitial @@ -864,7 +864,7 @@ area: reporting display_status: admin_facing released_on: 2026-02-10 - action_path: "/events/reports" + action_path: "/events/1/registrants" pr_number: 888 summary: >- Download an event's registrations as a CSV. @@ -882,7 +882,7 @@ area: scholarships display_status: public_facing released_on: 2026-02-28 - action_path: "/scholarships" + action_path: "/events" pr_number: 1253 summary: >- A dedicated scholarship application form, whose answers surface wherever the @@ -892,7 +892,7 @@ area: people display_status: admin_facing released_on: 2026-02-14 - action_path: "/people" + action_path: "/people/check_duplicates" pr_number: 990 summary: >- Creating a person or user warns about likely duplicates before you save. @@ -901,7 +901,7 @@ area: content display_status: admin_facing released_on: 2026-02-15 - action_path: "/workshops" + action_path: "/categories/dedupe_index" pr_number: 989 summary: >- Tools to find and merge duplicate categories and sectors, keeping the taxonomy @@ -911,7 +911,7 @@ area: people display_status: admin_facing released_on: 2026-02-15 - action_path: "/people" + action_path: "/people/1" pr_number: 996 summary: >- Leave threaded comments on a person or organization, with optional topic and @@ -921,7 +921,7 @@ area: people display_status: admin_facing released_on: 2026-02-12 - action_path: "/people" + action_path: "/users" pr_number: 893 summary: >- Invite people to the portal with a confirmation and welcome page where they set @@ -931,7 +931,7 @@ area: communications display_status: admin_facing released_on: 2026-02-22 - action_path: "/communications" + action_path: "/users" pr_number: 1162 summary: >- Send welcome instructions to many people at once as a background job. @@ -940,7 +940,7 @@ area: communications display_status: admin_facing released_on: 2026-05-25 - action_path: "/communications" + action_path: "/contact_us" pr_number: 1493 summary: >- Mark contact-us submissions as responded so nothing slips through. @@ -949,7 +949,7 @@ area: reporting display_status: admin_facing released_on: 2026-05-25 - action_path: "/events/reports" + action_path: "/monthly_reports" pr_number: 1530 summary: >- Monthly reports use standard list screens with the same search and filters as @@ -969,7 +969,7 @@ area: communications display_status: public_facing released_on: 2026-03-01 - action_path: "/communications" + action_path: "/events" pr_number: 1287 summary: >- Cancelling an event registration sends the registrant a cancellation email. @@ -978,7 +978,7 @@ area: people display_status: admin_facing released_on: 2026-01-23 - action_path: "/people" + action_path: "/users/1" pr_number: 760 summary: >- Admins can lock or unlock a user's account from the edit screen. @@ -987,7 +987,7 @@ area: content display_status: admin_facing released_on: 2026-01-25 - action_path: "/workshops" + action_path: "/categories" pr_number: 712 summary: >- Reorder categories within a category type by dragging them. @@ -996,7 +996,7 @@ area: content display_status: user_facing released_on: 2026-03-09 - action_path: "/workshops" + action_path: "/video_recordings" pr_number: 1228 summary: >- A gallery of video recordings, flagged as instructional or podcast, that @@ -1045,7 +1045,7 @@ area: content display_status: user_facing released_on: 2025-09-20 - action_path: "/workshops" + action_path: "/workshop_variations" summary: >- Facilitator-contributed variations on a workshop, with their own images and videos. @@ -1054,7 +1054,7 @@ area: content display_status: user_facing released_on: 2025-09-13 - action_path: "/workshops" + action_path: "/resources" summary: >- Downloadable handouts, toolkits, and templates that facilitators can search and save. @@ -1063,7 +1063,7 @@ area: content display_status: user_facing released_on: 2025-09-23 - action_path: "/workshops" + action_path: "/bookmarks/personal" summary: >- Bookmark workshops, resources, events, and more, then sort and filter your saved items. @@ -1081,15 +1081,15 @@ area: content display_status: user_facing released_on: 2025-09-27 - action_path: "/workshops" + action_path: "/workshop_logs" summary: >- Facilitators log the workshops they lead, with attendance and reflection fields. - name: "Workshop ideas" area: content - display_status: user_facing + display_status: admin_facing released_on: 2025-11-06 - action_path: "/workshops" + action_path: "/workshop_ideas" summary: >- Facilitators submit workshop ideas that admins can review and promote into full workshops. @@ -1098,7 +1098,7 @@ area: content display_status: user_facing released_on: 2026-02-09 - action_path: "/workshops" + action_path: "/workshop_variation_ideas" pr_number: 871 summary: >- Facilitators submit ideas for workshop variations, promotable into published @@ -1108,7 +1108,7 @@ area: content display_status: user_facing released_on: 2025-09-23 - action_path: "/workshops" + action_path: "/faqs" summary: >- A searchable, accordion-style FAQ and Help section, with reorderable, inline- editable questions. @@ -1117,7 +1117,7 @@ area: content display_status: admin_facing released_on: 2025-11-26 - action_path: "/workshops" + action_path: "/quotes" summary: >- Curate participant quotes and surface them alongside workshop logs. @@ -1125,7 +1125,7 @@ area: content display_status: admin_facing released_on: 2025-12-22 - action_path: "/workshops" + action_path: "/tags" summary: >- A tagging system of categories and sectors, with admin CRUD, that organizes content across the portal. @@ -1134,7 +1134,7 @@ area: content display_status: admin_facing released_on: 2025-11-27 - action_path: "/workshops" + action_path: "/windows_types" pr_number: 515 summary: >- Classify content by Windows audience type for filtering and display. @@ -1151,7 +1151,7 @@ area: events display_status: public_facing released_on: 2025-11-19 - action_path: "/events" + action_path: "/events/1" summary: >- Add-to-calendar links and location/zoom details on event pages. @@ -1159,7 +1159,7 @@ area: events display_status: public_facing released_on: 2026-02-21 - action_path: "/events" + action_path: "/events/1" pr_number: 1144 summary: >- A social-share sidebar lets visitors share an event page. @@ -1168,7 +1168,7 @@ area: people display_status: admin_facing released_on: 2025-09-13 - action_path: "/people" + action_path: "/organizations" summary: >- Organization records with affiliations, addresses, logos, and profile details. @@ -1176,7 +1176,7 @@ area: people display_status: admin_facing released_on: 2025-11-08 - action_path: "/people" + action_path: "/people/1" summary: >- Rich profiles for the people in AWBW's network, with contact info, sectors, and affiliations. @@ -1185,7 +1185,7 @@ area: people display_status: admin_facing released_on: 2026-02-16 - action_path: "/people" + action_path: "/people/1" summary: >- Connect people to organizations with titles and date ranges, distinguishing facilitator affiliations. @@ -1194,7 +1194,7 @@ area: people display_status: admin_facing released_on: 2025-11-16 - action_path: "/people" + action_path: "/organizations/1" summary: >- Organizations hold multiple structured addresses instead of a single set of fields. @@ -1203,7 +1203,7 @@ area: people display_status: admin_facing released_on: 2025-09-13 - action_path: "/people" + action_path: "/users" summary: >- Manage portal login accounts, tied to people, with confirmation and invite states. @@ -1212,7 +1212,7 @@ area: people display_status: public_facing released_on: 2025-09-22 - action_path: "/people" + action_path: "/users/sign_in" summary: >- Secure login with a clear password reset and change flow. @@ -1228,7 +1228,7 @@ area: stories display_status: user_facing released_on: 2025-11-02 - action_path: "/stories" + action_path: "/story_ideas" summary: >- Facilitators submit story ideas that admins can promote into published stories. @@ -1236,7 +1236,7 @@ area: stories display_status: public_facing released_on: 2025-11-12 - action_path: "/stories" + action_path: "/community_news" summary: >- Post and browse community news with images and rich text. @@ -1244,7 +1244,7 @@ area: communications display_status: public_facing released_on: 2025-11-07 - action_path: "/communications" + action_path: "/contact_us" summary: >- A contact form that emails AWBW and sets expectations for a reply. @@ -1252,7 +1252,7 @@ area: communications display_status: admin_facing released_on: 2025-11-07 - action_path: "/communications" + action_path: "/banners" summary: >- Admins publish banners with sanitized content across the portal. @@ -1269,7 +1269,7 @@ area: content display_status: user_facing released_on: 2025-09-28 - action_path: "/workshops" + action_path: "/" summary: >- A home dashboard surfacing featured workshops, stories, news, and events for signed-in facilitators. @@ -1278,7 +1278,7 @@ area: content display_status: admin_facing released_on: 2025-11-18 - action_path: "/workshops" + action_path: "/" summary: >- Flag workshops, stories, and other content as featured to intentionally place it on the home dashboard. @@ -1287,7 +1287,7 @@ area: stories display_status: user_facing released_on: 2026-02-04 - action_path: "/stories" + action_path: "/stories/1" pr_number: 738 summary: >- Stories and community news have print-friendly layouts. @@ -1296,7 +1296,7 @@ area: reporting display_status: admin_facing released_on: 2025-11-08 - action_path: "/events/reports" + action_path: "/admin" summary: >- A universal recent-activity feed showing the latest content changes across the portal. diff --git a/spec/decorators/feature_decorator_spec.rb b/spec/decorators/feature_decorator_spec.rb index 89575a6511..90343e598a 100644 --- a/spec/decorators/feature_decorator_spec.rb +++ b/spec/decorators/feature_decorator_spec.rb @@ -18,6 +18,29 @@ expect(decorated.area_color).to eq("amber") end + describe "#resolved_action_url" do + it "returns a non-record path unchanged" do + expect(build(:feature, action_path: "/events/reports").decorate.resolved_action_url).to eq("/events/reports") + expect(build(:feature, action_path: "/people").decorate.resolved_action_url).to eq("/people") + end + + it "keeps the deep link when the sample record (id 1) exists" do + allow(Event).to receive(:exists?).with(1).and_return(true) + expect(build(:feature, action_path: "/events/1/registrants").decorate.resolved_action_url) + .to eq("/events/1/registrants") + end + + it "falls back to the resource index when id 1 is missing" do + allow(Event).to receive(:exists?).with(1).and_return(false) + expect(build(:feature, action_path: "/events/1/registrants").decorate.resolved_action_url) + .to eq("/events") + end + + it "leaves a blank action_path nil" do + expect(build(:feature, action_path: nil).decorate.resolved_action_url).to be_nil + end + end + describe "#pr_url" do it "builds a GitHub PR link when a pr_number is set" do expect(build(:feature, pr_number: 2170).decorate.pr_url) diff --git a/spec/services/feature_catalog_spec.rb b/spec/services/feature_catalog_spec.rb index d4e97846f9..2b9079c7dd 100644 --- a/spec/services/feature_catalog_spec.rb +++ b/spec/services/feature_catalog_spec.rb @@ -59,26 +59,38 @@ expect(feature.rhino_description.to_plain_text).to include("Longer write-up") end - it "does not overwrite a field an admin already filled in" do + it "does not overwrite admin-written content (summary)" do existing = create(:feature, name: "Seed feature one", summary: "Edited in-app") expect { catalog.import! }.to change(Feature, :count).by(1) # only "two" is new expect(existing.reload.summary).to eq("Edited in-app") end - it "fills in blank fields on an existing feature (missing info)" do + it "fills in blank content fields (missing info)" do existing = create(:feature, name: "Seed feature one", summary: "Edited in-app", - action_path: nil, pr_number: nil, external_url: nil) + external_url: nil) + + result = catalog.import! + + expect(result.updated).to eq(1) + expect(existing.reload.external_url).to eq("https://docs.example.com/one") + expect(existing.summary).to eq("Edited in-app") # admin content left alone + end + + it "re-syncs catalog classification (audience/area/links) that has drifted from the seed" do + existing = create(:feature, name: "Seed feature one", + display_status: "admin_facing", area: "reporting", + action_path: "/reports", pr_number: nil) result = catalog.import! expect(result.updated).to eq(1) expect(existing.reload).to have_attributes( + display_status: "user_facing", # corrected back to the seed + area: "events", action_path: "/events", - pr_number: 1234, - external_url: "https://docs.example.com/one" + pr_number: 1234 ) - expect(existing.summary).to eq("Edited in-app") # non-blank field left alone end it "is a no-op on a second run" do