Skip to content
Draft
26 changes: 26 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 `<select>` (via `data-show-when` value match, or `data-show-when-attr` reading a `data-*` flag on the selected option β€” e.g. the subscription form's event field appears only for event-oriented topics)
- `grant_details` β€” Swaps a grant's eligibility criteria + tasks when the grant picker changes
Expand Down
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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

Expand Down Expand Up @@ -235,6 +236,39 @@ 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) so facilitators and admins can
see what the portal does. It is **database-backed** (`Feature` model) and edited
in-app by super-admins β€” the rich `description` uses the Rhino WYSIWYG (so pages
can carry screenshots), and each feature can link an external process doc.

**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` (longer HTML/text), `external_url`, `action_path` (in-app path for
the detail page's "Check out this feature" link), and `pr_number` (adds a
GitHub PR link).
- **Sentence case, plain language** β€” this page is read by facilitators, not devs.
- `admin_facing` features are visible to super-admins only (`FeaturePolicy` gates
this in its relation scope, not just in the UI).

An admin clicks **Sync latest updates** on `/features` (`FeatureCatalog#import!`,
matched by `name`) to pull newly-shipped features from the seed, **re-align the
catalog-owned classification** on existing records (`CATALOG_FIELDS` β€” area,
`display_status`, `released_on`, `action_path`, `pr_number`, so a seed fix like a
wrong audience propagates), **and fill in blank content** (`CONTENT_FIELDS` β€”
`summary`, `pro_tips`, `external_url`, `description`) without ever overwriting what
an admin wrote.

- **New area?** Add it to `Feature::AREAS` (label + Font Awesome icon + a Tailwind
hue already safelisted in `application.tailwind.css`) and use its key in the
seed. Area/audience presentation (badges/labels) lives on `FeatureDecorator`.

## JavaScript

- ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`)
Expand Down
85 changes: 85 additions & 0 deletions app/controllers/features_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 "Sync latest updates" button: add any newly-shipped features from
# config/features.yml and fill in blank fields on existing ones (never
# overwrites details an admin has already filled in).
def import
authorize! Feature, to: :create?
result = FeatureCatalog.new.import!

notice = if result.any?
parts = []
parts << "added #{result.created}" if result.created.positive?
parts << "filled in #{result.updated}" if result.updated.positive?
"Latest updates synced β€” #{parts.join(', ')} #{'feature'.pluralize(result.total)}."
else
"You're all caught up β€” no new updates."
end
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, :action_path, :pr_number, :released_on, :published, :rhino_description
)
end
end
96 changes: 96 additions & 0 deletions app/decorators/feature_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 area_color
area_meta[:color]
end

GITHUB_REPO = "rubyforgood/awbw".freeze

# Link to the GitHub PR the feature shipped in, or nil when unknown.
def pr_url
return if pr_number.blank?

"https://github.com/#{GITHUB_REPO}/pull/#{pr_number}"
end

# The "Check out this feature" destination. Record-scoped pages are seeded with
# the sample id 1 (e.g. /events/1/registrants); when no such record exists we
# fall back to that resource's index (/events) so the link never 404s on a
# fresh or differently-keyed database.
def resolved_action_url
path = action_path.to_s
return action_path if path.blank?

match = path.match(%r{\A/(?<resource>[a-z_]+)/1(?:/|\z)})
return action_path unless match

model = match[:resource].classify.safe_constantize
return action_path if model.respond_to?(:exists?) && model.exists?(1)

"/#{match[:resource]}"
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
Loading