diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6e30c211e..9394536fc 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,31 @@ 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`, `external_url`, `action_path` (in-app "Check out this feature" + link), and `pr_number` (GitHub PR link). +- **Sentence case, plain language** — read by facilitators, not devs. +- `admin_facing` features are visible to super-admins only (`FeaturePolicy`). + +Admins click **Sync latest updates** on `/features` (`FeatureCatalog#import!`, +matched by `name`) to add newly-shipped features, **re-sync catalog classification** +on existing records (`CATALOG_FIELDS` — area, audience, links, date, PR — so seed +fixes propagate) and **fill blank content** (`CONTENT_FIELDS` — summary, tips, +guide, description) without overwriting admin writing. 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 79461d4af..42e535ee0 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`, `action_path` (in-app "Check out this feature" link), `pr_number` (GitHub PR link), `released_on`, `published`. Admin-editable in-app; `config/features.yml` is the checked-in seed synced 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` — Syncs the checked-in feature seed (`config/features.yml`) into the `Feature` table behind the `/features` page (the "Sync latest updates" button). `#import!` (matched by `name`) creates missing features, re-aligns catalog-owned classification (`CATALOG_FIELDS` — area, display_status, released_on, action_path, pr_number, so seed corrections propagate), and fills blank admin content (`CONTENT_FIELDS` — summary, pro_tips, external_url, rhino_description) without overwriting it; returns a `Result` (`created`/`updated`) - `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 ` + + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + +

+ 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 000000000..a50d17c70 --- /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 000000000..d91669b07 --- /dev/null +++ b/app/views/features/show.html.erb @@ -0,0 +1,91 @@ +<% 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_label %> + +
+
+ <%= @feature.status_badge %> + <% unless @feature.published? %> + "> + Draft + + <% end %> +
+
+
+ +
+ +
+

<%= @feature.name %>

+ + <%= @feature.released_label %> + +
+ +

<%= @feature.summary %>

+ +
+ <% if @feature.action_path.present? %> + <%= link_to @feature.resolved_action_url, class: "btn btn-primary" do %> + Check out this feature + <% end %> + <% end %> + <% if @feature.external_url.present? %> + <%= link_to safe_url(@feature.external_url), target: "_blank", rel: "noopener noreferrer", + class: "btn btn-secondary-outline" do %> + Full guide + <% end %> + <% end %> + <% if @feature.pr_url.present? %> + <%= link_to @feature.pr_url, target: "_blank", rel: "noopener noreferrer", + class: "btn btn-secondary-outline" do %> + View the pull request + <% end %> + <% end %> +
+ + <% 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 allowed_to?(:update?, @feature) %> +
+ <%= link_to "Edit", edit_feature_path(@feature), class: "admin-only bg-blue-100 btn btn-primary-outline" %> + <%= button_to "Delete", feature_path(@feature), method: :delete, + class: "admin-only bg-blue-100 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 5e86ebccc..f48f26db7 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 abf4f0943..47a0e3247 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 000000000..c9ccb530c --- /dev/null +++ b/config/features.yml @@ -0,0 +1,1302 @@ +# 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 "Sync latest updates" 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). +# action_path: In-app path for the "Check out this feature" link (e.g. /events). +# pr_number: GitHub PR number it shipped in (adds a "View the pull request" link). +# 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). + +# ── 2026-08 ───────────────────────────────────────────────────────────────── + +- name: "Payment due date on the ticket" + area: registration + display_status: public_facing + released_on: 2026-08-11 + action_path: "/events/1/registrants" + pr_number: 2173 + summary: >- + Events can set a payment due date, and it shows on the registration ticket so + registrants know by when they need to pay. + +- name: "Bulk portal login invites from the reminders page" + area: communications + display_status: admin_facing + released_on: 2026-08-11 + action_path: "/events/1/preview_reminder" + pr_number: 2168 + summary: >- + Switch the bulk reminder tool into invite mode to send portal login invitations + to a filtered set of registrants in one pass, creating accounts for anyone who + doesn't have one yet. + +- name: "Import stories from the old WordPress site" + area: stories + display_status: admin_facing + released_on: 2026-08-11 + action_path: "/stories/import/new" + pr_number: 2163 + summary: >- + Bring stories over from a WordPress export, translating the old categories, tags, + authors, and organizations into the portal's sectors and records, with a preview + before anything is saved. + pro_tips: + - "You get a row-by-row dry-run preview first, so you can check the mapping before importing for real." + +- name: "Auto-filled organization details when linking a registration" + area: people + display_status: admin_facing + released_on: 2026-08-11 + action_path: "/events/1/registrants" + pr_number: 2140 + 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 + action_path: "/events/reports" + pr_number: 2113 + 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 + action_path: "/stories/1" + pr_number: 2166 + 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: "Cross-event training attendees page" + area: reporting + display_status: admin_facing + released_on: 2026-08-08 + action_path: "/events/attendees" + pr_number: 2101 + summary: >- + A single attendees page lists everyone who registered across all trainings, with + the same roster columns and breakdown charts as a single event, filterable and + drillable. + +- name: "Public Story Share portal" + area: stories + display_status: public_facing + released_on: 2026-08-09 + action_path: "/story_share" + pr_number: 2148 + 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 + action_path: "/events/1/registrants" + pr_number: 2143 + 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 + action_path: "/events/1/roster" + pr_number: 2138 + 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 + action_path: "/events/1/roster" + pr_number: 2107 + 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 + action_path: "/scholarships" + pr_number: 2054 + 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 + action_path: "/events/1/recipients" + pr_number: 2134 + 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 + action_path: "/events/1/staff" + pr_number: 2130 + 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 + action_path: "/events" + pr_number: 2131 + 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 + action_path: "/events/revenue" + pr_number: 2124 + 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 + action_path: "/topic_subscriptions/new" + pr_number: 2132 + 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 + action_path: "/communications" + pr_number: 2089 + 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 + action_path: "/events/1/registrants" + pr_number: 2139 + summary: >- + Registrants can indicate that someone else will pay for them, and that answer + is carried onto their registration for admins to see. + +- name: "Aggregated comments for a person" + area: people + display_status: admin_facing + released_on: 2026-08-06 + action_path: "/people/1/all_comments" + pr_number: 2059 + summary: >- + One page gathers every comment connected to a person — their profile, event + registrations, scholarships, CE, and subscriptions — plus a global comments + index with search and filters. + +- name: "Scholarship statistics report" + area: reporting + display_status: admin_facing + released_on: 2026-08-06 + action_path: "/events/scholarships" + pr_number: 2053 + summary: >- + A cross-event scholarship report totals award dollars and counts by funding + source and training, split by live vs. on-demand attendance. + +- name: "Membership sign-up and dues" + area: payments + display_status: user_facing + released_on: 2026-08-05 + 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: admin_facing + released_on: 2026-08-04 + action_path: "/topic_subscriptions" + pr_number: 2070 + summary: >- + Facilitators can subscribe to the topics they want to hear about — upcoming + trainings, news, resources — and admins manage the list of topics and who's + subscribed. + +- name: "Start a new communication from the index" + area: communications + display_status: admin_facing + released_on: 2026-08-04 + action_path: "/communications" + pr_number: 2091 + summary: >- + Compose and send a one-off communication to a person straight from the + communications index, without going through a record first. + +- name: "Printable completion certificate" + area: registration + display_status: admin_facing + released_on: 2026-08-04 + action_path: "/events/1/registrants" + pr_number: 2081 + summary: >- + A clean, one-page printable certificate of completion for a training registrant. + +- name: "Attendance stats on the event dashboard" + area: reporting + display_status: admin_facing + released_on: 2026-08-02 + action_path: "/events/1/dashboard" + pr_number: 2037 + summary: >- + The per-event dashboard shows how many registrants actually attended alongside + the registration and revenue figures. + +- name: "Event participation report and statistics hub" + area: reporting + display_status: admin_facing + released_on: 2026-08-02 + action_path: "/events/participation" + pr_number: 2036 + summary: >- + A cross-event participation report counts unique people trained versus seats + filled by year, anchored by an events statistics hub. + +- name: "Registrants-by-state map and location breakdowns" + area: reporting + display_status: admin_facing + released_on: 2026-08-02 + 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. + +- name: "High-profile flag for organizations" + area: people + display_status: admin_facing + released_on: 2026-08-03 + action_path: "/organizations/1" + pr_number: 2076 + summary: >- + Mark an organization as high-profile so it stands out in lists. + +# ── 2026-07 ───────────────────────────────────────────────────────────────── + +- name: "Event abbreviation for compact contexts" + area: events + display_status: admin_facing + released_on: 2026-07-15 + action_path: "/events/1" + pr_number: 1995 + summary: >- + Give an event a short abbreviation that shows in tight spaces like cards and + chips instead of the full title. + +- name: "\"Other\" answer curation queue" + area: people + display_status: admin_facing + released_on: 2026-07-14 + action_path: "/other_responses" + pr_number: 1939 + summary: >- + Free-text "Other" answers people type on forms (like a sector that isn't + listed) are captured as records you can promote, keep, or dismiss. + pro_tips: + - "Promoting a sector \"Other\" turns it into a real sector tag on the person and their organization." + +- name: "Scholarship agreement before an award shows" + area: scholarships + display_status: public_facing + released_on: 2026-07-14 + action_path: "/events" + pr_number: 1794 + summary: >- + Recipients accept a scholarship agreement before the award is shown as granted, + so acceptance is on record. + +- name: "Collapsible sections on ticket callout pages" + area: registration + display_status: public_facing + released_on: 2026-07-14 + action_path: "/events" + pr_number: 1979 + summary: >- + Callout detail pages render expandable sections from the admin's content, so + long instructions and FAQs stay tidy. + +- name: "Admin review of form submissions" + area: registration + display_status: admin_facing + released_on: 2026-07-06 + 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: admin_facing + released_on: 2026-07-04 + action_path: "/communications" + pr_number: 1941 + summary: >- + The user-facing "Notifications" area is now called "Communications" throughout + the portal. + +- name: "Revenue-over-time page for events" + area: reporting + display_status: admin_facing + released_on: 2026-07-04 + action_path: "/events/revenue" + pr_number: 1817 + summary: >- + A chart of event revenue over time, breaking money in from organization + subsidy and net. + +- name: "Age-range editor on the person form" + area: people + display_status: admin_facing + released_on: 2026-07-04 + action_path: "/people/1" + pr_number: 1872 + summary: >- + Pick a person's primary and additional age ranges with a chip editor, matching + how sectors are edited. + +- name: "Inline-editable communications with channel icons" + area: communications + display_status: admin_facing + released_on: 2026-07-08 + action_path: "/communications" + pr_number: 1942 + summary: >- + Edit a communication in place, with icons showing the channel and message + bodies gated to admins. + +- name: "Shareable story links" + area: stories + display_status: public_facing + released_on: 2026-07-08 + action_path: "/stories/1" + pr_number: 1951 + summary: >- + Every story has a friendly, shareable slug URL. + +- name: "Continuing education registration" + area: registration + display_status: public_facing + released_on: 2026-06-29 + action_path: "/events" + pr_number: 1916 + summary: >- + Registrants can request continuing-education hours against a professional + license, with fees, deadlines, and a certificate tracked per event. + +- name: "Filterable grants index" + area: scholarships + display_status: admin_facing + released_on: 2026-06-29 + action_path: "/grants" + pr_number: 1920 + summary: >- + Browse grants in a fast, filterable list with scholarship theming. + +- name: "PDF resources open in the browser viewer" + area: content + display_status: user_facing + released_on: 2026-07-13 + action_path: "/resources" + pr_number: 1893 + summary: >- + PDF resources open in the browser's built-in viewer instead of forcing a + download. + +- name: "Credit content to any person" + area: content + display_status: admin_facing + released_on: 2026-07-09 + action_path: "/workshops" + pr_number: 1940 + summary: >- + Workshops, stories, resources, and community news can be credited to any + person, not just people with active login accounts. + +# ── 2026-06 ───────────────────────────────────────────────────────────────── + +- name: "Refreshed registration ticket" + area: registration + display_status: public_facing + released_on: 2026-06-22 + action_path: "/events" + pr_number: 1751 + summary: >- + The registration ticket got a cleaner look with a thumbnail header, compact + details, and status chips. + +- name: "Videoconference access window and passcode" + area: events + display_status: public_facing + released_on: 2026-06-22 + action_path: "/events" + summary: >- + Videoconference links, passcodes, and room details appear on the ticket only + around the event time and for paid registrants. + +- name: "Onboarding tab for registrant setup" + area: registration + display_status: admin_facing + released_on: 2026-06-21 + action_path: "/events/1/onboarding" + pr_number: 1788 + summary: >- + A per-event Onboarding tab tracks each registrant through their setup steps + with a checkbox matrix. + +- name: "Consolidated ticket callout editor" + area: registration + display_status: admin_facing + released_on: 2026-06-21 + action_path: "/events/1" + pr_number: 1812 + summary: >- + Built-in and custom registration ticket callouts are edited together in one + drag-to-reorder editor, with live color and icon preview. + +- name: "Job and facilitator affiliations from registration" + area: people + display_status: admin_facing + released_on: 2026-06-21 + action_path: "/people/1" + pr_number: 1809 + summary: >- + Registering or linking an org automatically creates the person's job title + affiliation plus a standing Facilitator affiliation. + +- name: "Link an affiliation to an organization address" + area: people + display_status: admin_facing + released_on: 2026-06-21 + action_path: "/people/1" + pr_number: 1818 + summary: >- + Point a person's affiliation at one of the organization's specific addresses. + +- name: "Match returning registrants across name changes" + area: registration + display_status: admin_facing + released_on: 2026-06-21 + action_path: "/events/1/registrants" + pr_number: 1825 + summary: >- + Returning registrants are recognized even when they swap a nickname for a legal + first name, so they aren't duplicated. + +- name: "Multi-day events show as a date range" + area: events + display_status: public_facing + released_on: 2026-06-21 + action_path: "/events" + pr_number: 1791 + summary: >- + Events spanning several days display as a date range with a single daily time. + +- name: "Admin-configurable registration ticket callouts" + area: registration + display_status: admin_facing + released_on: 2026-06-17 + action_path: "/events/1" + pr_number: 1721 + summary: >- + Admins add call-out cards to an event's registration ticket — for payment, + handouts, videoconference, and more — each linking to its own detail page. + +- name: "Event invoice with PDF download" + area: payments + display_status: public_facing + released_on: 2026-06-17 + action_path: "/events" + pr_number: 1709 + summary: >- + A registration generates an invoice reflecting the balance due, downloadable as + a PDF. + +- name: "Paid-in-full receipt" + area: payments + display_status: public_facing + released_on: 2026-07-04 + action_path: "/events" + pr_number: 1830 + summary: >- + A receipt is available once a registration is paid in full. + +- name: "W-9 download on the ticket" + area: payments + display_status: public_facing + released_on: 2026-06-17 + action_path: "/events" + pr_number: 1707 + summary: >- + Registrants can download AWBW's W-9 from the payment callout on their ticket. + +- name: "Custom checkout amount" + area: payments + display_status: public_facing + released_on: 2026-06-18 + action_path: "/events" + pr_number: 1733 + summary: >- + Payers can enter a custom amount at checkout rather than only a fixed price. + +- name: "Pay for others in one submission" + area: payments + display_status: public_facing + released_on: 2026-06-08 + action_path: "/events" + pr_number: 1600 + summary: >- + A bulk-payment ticket lets one payer cover several participants at once and + allocate the total across them. + +- name: "Payments change log" + area: payments + display_status: admin_facing + released_on: 2026-06-18 + action_path: "/payments" + pr_number: 1737 + summary: >- + Payment and allocation changes are recorded in a change log admins can review. + +- name: "Grants as parents of scholarships" + area: scholarships + display_status: admin_facing + released_on: 2026-06-07 + action_path: "/grants" + pr_number: 1594 + summary: >- + Grants hold a funder, amount, eligibility, and deadlines, and scholarships draw + from them without exceeding the grant total. + +- name: "Grant-aware scholarship picker" + area: scholarships + display_status: admin_facing + released_on: 2026-06-08 + action_path: "/scholarships" + pr_number: 1597 + summary: >- + Awarding a scholarship shows each grant's remaining-of-total funds as you pick + the funding source. + +- name: "Per-event dashboard" + area: reporting + display_status: admin_facing + released_on: 2026-06-06 + action_path: "/events/1/dashboard" + pr_number: 1565 + summary: >- + Each event has a dashboard summarizing registrants, organizations, sectors, + locations, scholarships, and payments, with drill-in filters. + +- name: "Custom form builder with roles" + area: registration + display_status: admin_facing + released_on: 2026-06-06 + action_path: "/forms" + pr_number: 1563 + summary: >- + Build registration, scholarship, and other forms from composable sections and + fields, with per-field width, hints, options, and visibility rules. + +- name: "Curated event staff roster" + area: events + display_status: admin_facing + released_on: 2026-06-11 + action_path: "/events/1/staff" + pr_number: 1616 + summary: >- + Attach staff to an event with titles and an expected-to-attend flag, driving the + public "Meet the staff" roster. + +- name: "Event-specific staff bio" + area: events + display_status: admin_facing + released_on: 2026-06-13 + action_path: "/events/1/staff" + pr_number: 1664 + summary: >- + Give a staff member a bio written just for one event, alongside their profile + bio. + +- name: "Sortable registrants table" + area: events + display_status: admin_facing + released_on: 2026-06-11 + action_path: "/events/1/registrants" + pr_number: 1630 + summary: >- + Sort the registrants table by clicking its column headers. + +- name: "Bulk event reminders with a recipient picker" + area: communications + display_status: admin_facing + released_on: 2026-06-18 + action_path: "/events/1/preview_reminder" + pr_number: 1734 + summary: >- + Send reminder emails to a filtered set of registrants, with a confirm step + showing exactly who will receive it. + +- name: "Custom reminder message and subject" + area: communications + display_status: admin_facing + released_on: 2026-06-18 + action_path: "/events/1/preview_reminder" + pr_number: 1727 + summary: >- + Personalize the message and subject on a bulk reminder, with a live preview as + you type. + +- name: "Registrant organization-linking editor" + area: people + display_status: admin_facing + released_on: 2026-06-16 + action_path: "/event_registrations/1/link_organization" + pr_number: 1669 + summary: >- + Manage a registrant's organizations and affiliations from one editor, resolving + the org they typed to a saved record. + +- name: "Structured profile capture from registration" + area: people + display_status: admin_facing + released_on: 2026-06-23 + action_path: "/people/1" + pr_number: 1882 + summary: >- + Contact info, organization details, mailing consent, and racial/ethnic identity + submitted on registration are stored as structured profile data. + +- name: "Racial and ethnic identity on profiles" + area: people + display_status: admin_facing + released_on: 2026-06-23 + action_path: "/people/1" + pr_number: 1883 + summary: >- + Capture and edit a person's racial and ethnic identity as structured profile + data. + +- name: "Compound payer picker on allocations" + area: payments + display_status: admin_facing + released_on: 2026-06-20 + action_path: "/allocations/new" + pr_number: 1722 + summary: >- + Choose who paid — a person or organization — and designate additional payers on + the allocation form. + +- name: "Sample ticket preview" + area: events + display_status: admin_facing + released_on: 2026-06-19 + action_path: "/events/1/sample_ticket" + pr_number: 1793 + summary: >- + Preview an event's registration ticket as a typical registrant would see it, + without creating any data. + pro_tips: + - "The sample uses a throwaway \"Sample Person\" — nothing is saved and it never shows up in counts or rosters." + +- name: "Favorite event nav shortcut" + area: events + display_status: admin_facing + released_on: 2026-06-13 + action_path: "/events/1" + pr_number: 1666 + summary: >- + Pin a favorite event for a one-click shortcut to it in the nav. + +- name: "Grouped bulk-payment allocations" + area: payments + display_status: admin_facing + released_on: 2026-06-16 + action_path: "/events/1/bulk_payments" + pr_number: 1679 + summary: >- + Allocate a bulk payment across unpaid registrants with a clear allocation UI. + +# ── 2026-05 and earlier 2026 ──────────────────────────────────────────────── + +- name: "Online payments, allocations, and refunds" + area: payments + display_status: admin_facing + released_on: 2026-06-05 + action_path: "/payments" + pr_number: 1487 + summary: >- + Card payments through Stripe, with allocations recording what each payment + covers and support for refunds and discounts. + +- name: "Reminder emails from event management" + area: communications + display_status: admin_facing + released_on: 2026-04-05 + action_path: "/events/1/preview_reminder" + pr_number: 1433 + summary: >- + Send reminder emails to an event's registrants from the management screens. + +- name: "Portal usage and tagging analytics" + area: reporting + display_status: admin_facing + released_on: 2026-04-04 + action_path: "/admin/activities/charts" + pr_number: 1462 + summary: >- + Charts of portal usage and tagging activity across the admin analytics pages. + +- name: "Admin activity feed" + area: reporting + display_status: admin_facing + released_on: 2026-01-18 + action_path: "/admin/activities/events" + summary: >- + A searchable, filterable feed of visits and events across the portal for admins, + powered by activity tracking. + +- name: "Business intelligence queries" + area: reporting + display_status: admin_facing + released_on: 2026-02-16 + action_path: "/queries" + pr_number: 999 + summary: >- + Admins can run ad-hoc SQL reports through Blazer. + +- name: "Account activity tracking" + area: people + display_status: admin_facing + released_on: 2026-03-01 + action_path: "/people/1" + pr_number: 1277 + summary: >- + Track account changes — email changes, password resets, confirmations — with + dynamic search on a person's account activity. + +- name: "Confirm an email change with interstitials" + area: communications + display_status: admin_facing + released_on: 2026-03-01 + action_path: "/users" + pr_number: 1278 + summary: >- + Email-change and confirmation workflows guide admins through clear interstitial + pages and send the right copy to the right address. + +- name: "Public event registration" + area: registration + display_status: public_facing + released_on: 2026-02-24 + action_path: "/events" + pr_number: 1206 + summary: >- + Events can accept registrations from the public through a polished registration + form, gated by a per-event toggle. + +- name: "Slug-based registration URLs" + area: registration + display_status: public_facing + released_on: 2026-03-01 + action_path: "/events" + pr_number: 1268 + summary: >- + Each event registration has a friendly slug URL for the ticket. + +- name: "Export event registrations to CSV" + area: reporting + display_status: admin_facing + released_on: 2026-02-10 + action_path: "/events/1/registrants" + pr_number: 888 + summary: >- + Download an event's registrations as a CSV. + +- name: "Scholarship recipient tracking" + area: scholarships + display_status: admin_facing + released_on: 2026-02-28 + action_path: "/scholarships" + pr_number: 1262 + summary: >- + Track scholarship recipients against event registrations. + +- name: "Scholarship application form" + area: scholarships + display_status: public_facing + released_on: 2026-02-28 + action_path: "/events" + pr_number: 1253 + summary: >- + A dedicated scholarship application form, whose answers surface wherever the + scholarship is reviewed. + +- name: "Duplicate person detection" + area: people + display_status: admin_facing + released_on: 2026-02-14 + action_path: "/people/check_duplicates" + pr_number: 990 + summary: >- + Creating a person or user warns about likely duplicates before you save. + +- name: "Merge duplicate tags, categories, and sectors" + area: content + display_status: admin_facing + released_on: 2026-02-15 + action_path: "/categories/dedupe_index" + pr_number: 989 + summary: >- + Tools to find and merge duplicate categories and sectors, keeping the taxonomy + clean. + +- name: "Comments on people and organizations" + area: people + display_status: admin_facing + released_on: 2026-02-15 + action_path: "/people/1" + pr_number: 996 + summary: >- + Leave threaded comments on a person or organization, with optional topic and + follow-up flags. + +- name: "User invitations and welcome flow" + area: people + display_status: admin_facing + released_on: 2026-02-12 + action_path: "/users" + pr_number: 893 + summary: >- + Invite people to the portal with a confirmation and welcome page where they set + their password. + +- name: "Bulk portal invitations" + area: communications + display_status: admin_facing + released_on: 2026-02-22 + action_path: "/users" + pr_number: 1162 + summary: >- + Send welcome instructions to many people at once as a background job. + +- name: "Track replies to contact-us messages" + area: communications + display_status: admin_facing + released_on: 2026-05-25 + action_path: "/contact_us" + pr_number: 1493 + summary: >- + Mark contact-us submissions as responded so nothing slips through. + +- name: "Monthly reports with shared filters" + area: reporting + display_status: admin_facing + released_on: 2026-05-25 + action_path: "/monthly_reports" + pr_number: 1530 + summary: >- + Monthly reports use standard list screens with the same search and filters as + the rest of the portal. + +- name: "Resend a communication with history" + area: communications + display_status: admin_facing + released_on: 2026-02-10 + action_path: "/communications" + pr_number: 900 + summary: >- + Resend a notification while keeping the parent/child chain so the history is + intact. + +- name: "Cancellation emails" + area: communications + display_status: public_facing + released_on: 2026-03-01 + action_path: "/events" + pr_number: 1287 + summary: >- + Cancelling an event registration sends the registrant a cancellation email. + +- name: "Lock and unlock user accounts" + area: people + display_status: admin_facing + released_on: 2026-01-23 + action_path: "/users/1" + pr_number: 760 + summary: >- + Admins can lock or unlock a user's account from the edit screen. + +- name: "Drag-and-drop category ordering" + area: content + display_status: admin_facing + released_on: 2026-01-25 + action_path: "/categories" + pr_number: 712 + summary: >- + Reorder categories within a category type by dragging them. + +- name: "Video gallery" + area: content + display_status: user_facing + released_on: 2026-03-09 + action_path: "/video_recordings" + pr_number: 1228 + summary: >- + A gallery of video recordings, flagged as instructional or podcast, that + facilitators can browse and bookmark. + +- name: "@mentions in rich text" + area: content + display_status: admin_facing + released_on: 2026-01-15 + action_path: "/workshops" + pr_number: 662 + summary: >- + Mention people and records inside rich-text fields, with links back to what you + referenced. + +- name: "Rich text everywhere" + area: content + display_status: admin_facing + released_on: 2026-03-01 + action_path: "/workshops" + pr_number: 1297 + summary: >- + Content across the portal uses the Rhino rich-text editor with images, links, + grids, and formatting. + +# ── 2025 (foundations) ────────────────────────────────────────────────────── + +- name: "Workshop library" + area: content + display_status: user_facing + released_on: 2025-09-13 + action_path: "/workshops" + summary: >- + The core library of AWBW workshops with rich descriptions, categories, sectors, + and variations. + +- name: "Workshop series" + area: content + display_status: user_facing + released_on: 2025-09-17 + action_path: "/workshops" + summary: >- + Group related workshops as a series, shown as parent and member workshops. + +- name: "Workshop variations" + area: content + display_status: user_facing + released_on: 2025-09-20 + action_path: "/workshop_variations" + summary: >- + Facilitator-contributed variations on a workshop, with their own images and + videos. + +- name: "Resource library" + area: content + display_status: user_facing + released_on: 2025-09-13 + action_path: "/resources" + summary: >- + Downloadable handouts, toolkits, and templates that facilitators can search and + save. + +- name: "Bookmarks" + area: content + display_status: user_facing + released_on: 2025-09-23 + action_path: "/bookmarks/personal" + summary: >- + Bookmark workshops, resources, events, and more, then sort and filter your + saved items. + +- name: "Keyword search and filtering across libraries" + area: content + display_status: user_facing + released_on: 2025-10-25 + action_path: "/workshops" + summary: >- + Fast keyword search and filtering across workshops, resources, and other + libraries. + +- name: "Workshop logs" + area: content + display_status: user_facing + released_on: 2025-09-27 + action_path: "/workshop_logs" + summary: >- + Facilitators log the workshops they lead, with attendance and reflection fields. + +- name: "Workshop ideas" + area: content + display_status: admin_facing + released_on: 2025-11-06 + action_path: "/workshop_ideas" + summary: >- + Facilitators submit workshop ideas that admins can review and promote into full + workshops. + +- name: "Workshop variation ideas" + area: content + display_status: user_facing + released_on: 2026-02-09 + action_path: "/workshop_variation_ideas" + pr_number: 871 + summary: >- + Facilitators submit ideas for workshop variations, promotable into published + variations. + +- name: "FAQ and Help section" + area: content + display_status: user_facing + released_on: 2025-09-23 + action_path: "/faqs" + summary: >- + A searchable, accordion-style FAQ and Help section, with reorderable, inline- + editable questions. + +- name: "Quotes" + area: content + display_status: admin_facing + released_on: 2025-11-26 + action_path: "/quotes" + summary: >- + Curate participant quotes and surface them alongside workshop logs. + +- name: "Tags, categories, and sectors" + area: content + display_status: admin_facing + released_on: 2025-12-22 + action_path: "/tags" + summary: >- + A tagging system of categories and sectors, with admin CRUD, that organizes + content across the portal. + +- name: "Windows audiences" + area: content + display_status: admin_facing + released_on: 2025-11-27 + action_path: "/windows_types" + pr_number: 515 + summary: >- + Classify content by Windows audience type for filtering and display. + +- name: "Events" + area: events + display_status: public_facing + released_on: 2025-09-13 + action_path: "/events" + summary: >- + Create and publish events with public event pages and registrations. + +- name: "Calendar links on events" + area: events + display_status: public_facing + released_on: 2025-11-19 + action_path: "/events/1" + summary: >- + Add-to-calendar links and location/zoom details on event pages. + +- name: "Social share on event pages" + area: events + display_status: public_facing + released_on: 2026-02-21 + action_path: "/events/1" + pr_number: 1144 + summary: >- + A social-share sidebar lets visitors share an event page. + +- name: "Organizations" + area: people + display_status: admin_facing + released_on: 2025-09-13 + action_path: "/organizations" + summary: >- + Organization records with affiliations, addresses, logos, and profile details. + +- name: "Person profiles" + area: people + display_status: admin_facing + released_on: 2025-11-08 + action_path: "/people/1" + summary: >- + Rich profiles for the people in AWBW's network, with contact info, sectors, and + affiliations. + +- name: "Affiliations between people and organizations" + area: people + display_status: admin_facing + released_on: 2026-02-16 + action_path: "/people/1" + summary: >- + Connect people to organizations with titles and date ranges, distinguishing + facilitator affiliations. + +- name: "Organization addresses" + area: people + display_status: admin_facing + released_on: 2025-11-16 + action_path: "/organizations/1" + summary: >- + Organizations hold multiple structured addresses instead of a single set of + fields. + +- name: "User accounts" + area: people + display_status: admin_facing + released_on: 2025-09-13 + action_path: "/users" + summary: >- + Manage portal login accounts, tied to people, with confirmation and invite + states. + +- name: "Login and password reset" + area: people + display_status: public_facing + released_on: 2025-09-22 + action_path: "/users/sign_in" + summary: >- + Secure login with a clear password reset and change flow. + +- name: "Stories" + area: stories + display_status: public_facing + released_on: 2025-11-06 + action_path: "/stories" + summary: >- + Editorial stories with facilitators, images, and links back to the public site. + +- name: "Story ideas" + area: stories + display_status: user_facing + released_on: 2025-11-02 + action_path: "/story_ideas" + summary: >- + Facilitators submit story ideas that admins can promote into published stories. + +- name: "Community news" + area: stories + display_status: public_facing + released_on: 2025-11-12 + action_path: "/community_news" + summary: >- + Post and browse community news with images and rich text. + +- name: "Contact us" + area: communications + display_status: public_facing + released_on: 2025-11-07 + action_path: "/contact_us" + summary: >- + A contact form that emails AWBW and sets expectations for a reply. + +- name: "Site banners" + area: communications + display_status: admin_facing + released_on: 2025-11-07 + action_path: "/banners" + summary: >- + Admins publish banners with sanitized content across the portal. + +- name: "Communications and notifications system" + area: communications + display_status: admin_facing + released_on: 2026-01-18 + action_path: "/communications" + summary: >- + A unified system that sends, tracks, and records the portal's emails and + notifications, with delivery status. + +- name: "Home dashboard" + area: content + display_status: user_facing + released_on: 2025-09-28 + action_path: "/" + summary: >- + A home dashboard surfacing featured workshops, stories, news, and events for + signed-in facilitators. + +- name: "Featured content on the dashboard" + area: content + display_status: admin_facing + released_on: 2025-11-18 + action_path: "/" + summary: >- + Flag workshops, stories, and other content as featured to intentionally place + it on the home dashboard. + +- name: "Printable stories and news" + area: stories + display_status: user_facing + released_on: 2026-02-04 + action_path: "/stories/1" + pr_number: 738 + summary: >- + Stories and community news have print-friendly layouts. + +- name: "Recent activity feed" + area: reporting + display_status: admin_facing + released_on: 2025-11-08 + action_path: "/admin" + summary: >- + A universal recent-activity feed showing the latest content changes across the + portal. diff --git a/config/routes.rb b/config/routes.rb index ba71f55ff..bfa5e99b9 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 000000000..e17f13732 --- /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/migrate/20260812143317_add_action_path_to_features.rb b/db/migrate/20260812143317_add_action_path_to_features.rb new file mode 100644 index 000000000..64156630f --- /dev/null +++ b/db/migrate/20260812143317_add_action_path_to_features.rb @@ -0,0 +1,5 @@ +class AddActionPathToFeatures < ActiveRecord::Migration[8.1] + def change + add_column :features, :action_path, :string + end +end diff --git a/db/migrate/20260812143721_add_pr_number_to_features.rb b/db/migrate/20260812143721_add_pr_number_to_features.rb new file mode 100644 index 000000000..5ab934945 --- /dev/null +++ b/db/migrate/20260812143721_add_pr_number_to_features.rb @@ -0,0 +1,5 @@ +class AddPrNumberToFeatures < ActiveRecord::Migration[8.1] + def change + add_column :features, :pr_number, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 2ee74f8a9..4896041e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_12_015152) do +ActiveRecord::Schema[8.1].define(version: 2026_08_12_143721) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -594,6 +594,24 @@ t.index ["published"], name: "index_faqs_on_published" end + create_table "features", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "action_path" + 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.integer "pr_number" + 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/decorators/feature_decorator_spec.rb b/spec/decorators/feature_decorator_spec.rb new file mode 100644 index 000000000..90343e598 --- /dev/null +++ b/spec/decorators/feature_decorator_spec.rb @@ -0,0 +1,88 @@ +require "rails_helper" + +RSpec.describe FeatureDecorator do + subject(:decorated) { feature.decorate } + + let(:feature) do + build(:feature, :with_pro_tips, + name: "Roster slider", + area: "registration", + display_status: "admin_facing", + summary: "Mark certificates from the roster.", + released_on: Date.new(2026, 8, 9)) + end + + it "labels the area and audience" do + expect(decorated.area_label).to eq("Registration & tickets") + expect(decorated.status_label).to eq("Admin-facing") + 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) + .to eq("https://github.com/rubyforgood/awbw/pull/2170") + end + + it "is nil without a pr_number" do + expect(build(:feature, pr_number: nil).decorate.pr_url).to be_nil + end + end + + it "formats the release date and an ISO date for the JS filter" do + expect(decorated.released_label).to eq("Aug 9, 2026") + expect(decorated.released_iso).to eq("2026-08-09") + end + + it "builds a lowercased search haystack from name, summary, tips, area, and audience" do + text = decorated.search_text + expect(text).to include("roster slider") + expect(text).to include("mark certificates") + expect(text).to include("first tip") + expect(text).to include("registration & tickets") + expect(text).to include("admin-facing") + end + + it "falls back to a neutral area for an unknown key" do + unknown = build(:feature).decorate + allow(unknown).to receive(:area).and_return("mystery") + expect(unknown.area_label).to eq("More") + end + + describe "badges" do + it "renders the area badge with its icon and label" do + html = decorated.area_badge + expect(html).to include("fa-ticket") + expect(html).to include("Registration & tickets") + end + + it "renders the audience badge with its icon and label" do + html = decorated.status_badge + expect(html).to include("fa-lock") + expect(html).to include("Admin-facing") + end + end +end diff --git a/spec/factories/features.rb b/spec/factories/features.rb new file mode 100644 index 000000000..0882fd3f1 --- /dev/null +++ b/spec/factories/features.rb @@ -0,0 +1,34 @@ +FactoryBot.define do + factory :feature do + sequence(:name) { |n| "Feature #{n}" } + area { "events" } + display_status { "user_facing" } + summary { "A short, plain-language summary of the feature." } + released_on { Date.new(2026, 8, 1) } + published { true } + + trait :unpublished do + published { false } + end + + trait :admin_facing do + display_status { "admin_facing" } + end + + trait :public_facing do + display_status { "public_facing" } + end + + trait :with_pro_tips do + pro_tips { "First tip\nSecond tip" } + end + + trait :with_description do + rhino_description { "

Full write-up with a screenshot.

" } + end + + trait :with_external_url do + external_url { "https://docs.example.com/feature" } + end + end +end diff --git a/spec/models/feature_spec.rb b/spec/models/feature_spec.rb new file mode 100644 index 000000000..2b881c835 --- /dev/null +++ b/spec/models/feature_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" + +RSpec.describe Feature, type: :model do + describe "validations" do + subject { build(:feature) } + + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:summary) } + it { is_expected.to validate_presence_of(:released_on) } + it { is_expected.to validate_inclusion_of(:area).in_array(Feature::AREA_KEYS) } + it { is_expected.to validate_inclusion_of(:display_status).in_array(Feature::DISPLAY_STATUS_KEYS) } + + it "rejects an area outside the taxonomy" do + expect(build(:feature, area: "nonsense")).not_to be_valid + end + + it "allows a blank external_url" do + expect(build(:feature, external_url: "")).to be_valid + end + + it "rejects a non-http external_url" do + expect(build(:feature, external_url: "javascript:alert(1)")).not_to be_valid + end + + it "accepts an https external_url" do + expect(build(:feature, external_url: "https://docs.example.com/x")).to be_valid + end + + it "accepts a relative action_path" do + expect(build(:feature, action_path: "/events")).to be_valid + end + + it "rejects an action_path that is neither a path nor a URL" do + expect(build(:feature, action_path: "events")).not_to be_valid + end + + it "rejects a non-positive pr_number" do + expect(build(:feature, pr_number: 0)).not_to be_valid + expect(build(:feature, pr_number: 2170)).to be_valid + expect(build(:feature, pr_number: nil)).to be_valid + end + end + + describe "#pro_tips_list" do + it "splits newline-separated tips and drops blanks" do + feature = build(:feature, pro_tips: "One\n\n Two \n") + expect(feature.pro_tips_list).to eq([ "One", "Two" ]) + end + + it "is empty when there are no tips" do + expect(build(:feature, pro_tips: nil).pro_tips_list).to eq([]) + end + end + + describe "#admin_only?" do + it "is true only for admin-facing features" do + expect(build(:feature, :admin_facing)).to be_admin_only + expect(build(:feature, :public_facing)).not_to be_admin_only + expect(build(:feature)).not_to be_admin_only + end + end + + describe "scopes" do + let!(:published) { create(:feature, released_on: Date.new(2026, 1, 1)) } + let!(:unpublished) { create(:feature, :unpublished, released_on: Date.new(2026, 6, 1)) } + let!(:admin_only) { create(:feature, :admin_facing, released_on: Date.new(2026, 3, 1)) } + + it ".published returns only published features" do + expect(Feature.published).to contain_exactly(published, admin_only) + end + + it ".readable_by_non_admins excludes admin-facing features" do + expect(Feature.readable_by_non_admins).to contain_exactly(published, unpublished) + end + + it ".by_release orders newest first" do + expect(Feature.by_release.first).to eq(unpublished) + expect(Feature.by_release.last).to eq(published) + end + end +end diff --git a/spec/policies/feature_policy_spec.rb b/spec/policies/feature_policy_spec.rb new file mode 100644 index 000000000..4ba9918f1 --- /dev/null +++ b/spec/policies/feature_policy_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" + +RSpec.describe FeaturePolicy, type: :policy do + let(:admin_user) { build_stubbed :user, :admin } + let(:regular_user) { build_stubbed :user } + + let(:published_user_facing) { build_stubbed :feature, published: true, display_status: "user_facing" } + let(:published_admin_facing) { build_stubbed :feature, published: true, display_status: "admin_facing" } + let(:unpublished) { build_stubbed :feature, published: false, display_status: "user_facing" } + + def policy_for(record: nil, user:) + described_class.new(record, user: user) + end + + describe "#index?" do + it { expect(policy_for(user: admin_user)).to be_allowed_to(:index?) } + it { expect(policy_for(user: regular_user)).to be_allowed_to(:index?) } + it { expect(policy_for(user: nil)).not_to be_allowed_to(:index?) } + end + + describe "#show?" do + it "lets a regular user see a published, non-admin-facing feature" do + expect(policy_for(record: published_user_facing, user: regular_user)).to be_allowed_to(:show?) + end + + it "hides an admin-facing feature from a regular user" do + expect(policy_for(record: published_admin_facing, user: regular_user)).not_to be_allowed_to(:show?) + end + + it "hides an unpublished feature from a regular user" do + expect(policy_for(record: unpublished, user: regular_user)).not_to be_allowed_to(:show?) + end + + it "lets an admin see anything" do + expect(policy_for(record: published_admin_facing, user: admin_user)).to be_allowed_to(:show?) + expect(policy_for(record: unpublished, user: admin_user)).to be_allowed_to(:show?) + end + + it "denies a logged-out visitor" do + expect(policy_for(record: published_user_facing, user: nil)).not_to be_allowed_to(:show?) + end + end + + describe "manage rules (create/update/destroy)" do + %i[create? update? destroy?].each do |rule| + it "allows only admins for #{rule}" do + record = build_stubbed(:feature) + expect(policy_for(record: record, user: admin_user)).to be_allowed_to(rule) + expect(policy_for(record: record, user: regular_user)).not_to be_allowed_to(rule) + end + end + end + + describe "relation scope" do + let!(:published_user_facing) { create(:feature, published: true, display_status: "user_facing") } + let!(:published_admin_facing) { create(:feature, published: true, display_status: "admin_facing") } + let!(:draft) { create(:feature, published: false, display_status: "user_facing") } + + it "gives admins everything" do + scope = described_class.new(nil, user: admin_user).apply_scope(Feature.all, type: :active_record_relation) + expect(scope).to contain_exactly(published_user_facing, published_admin_facing, draft) + end + + it "gives regular users only published, non-admin-facing features" do + scope = described_class.new(nil, user: regular_user).apply_scope(Feature.all, type: :active_record_relation) + expect(scope).to contain_exactly(published_user_facing) + end + + it "gives logged-out visitors nothing" do + scope = described_class.new(nil, user: nil).apply_scope(Feature.all, type: :active_record_relation) + expect(scope).to be_empty + end + end +end diff --git a/spec/requests/features_spec.rb b/spec/requests/features_spec.rb new file mode 100644 index 000000000..f0dd684a6 --- /dev/null +++ b/spec/requests/features_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +RSpec.describe "/features", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + + let!(:user_facing) { create(:feature, name: "Facilitator feature", display_status: "user_facing", published: true) } + let!(:admin_facing) { create(:feature, name: "Admin-only feature", display_status: "admin_facing", published: true) } + let!(:draft) { create(:feature, name: "Draft feature", display_status: "user_facing", published: false) } + + describe "GET /features" do + it "redirects a logged-out visitor to sign in" do + get features_path + expect(response).to redirect_to(new_user_session_path) + end + + context "as a regular user" do + before { sign_in regular_user } + + it "shows published, non-admin-facing features only" do + get features_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("Facilitator feature") + expect(response.body).not_to include("Admin-only feature") + expect(response.body).not_to include("Draft feature") + end + + it "does not show admin actions" do + get features_path + expect(response.body).not_to include("Sync latest updates") + expect(response.body).not_to include("New feature") + end + end + + context "as an admin" do + before { sign_in admin } + + it "shows every feature and the admin actions" do + get features_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("Facilitator feature") + expect(response.body).to include("Admin-only feature") + expect(response.body).to include("Draft feature") + expect(response.body).to include("Sync latest updates") + expect(response.body).to include("New feature") + end + end + end + + describe "GET /features/:id" do + context "as a regular user" do + before { sign_in regular_user } + + it "shows a published, non-admin-facing feature" do + get feature_path(user_facing) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Facilitator feature") + end + + it "blocks an admin-facing feature" do + get feature_path(admin_facing) + expect(response).to redirect_to(root_path) + end + end + + context "as an admin" do + before { sign_in admin } + + it "shows an admin-facing feature" do + get feature_path(admin_facing) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Admin-only feature") + end + end + end + + describe "POST /features" do + let(:valid_attributes) do + { name: "Brand new", area: "events", display_status: "user_facing", + summary: "Something useful.", released_on: "2026-08-11" } + end + + it "lets an admin create a feature" do + sign_in admin + expect { post features_path, params: { feature: valid_attributes } }.to change(Feature, :count).by(1) + expect(response).to redirect_to(Feature.find_by(name: "Brand new")) + end + + it "blocks a regular user" do + sign_in regular_user + expect { post features_path, params: { feature: valid_attributes } }.not_to change(Feature, :count) + expect(response).to redirect_to(root_path) + end + end + + describe "POST /features/import" do + it "hydrates missing features from the seed for an admin" do + sign_in admin + expect { post import_features_path }.to change(Feature, :count) + expect(response).to redirect_to(features_path) + end + + it "blocks a regular user" do + sign_in regular_user + expect { post import_features_path }.not_to change(Feature, :count) + expect(response).to redirect_to(root_path) + end + end +end diff --git a/spec/routing/features_routing_spec.rb b/spec/routing/features_routing_spec.rb new file mode 100644 index 000000000..1e6194441 --- /dev/null +++ b/spec/routing/features_routing_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe FeaturesController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/features").to route_to("features#index") + end + + it "routes to #new" do + expect(get: "/features/new").to route_to("features#new") + end + + it "routes to #show" do + expect(get: "/features/1").to route_to("features#show", id: "1") + end + + it "routes to #edit" do + expect(get: "/features/1/edit").to route_to("features#edit", id: "1") + end + + it "routes to #create" do + expect(post: "/features").to route_to("features#create") + end + + it "routes to #update via PATCH" do + expect(patch: "/features/1").to route_to("features#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/features/1").to route_to("features#destroy", id: "1") + end + + it "routes the import collection action" do + expect(post: "/features/import").to route_to("features#import") + end + end +end diff --git a/spec/services/feature_catalog_spec.rb b/spec/services/feature_catalog_spec.rb new file mode 100644 index 000000000..2b9079c7d --- /dev/null +++ b/spec/services/feature_catalog_spec.rb @@ -0,0 +1,128 @@ +require "rails_helper" + +RSpec.describe FeatureCatalog do + describe "#import!" do + let(:seed_path) { Rails.root.join("spec/fixtures/files/features_seed.yml") } + + before do + File.write(seed_path, <<~YAML) + - name: "Seed feature one" + area: events + display_status: user_facing + summary: "First seeded feature." + released_on: 2026-08-01 + action_path: "/events" + pr_number: 1234 + pro_tips: + - "Tip A" + - "Tip B" + external_url: "https://docs.example.com/one" + description: "

Longer write-up.

" + - name: "Seed feature two" + area: scholarships + display_status: admin_facing + summary: "Second seeded feature." + released_on: 2026-08-05 + YAML + end + + after { File.delete(seed_path) if File.exist?(seed_path) } + + subject(:catalog) { described_class.new(path: seed_path) } + + it "creates a Feature per seed entry" do + expect { catalog.import! }.to change(Feature, :count).by(2) + end + + it "reports how many were created and updated" do + result = catalog.import! + expect(result.created).to eq(2) + expect(result.updated).to eq(0) + expect(result.total).to eq(2) + end + + it "maps every field, joining pro_tips with newlines" do + catalog.import! + feature = Feature.find_by(name: "Seed feature one") + + expect(feature).to have_attributes( + area: "events", + display_status: "user_facing", + summary: "First seeded feature.", + released_on: Date.new(2026, 8, 1), + external_url: "https://docs.example.com/one", + action_path: "/events", + pr_number: 1234, + published: true + ) + expect(feature.pro_tips_list).to eq([ "Tip A", "Tip B" ]) + expect(feature.rhino_description.to_plain_text).to include("Longer write-up") + end + + 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 content fields (missing info)" do + existing = create(:feature, name: "Seed feature one", summary: "Edited in-app", + 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 + ) + end + + it "is a no-op on a second run" do + catalog.import! + expect { catalog.import! }.not_to change(Feature, :count) + expect(catalog.import!.any?).to be(false) + end + end + + describe "the checked-in seed (config/features.yml)" do + subject(:entries) { described_class.new.entries } + + it "is a non-empty list" do + expect(entries).to be_an(Array) + expect(entries).not_to be_empty + end + + it "has every required field with a valid area and audience" do + entries.each do |entry| + expect(entry["name"]).to be_present, "missing name: #{entry.inspect}" + expect(entry["summary"]).to be_present, "missing summary for #{entry['name']}" + expect(entry["released_on"]).to be_a(Date), "released_on must be a date for #{entry['name']}" + expect(Feature::AREA_KEYS).to include(entry["area"]), + "unknown area '#{entry['area']}' for #{entry['name']}" + expect(Feature::DISPLAY_STATUS_KEYS).to include(entry["display_status"]), + "unknown display_status '#{entry['display_status']}' for #{entry['name']}" + end + end + + it "produces valid Feature records when imported" do + expect { described_class.new.import! }.not_to raise_error + expect(Feature.all).to all(be_valid) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 81b86df70..2285fb2ae 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",