diff --git a/.allow_skipping_tests b/.allow_skipping_tests index e69de29bb2..d4c169f8f7 100644 --- a/.allow_skipping_tests +++ b/.allow_skipping_tests @@ -0,0 +1,12 @@ +# App files intentionally without a 1:1 spec (checked by `rake test_checker` / the spec_checker CI job). +# Devise controller subclasses are thin overrides; the casa_app helpers/services/concern below are +# exercised indirectly by view / system / decorator specs rather than a dedicated unit spec. +controllers/all_casa_admins/invitations_controller.rb +controllers/all_casa_admins/passwords_controller.rb +controllers/users/confirmations_controller.rb +helpers/metrics_helper.rb +helpers/table_helper.rb +models/concerns/name_presentation.rb +services/admin_dashboard.rb +services/volunteer_dashboard.rb + diff --git a/.gitignore b/.gitignore index abb7c9f533..1d4113633d 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ npm-debug.log # In case we want to keep something in the compiled assets directory !/app/assets/builds/.keep .aider* + +# Personal AI working notes (Claude Code local memory) +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md index 355760a668..e17914ad3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,10 @@ Hotwire-first: Turbo for navigation/forms, Stimulus controllers in `app/javascri ## Style This project uses **Standard.rb** (not vanilla RuboCop). Don't fight it on spacing/quotes/trailing commas. There is also a `.standard_todo.yml` of grandfathered violations — leave older files alone unless touching them substantively. Older migration files (2020–2024) are intentionally excluded from linting. +## Design system (casadesign branch) + +This branch introduces a Tailwind CSS v4 design system, documented in **`design.md`** (repo root) — the source of truth for the new aesthetic. Read it before UI work and keep it updated. New pages use Tailwind-only layouts and coexist with the legacy Bootstrap UI (migrate page-by-page). **On every page, show user names without honorific prefixes (first + last only) — `display_person` for new UI, `formatted_name` at existing `.display_name` sites — never mutate the stored `display_name`.** **Workflow: on `casadesign`, commit and push to the `casadesign` branch at every checkpoint (do not wait for approval).** **Buttons come from the `button_classes(:primary|:secondary|:danger|:danger_outline|:success)` helper (one shared 40px height token, never hand-written class strings; before finishing UI work, grep the touched views for clickable elements with a hand-rolled button shape (`inline-flex` + `rounded-lg` + `px-`/`py-` + `bg-`/`border-`) and convert them to `button_classes` (the tertiary ghost is the only documented exception): this drift, a bespoke string at a different height next to the 40px token, is a recurring bug). Section headings and subtitles are sentence case with **no trailing colon** (colons only on inline key:value fact labels; audit `grep ']*>[^<]*:` + the `dropdown` controller): keep core actions visible and overflow only occasional ones, never bury a core action (also breaks non-JS specs, which can't open `
`). On mobile, collapse the remaining visible secondaries into `More` too (render twice with `hidden sm:contents` / `sm:hidden`) so only the primary CTA and `More` share the top line. Leading icons on menu/list items top-align to the first line (`items-start`), never centered against wrapped text. When you build and verify a component or pattern, document it in `design.md` immediately (do not wait for approval) and reuse it from there: `design.md` is the source of truth, so match it and never re-derive or let it drift. Before finishing a new or changed component, **check it against `design.md` and its sibling components and pixel-verify the match** (e.g. the multiselect chevron must match the single-select chevron, measured via screenshot + darkest-pixel, never eyeballed or trusted from computed style): rendering is not the same as matching. When a partial is still rendered by legacy Bootstrap pages (e.g. `shared/_manage_volunteers`, `shared/_court_order_list`), never restyle it in place: add a casa_app-specific Tailwind twin (`casa_cases/_volunteer_assignment`, `casa_cases/_court_orders`) that preserves every JS hook (ids, classes, data-actions, field names, DOM adjacencies) and leave the shared partial for the Bootstrap pages.** + ## Where to look for more - `.github/instructions/ruby.instructions.md` and `.github/instructions/copilot-review.instructions.md` — the project's own review checklist; the source of truth for "what is a bug-shaped change here." - `doc/architecture-decisions/` — ADRs (especially 0002 no-UI-signup, 0003 two-user-tables, 0006 few-controller-tests, 0007 inline-email-CSS). diff --git a/Gemfile b/Gemfile index fefe57ec0d..98291551e1 100644 --- a/Gemfile +++ b/Gemfile @@ -97,6 +97,7 @@ group :development do end group :test do + gem "axe-core-rspec" # Accessibility (axe-core) assertions for system specs gem "capybara" # Integration testing framework gem "capybara-screenshot" # Automatic screenshot on test failure gem "database_cleaner-active_record" # Database cleaning strategies for tests diff --git a/Gemfile.lock b/Gemfile.lock index 710d67f65e..0817f2d5df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,10 @@ GEM railties (>= 7.2) warden awesome_print (1.9.2) + axe-core-api (4.12.0) + dumb_delegator + axe-core-rspec (4.12.0) + axe-core-api (= 4.12.0) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) nokogiri (~> 1, >= 1.10.8) @@ -188,6 +192,7 @@ GEM request_store (>= 1.0) ruby2_keywords drb (2.2.3) + dumb_delegator (1.1.0) email_spec (2.3.1) htmlentities (~> 4.3) launchy (>= 2.1, < 4.0) @@ -705,6 +710,7 @@ DEPENDENCIES amazing_print annotaterb authtrail + axe-core-rspec azure-storage-blob blueprinter brakeman diff --git a/MIGRATION-MAP.md b/MIGRATION-MAP.md new file mode 100644 index 0000000000..fe0d8a68a9 --- /dev/null +++ b/MIGRATION-MAP.md @@ -0,0 +1,104 @@ +# Migration map — legacy Bootstrap → casadesign (Tailwind v4) + +**Status: every user-facing page is migrated _and reachable_.** (A reachability sweep found 6 pages that +were migrated but linked nowhere in the nav — all are now wired up; see *Reachability* under Loose ends.) +This map was built by inspecting the code (commit +`cbff2ed4e`), not by trusting the design.md checklist: each page-rendering controller resolves to a +Tailwind shell (class-level `layout "casa_app"`, an inline `render …, layout: "casa_app"`, or an +inherited/own Tailwind layout), and a scan for Bootstrap markers (`btn btn-`, `col-*`, `card-body`, +`data-bs-`, DataTables) surfaced only **dead (0-render) legacy files, mailers, and false positives** — +no live Bootstrap page. + +"Migrated" = renders on a Tailwind shell AND uses the design system (Tailwind utilities + +`button_classes`/`ghost_class`/`display_person`/`Dialog::`/Pagy), not Bootstrap classes or jQuery +DataTables. + +## Shells — the frame every page hangs in + +| Legacy (removed) | Redesign shell | Used by | +|---|---|---| +| `application.html.erb` (Bootstrap navbar + jQuery) | **`layouts/casa_app.html.erb`** — 256px sidebar + top bar | all signed-in chapter pages | +| Bootstrap auth | **`layouts/casa_auth.html.erb`** | sign-in, password reset, invitation accept (user + all-CASA) | +| Bootstrap all-CASA | **`layouts/all_casa_admin.html.erb`** | the all-CASA-admin console (inherited from `AllCasaAdminsController`) | +| — | self-contained Tailwind (`layout false`) | public `static#index` landing | +| — | **`layouts/error.html.erb`** | error page | + +## Page coverage — all migrated + +| Area | Pages | Shell | +|---|---|---| +| Auth | sign-in, forgot/reset password, invitation accept | casa_auth | +| App shell | sidebar, top bar, banners, flash, impersonation banner, notifications, edit profile | casa_app | +| Dashboards | volunteer, supervisor, admin (triage) | casa_app | +| Cases | index, show, new, edit; court dates + bulk; placements; emancipation checklist; court-report generator | casa_app | +| Case contacts | index, multi-step autosave form, drafts, followups, additional expenses, new-design table | casa_app | +| Volunteers | index (+ filter), edit, notes, manage cases/supervisor/active | casa_app | +| Supervisors | index (+ unassigned/assign type-ahead), edit | casa_app | +| Learning hours | index, form, volunteer/supervisor views | casa_app | +| Reimbursements | queue + complete toggle | casa_app | +| Reports | hub + CSV exports (learning-hours / followup / mileage / missing-data / placement / case-contact) | casa_app | +| Other duties | index / new / edit | casa_app | +| Org settings | casa_org edit + judges, languages, contact types/groups/topics, hearing types (+ checklist items), placement types, learning-hour types/topics, custom org links, mileage rates, banners | casa_app | +| Imports | volunteers / supervisors / cases + SMS opt-in | casa_app | +| Analytics | per-chapter charts (admin + supervisor) | casa_app | +| All-CASA admin | dashboard, casa_orgs, casa_admins, edit/new, patch notes, **Metrics** console | all_casa_admin | +| Public / ops | `static#index` landing (Tailwind); `error` page (Tailwind); `/health` (minimal ops status — intentionally not app UI) | own | + +**Non-page controllers** (no HTML views — nothing to migrate): case_assignments, supervisor_volunteers, +additional_expenses, contact_topic_answers, case_court_orders, preference_sets, android_app_associations, +the `*_reports` CSV exporters, case_contacts/followups (redirect / JSON / CSV only). + +## Element / pattern mapping (current → redesign) + +| Current (Bootstrap / jQuery) | Redesign (casadesign) | +|---|---| +| `.card` | `rounded-2xl border border-slate-200 bg-white p-5 shadow-sm` | +| `.btn .btn-primary/secondary/danger/success` | **`button_classes(:primary\|:secondary\|:danger\|:danger_outline\|:success)`** — one 40px (`h-10`) token | +| link/row actions (Edit, Delete, Detail view, Assign) | **`ghost_class`** (neutral) / **`ghost_class(:danger)`** (destructive: slate at rest, rose on hover) | +| jQuery **DataTables** (serverSide) | bespoke Tailwind `` + server-side filter scopes + `?sort=` + **Pagy** (`shared/_pagination`, in-card footer) | +| Bootstrap `.modal` / `Modal::*` / `data-bs-*` | **`Dialog::` ViewComponent** suite (native `` + `modal` Stimulus controller) | +| native `confirm()` / SweetAlert delete | **`shared/_confirm_button`** (Dialog-gated) | +| `.form-group` + `.form-control` | Tailwind inputs (`rounded-lg border-slate-300 px-3.5 py-2.5 …`) + `field_error` / `shared/_form_errors` | +| select2 / jQuery multiselect | **TomSelect** — `multiple-select`; searchable single-select `searchable-select` | +| Bootstrap grid `.row .col-*` | Tailwind `flex` / `grid grid-cols-*` | +| Bootstrap `.alert` flash | `shared/_flashes` + **`alert_classes`** | +| Bootstrap dropdown (`data-toggle`) | native `
` + `dropdown` controller (page-header **More** overflow) | +| honorific names (`.display_name`) | **`display_person`** (new UI) / **`formatted_name`** (legacy sites) — first + last only | +| in-content links | **`name_link_class`** (people: dark + underline) / **`record_link_class`** (records: brand) | + +## Loose ends (not blocking — tracked) + +- **Reachability — a real gap I initially missed** (page-level migration != reachable): a full sweep of + casa_app pages for inbound nav links found **6 orphaned pages** (migrated, but linked nowhere): + - **FIXED** — Mileage rates, Banners, Imports, Manage admins: now linked from **Settings → + Administration** (all return 200). + - **FIXED** — `other_duties`: now a **sidebar item** (gated by `policy(OtherDuty).index?`, which + includes the org's `other_duties_enabled` toggle; the controller sets `@active_nav`). + `emancipation_checklists#index`: a **volunteer-only link in the Cases header** + (`see_emancipation_checklist?` is volunteers-only, so it can't sit in the admin/supervisor More menu). + Both reachable + role-gated (verified). + - **All 6 orphans are now linked.** Everything else was already reachable (sidebar / the Settings + inline CRUD / case-child pages such as fund requests via the case show). **My earlier "all migrated" + checked Tailwind-on-a-shell, not navigability — the sweep + these fixes close that gap.** +- **Dead legacy files — DELETED** (verified 0 references across views / rb / js / specs; superseded by + `Dialog::` and the casa_app twins): `shared/_court_order_form`, `shared/_court_order_list`, + `casa_cases/_thank_you_modal`, `case_contacts/_confirm_note_content_dialog`, `layouts/_mobile_navbar`, + `devise/shared/_links`, `all_casa_admins/shared/_links`; and (2026-07-27) the legacy Bootstrap + **`Modal::*` suite** (Group/Header/Body/Footer/OpenButton/OpenLink) + **`DropdownMenuComponent`** + -- superseded by `Dialog::` and the native `
` + `dropdown` controller -- with their specs. +- **Footer — DELETED**: `layouts/footers/_logged_in` + `_not_logged_in` + `spec/.../footer.html.erb_spec.rb` + (rendered by no layout). Their links survive on the `all_casa_admin` layout footer + the `static` + landing. **Note:** `casa_app` (the main chapter app) has **no footer** — so the SMS Terms & Conditions + and Report-a-site-issue links aren't shown to signed-in chapter users. Pre-existing gap (the footer was + dropped, not migrated onto casa_app), worth a product call. +- **Emancipation — dead code, decision pending** (I mis-labelled this twice): `shared/_emancipation_link` + is rendered by `CasaCaseDecorator#transition_aged_youth`, which **no view calls** (only the decorator + spec). The migrated cases index built its **own** transition-aged column (a plain Yes/No violet pill) + and dropped the emancipation quick-link; the checklist is still reachable from the case **show** page. + So this is dead legacy code, not a displayed element. Options: **(a)** delete the dead cluster + (`_emancipation_link` + the `transition_aged_youth` method + its spec examples), or **(b)** re-add the + quick-link to the migrated index, design-system-styled. +- **Mailers are intentionally NOT part of the UI redesign** — emails use inline CSS (ADR 0007): + volunteer / supervisor / casa_admin / user / fund_request / learning_hours mailers + the devise mailer. +- **`[~]` help-link destination** — the one open item on the design.md checklist (app-shell leaf). +- **`/health`** — deliberately minimal ops status page, off the app shell by design (not a gap). diff --git a/Procfile.dev b/Procfile.dev index 9164cb4634..e9fcf00ccc 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ web: bin/rails server -p 3000 -b 0.0.0.0 js: npm run build:dev --watch -css: npm run build:css:dev --watch +tw: npm run build:tailwind:dev diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss deleted file mode 100644 index 76c756054f..0000000000 --- a/app/assets/stylesheets/application.scss +++ /dev/null @@ -1,47 +0,0 @@ -// links to stylesheets inside node_modules -@use "bootstrap/scss/bootstrap.scss" with ( - $primary: #00447c -); -@use "bootstrap-datepicker/dist/css/bootstrap-datepicker"; -@use "datatables.net-dt/css/jquery.dataTables"; -@use "@fortawesome/fontawesome-free/scss/fontawesome"; -@use "@fortawesome/fontawesome-free/scss/solid"; -@use "select2/dist/css/select2"; -@use "tom-select/dist/css/tom-select.bootstrap5.css"; - -// custom css for the app -@use "base/breakpoints"; - -@use "shared/notifier"; -@use "shared/dashboard"; -@use "shared/data_tables_overide"; -@use "shared/flashes"; -@use "shared/footer"; -@use "shared/form"; -@use "shared/layout"; -@use "shared/navbar"; -@use "shared/noscript"; -@use "shared/select2_additional_styles"; -@use "shared/sidebar"; -@use "shared/tomselect_additional_styles.scss"; -@use "shared/typography"; -@use "shared/utilities"; -@use "shared/validated_form_component"; -@use "shared/truncated_text_component"; - -@use "pages/all_casa_admin_dashboard"; -@use "pages/casa_cases"; -@use "pages/casa_org"; -@use "pages/case_contacts"; -@use "pages/case_contacts_form"; -@use "pages/court_dates"; -@use "pages/emancipation"; -@use "pages/login"; -@use "pages/password"; -@use "pages/patch_notes"; -@use "pages/reimbursements"; -@use "pages/supervisors"; -@use "pages/volunteers"; - -@use "trix/dist/trix.css"; -@use "actiontext"; diff --git a/app/assets/stylesheets/base/breakpoints.scss b/app/assets/stylesheets/base/breakpoints.scss deleted file mode 100644 index 180daab2ff..0000000000 --- a/app/assets/stylesheets/base/breakpoints.scss +++ /dev/null @@ -1,6 +0,0 @@ -$small: 640px; -$medium: 1024px; -$large: 1200px; - -// If you adjust this breakpoint, you also need to adjust the breakpoint value in app/javascript/controllers/sidebar-controller.js -$mobile: 768px; diff --git a/app/assets/stylesheets/base/variables.scss b/app/assets/stylesheets/base/variables.scss deleted file mode 100644 index f17900e95a..0000000000 --- a/app/assets/stylesheets/base/variables.scss +++ /dev/null @@ -1,26 +0,0 @@ -$primary: #00447c; -$red: #dc3545; - -// ======== Sidebar colors -$sidebar-inactive: #9AA4CA; -$sidebar-active: #365CF5; -$sidebar-dark: #1A2142; - -:root { - --casa-red: #D50100; - --casa-primary-blue: #4A6CF7; - - --gunmetal: #262D3F; - --dark-gunmetal: #212529; - --dark: #262d3f; - --dark-electric-blue: #5D657B; - --gray: #5d657b; - --dark-gray: #AAAAAA; - --gray-80: #CCCCCC; - --silver: #D9D9D9; - - --inactive: #9AA4CA; - - --input-placeholder-text: var(--gray); - --input-border-color: var(--gray-80); -} diff --git a/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss b/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss deleted file mode 100644 index 44d39c7adb..0000000000 --- a/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss +++ /dev/null @@ -1,41 +0,0 @@ -.dashboard, .all_casa_admins-dashboard { - .container { - @media (max-width: 1200px) { - max-width: 95%; - } - } - - .dashboard-table-header { - h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a { - display: inline-block; - } - } - - .cases { - background: lightgrey; - padding: 10px; - } - - .dropdown { - margin-left: 8px; - } - - .button { - border: 1px black solid; - padding: 10px; - background-color: #00447c; - } - - .button > a { - color: white; - &:visited { - color: white; - } - } -} diff --git a/app/assets/stylesheets/pages/casa_cases.scss b/app/assets/stylesheets/pages/casa_cases.scss deleted file mode 100644 index 1daaf9254b..0000000000 --- a/app/assets/stylesheets/pages/casa_cases.scss +++ /dev/null @@ -1,231 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -body.casa_cases { - .form-header { - h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a { - display: inline-block; - } - } - - .a2cldr { - width: 190px; - } - - .a2cldr .a2cldr-btn { - width: 190px; - text-align: center; - } - - .a2cldr-list { - position: absolute; - width: 100%; - } - .cal-btn { - display: inline-block; - } - - .court-date-row { - display: flex; - align-items: center; - gap: 10px; - - .court-date-link { - flex: 1; - } - - .cal-btn { - flex-shrink: 0; - - .a2cldr { - width: 120px !important; - - .a2cldr-btn { - width: 120px !important; - font-size: 12px; - padding: 4px 8px; - text-align: center; - } - } - } - } - - #cc-check { - p { - display: flex; - align-items: center; - } - - #with_cc { - margin-right: 5px; - } - } - - #reminder_button { - margin-left: 30px; - margin-bottom: 0; - } - - .case-contact-list { - margin-bottom: 20px; - } - - .datetime-year-month select.date-input { - width: 130px; - padding-bottom: 15px; - } - - .court-orders { - textarea { - display: block; - margin-bottom: 5px; - } - - textarea, .add-court-order { - width: 500px; - } - - .implementation-status { - align-self: flex-start; - min-height: 38px; - } - - .remove-court-order-button { - align-self: flex-start; - } - } - - .court-order-entry { - display: flex; - gap: 8px; - align-items: center; - } - - select { - padding: 5px; - } - - .emancipation-btn { - padding: 5px 10px; - font-size: 10px; - line-height: 16px; - vertical-align: middle; - } -} - -@media only screen and (max-width: screen-sizes.$small) { - body.casa_cases { - .court-orders { - textarea, .add-court-orders { - width: 100%; - } - } - - .court-order-entry { - display: grid; - grid-template-columns: minmax(100px, 100%) 1fr; - gap: 2px; - - .implementation-status { - grid-row: 2; - } - - margin: 1em 1em 2em 1em; - } - } -} - -body.casa_cases-show { - .past-court-dates.add-container { - display: block; - } -} - -#generate-docx-report-modal { - .dates-container { - display: flex; - } - - .docx-report__modal-body { - display: flex; - flex-direction: column; - row-gap: 1rem; - } - - .modal-footer { - [data-bs-dismiss="modal"] { - padding-block: 11px; - } - } -} - -.generate-report-button { - display: flex; - flex-direction: row; - width: 100%; - border: solid 1px var(--silver); - padding: 1.2rem; - column-gap: 1.2rem; - - &:hover { - border-color: black; - } - - svg { - min-inline-size: 40px; - width: 40px; - height: auto; - } - - div { - text-align: left; - h3 { - margin-block-end: .25rem; - } - p { - } - } -} - -.card-text { - ul { - display: flex; - flex-direction: column; - gap: .2rem; - } -} - -#case-selection:required, .select2.select2-container { - background: transparent; - border: 1px solid #e5e5e5; - border-radius: 10px; - padding: 8px; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; -} - -// removes select2 dropdown default border -span.select2-selection.select2-selection--single { - border: none; -} - -// removes select2 dropdown default arrow -.select2-selection__arrow { - display: none; -} - -.select-wrapper { - display: flex; - flex-direction: column; - gap: 8px; -} - -span[role=combobox] { - padding-top: 2px; -} diff --git a/app/assets/stylesheets/pages/casa_org.scss b/app/assets/stylesheets/pages/casa_org.scss deleted file mode 100644 index 103c5c3526..0000000000 --- a/app/assets/stylesheets/pages/casa_org.scss +++ /dev/null @@ -1,4 +0,0 @@ -#casa_org_logo{ - height: 2.7rem; - overflow: hidden; -} diff --git a/app/assets/stylesheets/pages/case_contacts.scss b/app/assets/stylesheets/pages/case_contacts.scss deleted file mode 100644 index 43ef3bc6b7..0000000000 --- a/app/assets/stylesheets/pages/case_contacts.scss +++ /dev/null @@ -1,181 +0,0 @@ -.slide-container { - padding: 0rem 1rem; -} - -.slider { - display: inline; -} - -.slider-row { - margin: 0px; -} - -.dataTables_scrollHeadInner { - width: 100% !important; -} - -.followup-button { - min-width: max-content; -} - -.flex-container { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; -} - -.field-card { - background-color: #fff; - background-clip: border-box; - border: 1px solid rgba(0,0,0,0.125); - border-radius: .25rem; - padding: 1rem; - box-shadow: 0 4px 12px 5px rgba(0, 0, 0, 0.08); -} - -.pr-6 { - padding-right: 1.5rem !important; -} - -.red-letter { - color: #d50100; - font-weight: 900; - display: inline; -} - -.label-font-weight { - font-weight: 900; -} - -.cc-field { - height: calc(1.5em + .75rem + 2px); - padding: .375rem .75rem; - font-size: 1rem; - line-height: 1.5; - color: #495057; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #ced4da; - border-radius: .25rem; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; -} - -.expense-container { - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: .5rem; - justify-content: flex-end; -} - -.expense-container { - & > *:last-child { - width: 80%; - }; - .field_with_errors { - input { - width: 100%; - } - } - } - -.other-expenses li { - margin-bottom: .2rem; -} - -.other-expense-amount { - width: 20%; - margin-left: .4rem; - color: #5D657B !important; -} - -.add-expense-link { - margin-top: -.5rem; - margin-bottom: .5rem; - display: block; - padding-left: .8rem; - text-decoration: underline; -} - -#add-another-expense { - cursor: pointer; -} - -.dollar-sign { - margin: 0px 10px 0 0; - display: flex; - align-items: center; - } - -.dollar-sign .other-expense-amount { - position: relative; - padding-left: 32px; -} - -.dollar-sign input::placeholder { - color: #5D657B; -} - -.dollar-sign:before { - position: absolute; - top: 17px; - content: "$"; - left: 28px; - z-index: 100; -} - -.pr-7 { - padding-right: 1.5rem !important; - padding-left: 2.5rem !important; -} - -.pr-9 { - padding-left: 3.1rem !important; -} - -.wide_button { - width: 100%; - height: 3rem; - font-weight: 900; -} - -.cc-italic { - font-style: italic; -} - -.expand-toggle { - display: inline-block; - cursor: pointer; - background: none; - border: 0; - padding: 0; - transition: transform 0.3s; - - &.expanded { - transform: rotate(180deg); - } -} - -.expanded-content { - padding: 0.75rem 1rem; - - .expanded-topic { - margin-bottom: 0.75rem; - - strong { - display: block; - } - - p { - margin: 0.25rem 0 0; - } - } -} - -.expanded-empty { - padding: 0.75rem 1rem; - margin: 0; -} diff --git a/app/assets/stylesheets/pages/case_contacts_form.scss b/app/assets/stylesheets/pages/case_contacts_form.scss deleted file mode 100644 index 5f0121391d..0000000000 --- a/app/assets/stylesheets/pages/case_contacts_form.scss +++ /dev/null @@ -1,151 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -#case-contact-form { - color: var(--gunmetal); - - & section { - background: var(--white, #FFFFFF); - border: 1px solid var(--form-element-stroke, #E5E5E5); - border-radius: 10px; - box-shadow: 0px 0px 5px 0px #1A252F0D; - margin-bottom: 30px; - padding: 30px 20px; - } - - & h2 { - font-size: 28px; - margin-bottom: 24px; - } - - & h3 { - font-size: 16px; - } - - & legend { - font-size: 16px; - font-weight: 400; - line-height: 19px; - color: var(--dark-electric-blue); - } - - & label { - font-size: 16px; - font-weight: 500; - line-height: 19px; - } - - & input, - textarea, - select { - border: 1px solid var(--input-border-color); - - &::placeholder { - color: var(--input-placeholder-text); - opacity: 1; - } - } - - & input[type="text"], - input[type="date"], - input[type="number"], - textarea, - select { - padding: 16px; - } - - & .form-check { - margin-left: 0px; - padding-left: 0px; - - & input { - width: 20px; - height: 20px; - margin-left: 0px; - margin-right: 3px; - float: none; - - &:checked { - background-color: var(--casa-primary-blue); - border-color: var(--casa-primary-blue); - } - - &[type="checkbox"] + label { - padding-top: 5px; - font-weight: 300; - } - - &[type="radio"] + label { - padding-top: 1px; - font-size: 16px; - font-weight: 300; - line-height: 25px; - color: var(--dark-electric-blue) - } - } - - label { - font-size: 16px; - font-weight: 600; - } - } - - & button { - --bs-link-color: var(--casa-primary-blue); - - &.btn-link { - text-decoration: none; - padding: 0px; - } - } - - & .row { - --bs-gutter-x: .5rem; - } - - & #contact-form-types { - --group-width: 250px; - - columns: var(--group-width); - - & fieldset { - display: inline-block; - width: var(--group-width); - margin-bottom: .25rem; - - & .form-check { - & label { - display: inline; - color: var(--dark); - font-weight: 400; - color: var(--dark-gunmetal); - } - - & small { - display: block; - margin-left: 28px; - font-style: italic; - font-weight: 300; - color: var(--dark-electric-blue); - } - } - } - } - - & .input-group-text { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - & #contact-form-action-buttons { - display: flex; - justify-content: flex-end; - gap: 10px; - - @media only screen and (max-width: screen-sizes.$mobile) { - & > a, & > button { - flex-grow: 1; - } - } - } -} - diff --git a/app/assets/stylesheets/pages/case_court_reports.scss b/app/assets/stylesheets/pages/case_court_reports.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/assets/stylesheets/pages/court_dates.scss b/app/assets/stylesheets/pages/court_dates.scss deleted file mode 100644 index 4d0299361c..0000000000 --- a/app/assets/stylesheets/pages/court_dates.scss +++ /dev/null @@ -1,22 +0,0 @@ -body.court_dates { - .court-orders { - textarea { - display: block; - margin-bottom: 5px; - } - - textarea, .add-court-order { - width: 500px; - } - } - - .court-order-entry { - display: flex; - gap: 8px; - align-items: center; - } - - select { - padding: 5px; - } -} diff --git a/app/assets/stylesheets/pages/emancipation.scss b/app/assets/stylesheets/pages/emancipation.scss deleted file mode 100644 index 7064864df2..0000000000 --- a/app/assets/stylesheets/pages/emancipation.scss +++ /dev/null @@ -1,82 +0,0 @@ -.category-options { - margin-left: 2em; - - input[type="checkbox"], input[type="radio"] { - pointer-events: none - } -} - -.check-item.disabled { - cursor: default; - - label { - color: #757575 - } -} - -.check-item { - label { - width: 80%; - vertical-align: middle; - } -} - -.emancipation-category { - border-bottom: 2px solid #bfe3ff; - margin: 0; - padding: 0 .5em; - display: flex; - justify-content: space-between; - align-items: center; - - input[type="checkbox"] { - pointer-events: none - } - - label { - margin: 0; - padding: .5em; - vertical-align: middle; - } - - span { - color: #00447c; - font-weight: bold; - font-size: 18pt - } -} - -.emancipation-category:hover { - background-color: #f5f5f5; - cursor: pointer; - - input[type="checkbox"], label{ - cursor: pointer - } -} - -.emancipation-category.disabled { - cursor: default; - - span, label{ - color: #757575 - } -} - -.emancipation-category.disabled:hover{ - background-color: unset -} - -.no-select { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Old versions of Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome, Edge, Opera and Firefox */ -} - -.emacipation-category-input-label-pair { - display: flex; -} diff --git a/app/assets/stylesheets/pages/feature_flags.scss b/app/assets/stylesheets/pages/feature_flags.scss deleted file mode 100644 index cc0fd1fef4..0000000000 --- a/app/assets/stylesheets/pages/feature_flags.scss +++ /dev/null @@ -1,9 +0,0 @@ -#feature-flags { - padding-top: 1em; - h1 { - padding-bottom: 1em; - } - .flag-name { - padding-right: 2em; - } -} \ No newline at end of file diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss deleted file mode 100644 index 0191146c8d..0000000000 --- a/app/assets/stylesheets/pages/login.scss +++ /dev/null @@ -1,17 +0,0 @@ -body.devise-sessions-new, body.users-sessions-new, body.devise-passwords-new { - .container { - max-width: none; - } - - .login-email-link { - font-size: small; - } - - aside { - background-position: right; - background-image: url("login.jpg"); - min-height: 700px; - background-repeat: no-repeat; - background-size: cover; - } -} diff --git a/app/assets/stylesheets/pages/password.scss b/app/assets/stylesheets/pages/password.scss deleted file mode 100644 index d9df2fba24..0000000000 --- a/app/assets/stylesheets/pages/password.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use "../base/variables.scss" as globals; - -.password-box { - width: 80%; - margin: 10% auto; - text-align: center; - border-radius: 25px; - border: 2px solid globals.$primary; - - form#edit_user.edit_user div.field { - margin: 0 auto; - width: 90%; - max-width: 90%; - flex: none; - } - - input#user_password { - margin: 10px 0 10px; - width: 100%; - } - - input#user_password_confirmation { - margin: 10px 0 10px; - width: 100%; - } - - form#edit_user.edit_user div.actions input { - color: #fff; - background-color: #002f56; - border-color: #002849; - margin: 10px; - } -} - -body.devise-invitations.devise-invitations-edit footer { - width: 100%; -} - -body.devise-passwords.devise-passwords-edit div.password-box { - div.row { - margin: 0 0; - - div.col-sm-6 { - flex: none; - margin: 0 auto; - max-width: 90%; - padding: 0; - } - } - - a.btn.btn-info { - margin-bottom: 10px; - } -} diff --git a/app/assets/stylesheets/pages/patch_notes.scss b/app/assets/stylesheets/pages/patch_notes.scss deleted file mode 100644 index 60d112b3d2..0000000000 --- a/app/assets/stylesheets/pages/patch_notes.scss +++ /dev/null @@ -1,53 +0,0 @@ -#patch-notes { - display: flex; - - #patch-note-list { - width: 50%; - - h1 { - text-align: left; - } - - .patch-note-list-item { - & .card-body>* { - margin: 0.25em 0; - } - - &.new { - background-color: #ffecb3; - } - - textarea { - width: 100%; - } - - .label-and-select { - display: flex; - - label { - padding-right: 0.5rem; - } - - select { - flex-grow: 1 - } - } - - .patch-note-button-controls { - display: flex; - - button { - flex-grow: 1; - - &:nth-child(2) { - margin-left: 0.5em - }; - - &.button-cancel { - flex-grow: 3 - } - } - } - }; - }; -} diff --git a/app/assets/stylesheets/pages/reimbursements.scss b/app/assets/stylesheets/pages/reimbursements.scss deleted file mode 100644 index f913149528..0000000000 --- a/app/assets/stylesheets/pages/reimbursements.scss +++ /dev/null @@ -1,9 +0,0 @@ -.reimbursements-index { - .select2-container { - .select2-selection { - max-height: 60px; - overflow-y: auto; - overflow-x: hidden; - } - } -} diff --git a/app/assets/stylesheets/pages/supervisors.scss b/app/assets/stylesheets/pages/supervisors.scss deleted file mode 100644 index f631eba28c..0000000000 --- a/app/assets/stylesheets/pages/supervisors.scss +++ /dev/null @@ -1,84 +0,0 @@ -$indictator_negative_color: #f44336; -$indicator_positive_color: #4caf50; - -.supervisor_case_contact_stats { - display: flex; - - div { - &:first-child { - display: flex; - margin-right: 1em; - flex-grow: 1; - } - - span { - border-radius: 0.25em; - padding-top: .25em; - text-align: center; - - &.attempted-contact { - background-color: $indicator_positive_color; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - - & + span.no-attempted-contact { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - & + span.attempted-contact-end { - background-color: $indicator_positive_color; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - width: 0.25em; - } - } - - &.no-attempted-contact { - background-color: $indictator_negative_color; - } - - &.no-volunteers { - background-color: #ccc; - } - - &:last-child { - margin-left: 0.5em; - background-color: #cfd8dc; - border-radius: 100vw; - padding: 0.2em 1em; - } - } - } - - &.legend { - align-items: center; - margin-bottom: 0.25rem; - margin-top: 1em; - - span { - &:last-child { - padding: .5em; - } - - padding: .5em 1em; - flex-grow: 1; - } - } -} - -.supervisor_table_row { - display: flex; - - td:nth-of-type(1), th:nth-of-type(1) { - flex: 3; - } - - td:nth-of-type(2), th:nth-of-type(2) { - flex: 3; - } - - td:nth-of-type(3), th:nth-of-type(3) { - flex: 1; - } -} diff --git a/app/assets/stylesheets/pages/volunteers.scss b/app/assets/stylesheets/pages/volunteers.scss deleted file mode 100644 index 4f5c8dd8c4..0000000000 --- a/app/assets/stylesheets/pages/volunteers.scss +++ /dev/null @@ -1,35 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -body.volunteers { - @media only screen and (max-width: screen-sizes.$mobile) { - table#volunteers { - tbody tr { - padding-bottom: 2.5rem; - border-bottom: none; - - td { - border-left: none; - border-right: none; - } - td:last-child { - border-bottom: none; - } - } - } - } -} -table#volunteers.dataTable.hover tbody tr.selected > * { - background-color: rgba(54, 92, 245, 0.1); - box-shadow: none; - color: inherit; -} -table#volunteers.dataTable.hover > tbody > tr.selected:hover > * { - background-color: rgba(54, 92, 245, 0.15); - box-shadow: none !important; - color: inherit; -} - -.form-check-input { - border: 1px solid #757575; -} - diff --git a/app/assets/stylesheets/shared/dashboard.scss b/app/assets/stylesheets/shared/dashboard.scss deleted file mode 100644 index fc6ff4896c..0000000000 --- a/app/assets/stylesheets/shared/dashboard.scss +++ /dev/null @@ -1,35 +0,0 @@ -.dashboard-table-header { - h1,h2,h3,h4,h5,h6 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a.btn { - display: inline-block; - } -} - -.dropdown-menu { - li { - input[type="checkbox"] { - transform: scale(1.5); - margin: 0 8px; - } - &:not(:last-child) { - margin-bottom: 7px; - } - } - padding: 8px; -} - -.supervisor-options { - min-width: 15rem; -} - -.language-icon { - cursor: context-menu; - font-size: 1.5rem; - display: flex; - justify-content: center; -} diff --git a/app/assets/stylesheets/shared/data_tables_overide.scss b/app/assets/stylesheets/shared/data_tables_overide.scss deleted file mode 100644 index abef82dab0..0000000000 --- a/app/assets/stylesheets/shared/data_tables_overide.scss +++ /dev/null @@ -1,11 +0,0 @@ -/** - Needed to display the prev, next, number, and ellipsis buttons on the same line. This causes issues on mobile with - lots of pages, but there is no current way to change the number of pages in a view or in JS based on window size. - */ -.dataTables_paginate { - white-space: nowrap !important; - - span { - display: inline-block !important; - } -} diff --git a/app/assets/stylesheets/shared/flashes.scss b/app/assets/stylesheets/shared/flashes.scss deleted file mode 100644 index dce6aede63..0000000000 --- a/app/assets/stylesheets/shared/flashes.scss +++ /dev/null @@ -1,10 +0,0 @@ -.notice { - text-align: center; - padding: 8px; - margin: 12px 0; - list-style-position: inside; -} - -.alert-dismissible .close { - padding: 0.5rem 0.75rem; -} diff --git a/app/assets/stylesheets/shared/footer.scss b/app/assets/stylesheets/shared/footer.scss deleted file mode 100644 index e3c5669073..0000000000 --- a/app/assets/stylesheets/shared/footer.scss +++ /dev/null @@ -1,95 +0,0 @@ -//@use "../base/variables" as globals; -//@use "../base/breakpoints.scss" as screen-sizes; -// -//footer { -// padding: 2rem 0; -// text-align: center; -// background-color: globals.$primary; -// color: #fff; -// -// .default-footer { -// a.rfglink { -// color: #fff; -// } -// -// .rfglogo { -// max-height: 3em; -// } -// } -// .terms-conditions-link { -// color:#fff; -// } -// -// .footer-logos { -// .rfglink { -// .rfglogo { -// max-height: 6em; -// } -// } -// } -// -// ul { -// list-style: none; -// padding-left: 0; -// margin-top: 2rem; -// } -// -// li { -// display: inline-block; -// -// a, a:hover { -// color: white; -// } -// } -// -// li:not(:first-child):before { -// content: " | "; -// } -// -// .address { -// margin-bottom: 2rem; -// } -// -// .copyright { -// font-size: 18px; -// font-weight: 700; -// } -//} -// -//.footer.container-fluid { -// min-height: calc(100vh - 45px); -//} -// -//footer.logged-in { -// position: relative; -// height: 40px; -// left: 0px; -// bottom: 0px; -// background: #F8F8F8; -// box-shadow: 0px -2px 12px rgba(0, 0, 0, 0.25); -// padding: 2rem; -// padding-top: 1rem; -// padding-left: 2%; -// margin-left: 320px; -// width: calc(100% - 320px); -// -// span { -// font-family: 'Montserrat'; -// font-style: normal; -// font-weight: 400; -// font-size: 14px; -// line-height: 140%; -// color: #2E2E2E; -// } -// -// a { -// white-space: nowrap; -// } -// -// @media only screen and (max-width: screen-sizes.$mobile) { -// width: 100%; -// margin-left: 0; -// padding-bottom: 1rem; -// height: 100px; -// } -//} diff --git a/app/assets/stylesheets/shared/form.scss b/app/assets/stylesheets/shared/form.scss deleted file mode 100644 index 35f3aae004..0000000000 --- a/app/assets/stylesheets/shared/form.scss +++ /dev/null @@ -1,35 +0,0 @@ -@use "../base/variables.scss" as globals; - -form { - .field_with_errors { - label { - @extend .text-danger !optional; - @extend .font-weight-bold !optional; - } - - input { - border: 1px solid globals.$red; - } - - } - - .read-more { - display: inline; - } - - .alert.alert-danger { - ul { - margin-bottom: 0; - } - } - - #with_cc { - margin-left: 20px; - } -} - -#cc-check { - margin-left: 30px; - display: initial; -} - diff --git a/app/assets/stylesheets/shared/header.scss b/app/assets/stylesheets/shared/header.scss deleted file mode 100644 index b4902edd78..0000000000 --- a/app/assets/stylesheets/shared/header.scss +++ /dev/null @@ -1,23 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -.header { - .header-left { - .menu-toggle-btn { - display: none; - - .main-btn { - border-radius: 4px !important; - } - } - } -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .header { - .header-left { - .menu-toggle-btn { - display: block; - } - } - } -} diff --git a/app/assets/stylesheets/shared/layout.scss b/app/assets/stylesheets/shared/layout.scss deleted file mode 100644 index 24eab988a2..0000000000 --- a/app/assets/stylesheets/shared/layout.scss +++ /dev/null @@ -1,112 +0,0 @@ -@use "../base/variables.scss" as globals; -@use "../base/breakpoints.scss" as screen-sizes; - -body { - background-color: #f4f7fa; - min-width: 320px; -} - -.content { - margin-left: 320px; - padding: 2%; - position: relative; - overflow: auto; -} - -.casa-case-scroll { - width: 13rem; - height: auto; - overflow-y: auto; -} - -// This may effect other pages than the case contact form!!! -h5 label { - font-weight: 500; -} - - -.unbold { - font-weight: 100; -} - -.fa-car { - color: globals.$primary; -} - -div.row div.col-sm-12.form-header h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; -} - -div.row.volunteer-filters { - margin-top: 2rem; -} - -.card.card-container { - box-shadow: 0 4px 12px 5px rgba(0, 0, 0, 0.08); - margin-bottom: 1.5rem; -} - -.casa-case-button { - margin: 0 10px 10px 0; -} - -.card-title { - &__hint { - font-size: 11px; - font-style: italic; - } -} - -/* ASSUMPTION: default screen size is desktop view (> 1024px) */ - -/* As 'mobile-label' class is mobile only, not display them on desktop view */ - -@media only screen and (min-width: screen-sizes.$mobile) { - table.table { - span.mobile-label { - display: none; - } - } -} - -#dropdownMenuButton { - margin-bottom: 0.5em; -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .login-header { - font-size: 0.75em !important; - } - .content { - padding: 3rem 0; - margin-left: 0; - } - .card-body { - padding: 0.5rem; - } - table.table { - thead { - display: none; - } - tr { - display: table; - width: 100%; - border-bottom: 1px solid #efefef; - > * { - display: block; - } - } - th, - td { - border-top: none; - font-size: 1.25rem; - span.mobile-label { - color: #757575; - display: block; - font-size: 0.9rem; - } - } - } -} diff --git a/app/assets/stylesheets/shared/navbar.scss b/app/assets/stylesheets/shared/navbar.scss deleted file mode 100644 index 5f0b51ee49..0000000000 --- a/app/assets/stylesheets/shared/navbar.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -.navbar { - color: #fff; - background: #00447c; - box-shadow: 0 0 4px 4px rgba(0,0,0,0.28); - height: 80px; - justify-content: space-between; - padding: 16px; - width: 100%; - z-index: 3; -} - -.navbar strong { - font-size: 2rem; -} - -.navbar.mobile { - display: none; -} - -.mobile-icon { - top: -2px; - width: 40px; -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .navbar.mobile { - display: flex; - position: sticky; - top: 0; - } -} diff --git a/app/assets/stylesheets/shared/notifier.scss b/app/assets/stylesheets/shared/notifier.scss deleted file mode 100644 index d70c895610..0000000000 --- a/app/assets/stylesheets/shared/notifier.scss +++ /dev/null @@ -1,98 +0,0 @@ -@keyframes spin { - 0% { - transform: rotate(0deg) - } - 100% { - transform: rotate(360deg) - } -} - -#notifications { - align-items: end; - bottom: 0; - display: flex; - flex-direction: column; - font-weight: 900; - max-height: 100vh; - max-width: 40%; - overflow-y: auto; - pointer-events: none; - position: fixed; - right: 2em; - z-index: 100; - - &>* { - pointer-events: auto; - } - - div { - border-radius: .25em; - margin-bottom: .5em; - margin-left: auto; - margin-right: 0; - padding: .5em; - - span { - display: inline - } - } - - .danger-notification { - background-color: #b71c1c; - color: white - } - - .messages { - margin-bottom: 0; - padding: 0; - pointer-events: none; - - &>* { - pointer-events: auto; - } - - div { - display: flex; - width: fit-content; - } - } - - .success-notification { - background-color: #28a745; - color: white - } - - .warning-notification { - background-color: #ffc107; - } - - #async-waiting-indicator { - background-color: #007bff; - color: white; - - .load-spinner { - animation: spin 1.5s linear infinite; - border: .25em solid #f3f3f3; - border-top: .25em solid #3498db; - border-radius: 50%; - display: inline-block; - height: 1em; - margin: 0; - padding: 0; - width: 1em - } - } - - #toggle-minimize-notifications { - background-color: #333; - border: 0; - border-radius: 10px 10px 0 0; - color: white; - padding: 0.25em 1em; - width: fit-content; - - span { - display: inline; - } - } -} diff --git a/app/assets/stylesheets/shared/select2_additional_styles.scss b/app/assets/stylesheets/shared/select2_additional_styles.scss deleted file mode 100644 index 8c7ed119e4..0000000000 --- a/app/assets/stylesheets/shared/select2_additional_styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.select2 { - input { - min-width: 250px; - } -} diff --git a/app/assets/stylesheets/shared/sidebar.scss b/app/assets/stylesheets/shared/sidebar.scss deleted file mode 100644 index b7a044a55d..0000000000 --- a/app/assets/stylesheets/shared/sidebar.scss +++ /dev/null @@ -1,213 +0,0 @@ -@use "../base/variables.scss" as globals; -@use "../base/breakpoints.scss" as screen-sizes; - -#wrapper { - overflow-x: hidden; - } - -.sidebar-wrapper { - background: rgba(0,0,0,0.25); - display: flex; - flex-direction: column; - max-height: 100vh; - position: fixed; - width: 320px; - overflow: auto; -} - -.sidebar-wrapper .sidebar-heading { - position: sticky; - top: 0; - z-index: 10; - background: globals.$primary; - border-bottom: 1px solid #103862; - font-size: 1.2rem; - padding-bottom: 16px; - padding: 0.875rem 1.25rem; -} - -.sidebar-wrapper .list-group { - width: 100%; - .list-group-item { - background: none; - color: #fff; - padding: 1.25rem 1.75rem; - text-decoration: none; - - &:hover { - text-decoration: none; - } - } -} - -#page-content-wrapper { - width: 100%; -} - -.sidebar-container { - background: globals.$primary; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 100vh; - overflow-y: auto; -} - -.logo-div { - height: 80px; -} - -// Stops weird text wrapping when opening and closing sidebar -.nav-item { - width: 250px; -} - -.nav-item > a { - color: globals.$sidebar-inactive !important; - height: 44px; - - &:hover { - color: globals.$sidebar-active !important; - - span { - color: globals.$sidebar-dark !important; - } - } -} - -.nav-item.active > a { - color: globals.$sidebar-dark !important; -} - -.btn-sidebar { - color: #fff !important; - background-color: globals.$sidebar-active !important; -} - -.sidebar-wrapper .sidebar-menu { - margin-top: 24px; - .list-group-item { - border: none; - border-left: 6px solid transparent; - padding: 1.25rem 1.75rem; - a { - color: #fff; - text-decoration: none; - } - &:hover { - border-left: 6px solid #fff; - } - &.active { - background: #103862; - border-left: 6px solid #fff; - font-weight: bold; - } - } -} - -.sidebar-wrapper .sidebar-footer { - .list-group.account-details { - padding-bottom: 1rem; - .list-group-item { - border: none; - padding: 1rem 2rem; - > * { - font-size: 0.9em; - } - .label { - color: #729DCB; - display: block; - - } - .value, a { - color: #fff; - } - } - } - - .list-group.footer-nav { - background: #103862; - padding: 1rem 0 5rem; - width: 100%; - a.list-group-item { - background: none !important; - border: none; - color: #fff; - font-size: 0.9em; - padding: 1rem 1.75rem; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } -} - -.group-actions { - &:hover::before { - opacity: 1 !important; - visibility: visible !important; - } - &::before { - opacity: 0 !important; - visibility: hidden !important; - } -} -// Media Queries -@media only screen and (max-width: screen-sizes.$mobile) { - .sidebar-wrapper { - display: none; - width: 100%; - &.sidebar-open { - display: inherit; - z-index: 1; - } - } - - .sidebar-container { - height: calc(100vh - 72px); - width: 320px; - } - - .sidebar-heading { - display: none; - } - - #wrapper.toggled .sidebar-wrapper { - margin-left: -15rem; - } - - .footer-nav #mobile-space { - margin-top: 72px; - } -} - -// These overrides are for larger than mobile screens -@media only screen and (min-width: screen-sizes.$mobile) { - .sidebar-nav-wrapper { - -webkit-transform: translateX(0px) !important; - -moz-transform: translateX(0px) !important; - -ms-transform: translateX(0px) !important; - -o-transform: translateX(0px) !important; - transform: translateX(0px) !important; - - &.active { - width: 66px; - - -webkit-transform: translateX(0px) !important; - -moz-transform: translateX(0px) !important; - -ms-transform: translateX(0px) !important; - -o-transform: translateX(0px) !important; - transform: translateX(0px) !important; - } - } - - .main-wrapper { - margin-left: 250px !important; - - &.active { - margin-left: 66px !important; - } - } -} diff --git a/app/assets/stylesheets/shared/tomselect_additional_styles.scss b/app/assets/stylesheets/shared/tomselect_additional_styles.scss deleted file mode 100644 index 81817fd772..0000000000 --- a/app/assets/stylesheets/shared/tomselect_additional_styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.ts-dropdown [data-selectable] .highlight { - display: inline-block; -} \ No newline at end of file diff --git a/app/assets/stylesheets/shared/truncated_text_component.scss b/app/assets/stylesheets/shared/truncated_text_component.scss deleted file mode 100644 index fd537a2c3e..0000000000 --- a/app/assets/stylesheets/shared/truncated_text_component.scss +++ /dev/null @@ -1,8 +0,0 @@ -.truncation-container { - display: flex; - - a { - min-width: fit-content; - margin-inline-start: auto; - } -} diff --git a/app/assets/stylesheets/shared/typography.scss b/app/assets/stylesheets/shared/typography.scss deleted file mode 100644 index 7a5ec90bb8..0000000000 --- a/app/assets/stylesheets/shared/typography.scss +++ /dev/null @@ -1,37 +0,0 @@ -// TODO check in css so tests can run without internet -@use "../base/variables.scss" as globals; -@import url(https://fonts.googleapis.com/css?family=Montserrat:400,700); - -body { - font-family: "Montserrat"; -} - -option { - font-family: Helvetica, Arial, sans-serif; -} - -.content-1 { - font-family: Inter; - font-size: 16px; - font-weight: 500; - line-height: 19px; - color: var(--dark); -} - -.content-2 { - font-family: Inter; - font-size: 14px; - font-weight: 500; - line-height: 22px; - color: var(--gray) -} - -.content-3 { - font-family: Inter; - font-size: 14px; - font-weight: 400; -} - -.pre-line { - white-space: pre-line; -} diff --git a/app/assets/stylesheets/shared/utilities.scss b/app/assets/stylesheets/shared/utilities.scss deleted file mode 100644 index 896b6ff9ee..0000000000 --- a/app/assets/stylesheets/shared/utilities.scss +++ /dev/null @@ -1,46 +0,0 @@ -@use "../base/variables.scss" as globals; - -.pull-left { - float: left; -} - -.pull-right { - float: right; -} - -.vertically-center { - display: flex; - align-items: center; -} - -.add-container { - display: flex; - justify-content: flex-start; - align-items: center; - gap: 10px; - - .add-button { - color: white; - border: none; - background-color: #{globals.$primary}; - - padding: 0.25em 1.5em; - border-radius: 10px; - font-size: 1.25em; - - :hover { - cursor: pointer; - } - } -} - -.hidden { - display: none; -} - -.line-clamp-1 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; -} diff --git a/app/assets/stylesheets/shared/validated_form_component.scss b/app/assets/stylesheets/shared/validated_form_component.scss deleted file mode 100644 index 0221951573..0000000000 --- a/app/assets/stylesheets/shared/validated_form_component.scss +++ /dev/null @@ -1,10 +0,0 @@ -.warning-required-checkbox { - background-color: #ffc107; - padding: 0.5em; - - input[type="checkbox"] { - width: 18px; - height: 18px; - margin-right: 0.5em; - } -} \ No newline at end of file diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css new file mode 100644 index 0000000000..775b48703c --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,229 @@ +@import "tailwindcss"; +@import "tom-select/dist/css/tom-select.css"; +@import "trix/dist/trix.css"; + +/* Self-hosted fonts + icons (vendored under app/assets/stylesheets/vendor/, + binaries in public/vendor/) -- replaces the Google Fonts + jsdelivr CDN links. */ +@import "./vendor/figtree.css"; +@import "./vendor/bootstrap-icons.css"; + +/* Where Tailwind should look for class names (paths are relative to this file). */ +@source "../../components/**/*.{erb,rb}"; +@source "../../views/**/*.erb"; +@source "../../helpers/**/*.rb"; +@source "../../javascript/**/*.js"; + +/* ------------------------------------------------------------------ + CASA design system (Tailwind v4 CSS-first config). + A calm, professional indigo palette + Figtree — the visual direction + for the casadesign redesign. +------------------------------------------------------------------ */ +@theme { + --font-sans: "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; +} + +/* ------------------------------------------------------------------ + TomSelect (the `multiple-select` Stimulus controller) restyled to the + design system. tom-select.css above provides the structure; these rules + theme it to match our inputs (slate border, rounded-lg, brand focus + + brand-50 pills). Only loaded on casa_app; Bootstrap pages keep the + tom-select.bootstrap5 theme from the retired Bootstrap bundle. +------------------------------------------------------------------ */ +.ts-wrapper { font-size: 0.875rem; position: relative; width: 100%; } +.ts-control { + border-radius: 0.5rem; + border: 1px solid #cbd5e1; /* slate-300 */ + padding: 0.375rem 2rem 0.375rem 0.625rem !important; /* right room for the chevron */ + min-height: 2.625rem; /* match the text-input height so it reads as one control */ + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + background-color: #fff; + gap: 0.375rem; +} +.ts-wrapper.focus .ts-control { + border-color: #6366f1; /* brand-500 */ + box-shadow: 0 0 0 2px rgb(99 102 241 / 0.3); +} +/* Chevron: the Bootstrap-Icons `chevron-down` shape as an inline base64 SVG, sized (text-xs) + and placed (right-3) to match the single-select chevrons. Needs `z-index` because + TomSelect's opaque `.ts-control` paints over a plain `::after` (that stacking, not a + missing rule, is why every earlier chevron was invisible). base64 is minifier- and + parser-safe; a raw `data:` URI and a `content` glyph escape both broke in the build, and + the injected icon-font element never painted. Pixel-verified to match the single-selects + (identical 8x4 dark extent), not just checked by computed style. */ +.ts-wrapper::after { + content: ""; + position: absolute; + right: 0.75rem; + top: 50%; + width: 0.75rem; + height: 0.75rem; + transform: translateY(-50%); + z-index: 2; + background: center / contain no-repeat url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0iIzY0NzQ4YiI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMS42NDYgNC42NDZhLjUuNSAwIDAgMSAuNzA4IDBMOCAxMC4yOTNsNS42NDYtNS42NDdhLjUuNSAwIDAgMSAuNzA4LjcwOGwtNiA2YS41LjUgMCAwIDEtLjcwOCAwbC02LTZhLjUuNSAwIDAgMSAwLS43MDh6Ii8+PC9zdmc+"); + pointer-events: none; +} +.ts-wrapper.dropdown-active::after { transform: translateY(-50%) rotate(180deg); } +.ts-wrapper .ts-control { padding-right: 2rem !important; } /* keep room for the chevron */ +/* Single-select: vertically center the selected item + the search/placeholder input. Without this the + item stretches to the full content box and the text rides high (uneven top/bottom). */ +.ts-wrapper.single .ts-control { align-items: center; padding-left: 0.875rem !important; } /* px-3.5, matches the design-system inputs' left padding (icon state overrides below) */ +/* The magnifier is a RESTING affordance: shown ONLY when the field is empty AND not focused. The + moment the user focuses (types) or a value is selected, it's a plain field -- no icon, caret/text at + the normal left. Keeping a leading icon on focus/has-items pushed the caret in and left the icon + lingering after a pick, which isn't standard type-ahead behavior. */ +.ts-wrapper.single:not(.focus):not(.has-items) .ts-control { + padding-left: 2rem !important; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0iIzY0NzQ4YiI+PHBhdGggZD0iTTExLjc0MiAxMC4zNDRhNi41IDYuNSAwIDEgMC0xLjM5NyAxLjM5OGgtLjAwMXEuMDQ0LjA2LjA5OC4xMTVsMy44NSAzLjg1YTEgMSAwIDAgMCAxLjQxNS0xLjQxNWwtMy44NS0zLjg1YTEgMSAwIDAgMC0uMTE1LS4wOTh6TTEyIDYuNWE1LjUgNS41IDAgMSAxLTExIDAgNS41IDUuNSAwIDAgMSAxMSAwIi8+PC9zdmc+"); + background-repeat: no-repeat; + background-position: left 0.625rem center; + background-size: 1rem 1rem; +} +.ts-wrapper.single .ts-control > .item, +.ts-wrapper.single .ts-control > input { align-self: center; } +/* Keep the selected item + search input on ONE line (never wrap to a 2nd line, which grows the field): + the item truncates and the input shrinks instead of forcing an overflow. TomSelect's default + input `min-width: 7rem !important` is what wraps a name + input in a narrow field. */ +.ts-wrapper.single .ts-control { flex-wrap: nowrap; } +.ts-wrapper.single .ts-control > input { min-width: 0 !important; } +.ts-wrapper.single .ts-control > .item { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* legible placeholder, matching the design-system inputs (slate-500 -- slate-400 is 2.56:1 and a + placeholder is text). This rule outranks the bare `.ts-control input::placeholder` one, so it is + the only place the colour is set. */ +.ts-wrapper .ts-control input::placeholder { color: #64748b; opacity: 1; } +/* clear_button plugin: the bundled plugin CSS positions the x and toggles its opacity on + hover/focus-with-selection. We (a) hide the chevron underneath while the x shows so the two never + overlap, (b) restyle the x for casa, and (c) hide the blank placeholder option from the menu (it + exists only to default the native
` - ) - $(`.cc-${action}-action`).trigger('click') - } - - beforeEach(() => { - mockAjaxReload = jest.fn() - mockTableInstance = { ajax: { reload: mockAjaxReload } } - mockDataTable.mockReturnValue(mockTableInstance) - - // Add CSRF meta tag - document.head.innerHTML = '' - - defineCaseContactsTable() - }) - - afterEach(() => { - Swal.fire.mockReset() - fireSwalFollowupAlert.mockReset() - }) - - describe('Delete action', () => { - it('shows a SweetAlert confirmation dialog when cc-delete-action is clicked', () => { - Swal.fire.mockResolvedValue({ isConfirmed: false }) - - clickActionButton('delete', { id: '42' }) - - expect(Swal.fire).toHaveBeenCalled() - }) - - it('sends DELETE request when confirmed', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/42', - type: 'DELETE', - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') - - ajaxSpy.mockRestore() - }) - - it('does not send DELETE request when cancelled', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: false }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(ajaxSpy).not.toHaveBeenCalled() - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after successful delete', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: true }) - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - - describe('Set Reminder action', () => { - it('calls fireSwalFollowupAlert when cc-set-reminder-action is clicked', () => { - fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) - - clickActionButton('set-reminder', { id: '5' }) - - expect(fireSwalFollowupAlert).toHaveBeenCalled() - }) - - it('posts to the followups endpoint with CSRF header when confirmed without a note', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/5/followups', - type: 'POST', - data: {}, - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - - ajaxSpy.mockRestore() - }) - - it('posts with note when confirmed with a note', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: 'My note', isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/5/followups', - type: 'POST', - data: { note: 'My note' }, - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - - ajaxSpy.mockRestore() - }) - - it('does not post when cancelled', async () => { - fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).not.toHaveBeenCalled() - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after creating a reminder', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - - describe('Resolve Reminder action', () => { - it('sends PATCH request when cc-resolve-reminder-action is clicked', () => { - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/followups/42/resolve', - type: 'PATCH', - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after resolving a reminder', () => { - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - }) - - describe('edge cases', () => { - it('handles missing data-source attribute gracefully', () => { - tableElement.removeAttr('data-source') - - expect(() => defineCaseContactsTable()).not.toThrow() - - const config = mockDataTable.mock.calls[0][0] - expect(config.ajax.url).toBeUndefined() - }) - - it('handles table element not existing', () => { - document.body.innerHTML = '' - - // Should not throw when table doesn't exist - expect(() => defineCaseContactsTable()).not.toThrow() - }) - }) - - describe('DataTable integration', () => { - it('passes all required configuration options', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - // Verify all critical config options are present - expect(config).toHaveProperty('autoWidth') - expect(config).toHaveProperty('searching') - expect(config).toHaveProperty('processing') - expect(config).toHaveProperty('serverSide') - expect(config).toHaveProperty('order') - expect(config).toHaveProperty('ajax') - expect(config).toHaveProperty('columnDefs') - expect(config).toHaveProperty('columns') - }) - - it('configures columns array matching table structure', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - const headerColumns = $('table#case_contacts thead th').length - - expect(config.columns.length).toBe(headerColumns) - }) - }) -}) diff --git a/app/javascript/__tests__/local_storage_reset_controller.test.js b/app/javascript/__tests__/local_storage_reset_controller.test.js new file mode 100644 index 0000000000..81cd32c7da --- /dev/null +++ b/app/javascript/__tests__/local_storage_reset_controller.test.js @@ -0,0 +1,35 @@ +/* eslint-env jest */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import LocalStorageResetController from '../controllers/local_storage_reset_controller' + +describe('local_storage_reset_controller', () => { + let application + + const mount = async (html) => { + document.body.innerHTML = html + application = Application.start() + application.register('local-storage-reset', LocalStorageResetController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + window.localStorage.clear() + }) + + test('removes the configured key on connect', async () => { + window.localStorage.setItem('casa-contact-form', 'draft') + await mount('
') + expect(window.localStorage.getItem('casa-contact-form')).toBeNull() + }) + + test('leaves other keys untouched', async () => { + window.localStorage.setItem('keep-me', 'yes') + await mount('
') + expect(window.localStorage.getItem('keep-me')).toBe('yes') + }) +}) diff --git a/app/javascript/__tests__/nav_drawer_controller.test.js b/app/javascript/__tests__/nav_drawer_controller.test.js new file mode 100644 index 0000000000..9a8b07fd4d --- /dev/null +++ b/app/javascript/__tests__/nav_drawer_controller.test.js @@ -0,0 +1,71 @@ +/* eslint-env jest, browser */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import NavDrawerController from '../controllers/nav_drawer_controller' + +describe('nav_drawer_controller', () => { + let application + + const mount = async () => { + document.body.innerHTML = ` +
+ + + +
` + application = Application.start() + application.register('nav-drawer', NavDrawerController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + const els = () => ({ + backdrop: document.querySelector('[data-nav-drawer-target="backdrop"]'), + sidebar: document.querySelector('[data-nav-drawer-target="sidebar"]'), + button: document.querySelector('[data-nav-drawer-target="button"]') + }) + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + document.body.classList.remove('overflow-hidden') + }) + + test('the toggle button opens the drawer', async () => { + await mount() + const { backdrop, sidebar, button } = els() + button.click() + expect(sidebar.classList.contains('-translate-x-full')).toBe(false) + expect(backdrop.classList.contains('hidden')).toBe(false) + expect(button.getAttribute('aria-expanded')).toBe('true') + expect(document.body.classList.contains('overflow-hidden')).toBe(true) + }) + + test('the toggle button closes an open drawer', async () => { + await mount() + const { sidebar, button } = els() + button.click() // open + button.click() // close + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + expect(button.getAttribute('aria-expanded')).toBe('false') + expect(document.body.classList.contains('overflow-hidden')).toBe(false) + }) + + test('clicking the backdrop closes the drawer', async () => { + await mount() + const { backdrop, sidebar, button } = els() + button.click() // open + backdrop.click() + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + expect(backdrop.classList.contains('hidden')).toBe(true) + }) + + test('pressing Escape closes the drawer', async () => { + await mount() + const { sidebar, button } = els() + button.click() // open + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + }) +}) diff --git a/app/javascript/__tests__/validated_form.test.js b/app/javascript/__tests__/validated_form.test.js index 3d4a5d0432..00661ae599 100644 --- a/app/javascript/__tests__/validated_form.test.js +++ b/app/javascript/__tests__/validated_form.test.js @@ -21,11 +21,11 @@ describe('NonDrivingContactMediumWarning', () => {
- +
- +
@@ -35,7 +35,7 @@ describe('NonDrivingContactMediumWarning', () => {
- +
diff --git a/app/javascript/all_casa_admin.js b/app/javascript/all_casa_admin.js index 6324ded939..b0723b43f9 100644 --- a/app/javascript/all_casa_admin.js +++ b/app/javascript/all_casa_admin.js @@ -1,4 +1,3 @@ require('./src/all_casa_admin/tables') require('./src/all_casa_admin/patch_notes') require('./src/session_timeout_poller.js') -require('./src/display_app_metric.js') diff --git a/app/javascript/application.js b/app/javascript/application.js index 88f9aef317..1374a06d25 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,7 +1,6 @@ /* global window */ import './jQueryGlobalizer.js' import '@hotwired/turbo-rails' -import 'bootstrap' import './sweet-alert-confirm.js' import './controllers' import 'trix' @@ -10,29 +9,34 @@ import './datatable.js' Turbo.session.drive = false require('datatables.net-dt')(null, window.jQuery) // First parameter is the global object. Defaults to window if null -require('select2')(window.jQuery) require('@rails/ujs').start() require('@rails/activestorage').start() -require('bootstrap-datepicker') -require('./src/add_to_calendar_button') require('./src/case_contact') require('./src/case_emancipation') require('./src/casa_case') require('./src/new_casa_case') -require('./src/dashboard') require('./src/emancipations') require('./src/import') require('./src/password_confirmation') require('./src/read_more') -require('./src/reimbursements') require('./src/reports') require('./src/require_communication_preference') -require('./src/select') -require('./src/tooltip') require('./src/time_zone') require('./src/session_timeout_poller.js') -require('./src/display_app_metric.js') -require('./src/casa_org') require('./src/sms_reactivation_toggle') require('./src/validated_form') -require('./src/learning_hours') + +// Disable the browser's native HTML5 validation app-wide so an invalid submit reaches the server +// and Rails renders the design-system validation (.field_with_errors rose borders + the +// _form_errors summary) instead of the unstyleable native bubbles. Turbo Drive is off, so this +// runs on each full page load (DOMContentLoaded) plus any Turbo frame/render. Opt a single form +// back into native validation with data-native-validation. +const disableNativeFormValidation = () => { + document.querySelectorAll('form:not([data-native-validation])').forEach((form) => { + form.noValidate = true + }) +} +if (document.readyState !== 'loading') disableNativeFormValidation() +document.addEventListener('DOMContentLoaded', disableNativeFormValidation) +document.addEventListener('turbo:load', disableNativeFormValidation) +document.addEventListener('turbo:frame-load', disableNativeFormValidation) diff --git a/app/javascript/controllers/add_to_calendar_controller.js b/app/javascript/controllers/add_to_calendar_controller.js new file mode 100644 index 0000000000..997c076d13 --- /dev/null +++ b/app/javascript/controllers/add_to_calendar_controller.js @@ -0,0 +1,40 @@ +import { Controller } from '@hotwired/stimulus' + +// "Add to calendar": builds an .ics file from the court-date values and downloads +// it on click, so a court date imports into any calendar (Google, Apple, Outlook). +// Replaces the third-party web component, whose own button +// styling did not match the design system; the trigger is now a ghost button. +export default class extends Controller { + static values = { title: String, start: String, end: String } + + download (event) { + event.preventDefault() + const compact = (d) => d.replace(/-/g, '') + // DTEND is exclusive for all-day events, so add a day to the inclusive end date. + const end = new Date(`${this.endValue}T00:00:00`) + end.setDate(end.getDate() + 1) + const dtEnd = `${end.getFullYear()}${String(end.getMonth() + 1).padStart(2, '0')}${String(end.getDate()).padStart(2, '0')}` + const stamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' + const ics = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//CASA//Court Date//EN', + 'BEGIN:VEVENT', + `UID:${Date.now()}-${Math.random().toString(36).slice(2)}@casa`, + `DTSTAMP:${stamp}`, + `DTSTART;VALUE=DATE:${compact(this.startValue)}`, + `DTEND;VALUE=DATE:${dtEnd}`, + `SUMMARY:${this.titleValue}`, + 'END:VEVENT', + 'END:VCALENDAR' + ].join('\r\n') + const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `${this.titleValue.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.ics` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + } +} diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js new file mode 100644 index 0000000000..fb64329686 --- /dev/null +++ b/app/javascript/controllers/auto_submit_controller.js @@ -0,0 +1,9 @@ +import { Controller } from '@hotwired/stimulus' + +// Submits the form when a control changes (e.g. the table filter selects). Turbo Drive +// keeps the navigation smooth, so filtering has no full-page flash. +export default class extends Controller { + submit () { + this.element.requestSubmit() + } +} diff --git a/app/javascript/controllers/autosave_controller.js b/app/javascript/controllers/autosave_controller.js index 5412001be0..264a6edcdf 100644 --- a/app/javascript/controllers/autosave_controller.js +++ b/app/javascript/controllers/autosave_controller.js @@ -1,5 +1,6 @@ import { Controller } from '@hotwired/stimulus' import { debounce } from 'lodash' +import { ensureCaseContact, isPersisted } from '../src/case_contact_draft' export default class extends Controller { static targets = ['form', 'alert'] @@ -17,8 +18,9 @@ export default class extends Controller { static classes = ['goodAlert', 'badAlert'] connect () { - this.visibleClass = 'visible' - this.hiddenClass = 'invisible' + // display (not visibility) so a hidden status line reserves no space at the card's bottom + this.visibleClass = 'block' + this.hiddenClass = 'hidden' this.save = debounce(this.save, this.delayValue).bind(this) } @@ -28,38 +30,56 @@ export default class extends Controller { } submitForm () { + // First save of a brand-new contact: the record does not exist yet, so create it and adopt the + // id. Every save after that PATCHes the wizard step -- posting to create twice would insert a + // second draft. + if (!isPersisted(this.formTarget)) { + ensureCaseContact(this.formTarget) + .then(() => this.handleSuccess()) + .catch(error => this.handleError(error)) + return + } + fetch(this.formTarget.action, { method: 'POST', headers: { Accept: 'application/json' }, body: new FormData(this.formTarget) }).then(response => { if (response.ok) { - this.goodAlert() - const event = new CustomEvent('autosave:success', { bubbles: true }) // eslint-disable-line no-undef - this.element.dispatchEvent(event) + this.handleSuccess() } else { return Promise.reject(response) } - }).catch(error => { - console.error(error.status, error.statusText) - switch (error.status) { - case 504: - this.badAlert('Connection lost: Changes will be saved when connection is restored.') - break - case 422: - error.json().then(errorJson => { - console.error('errorJson', errorJson) - const errorMessage = errorJson.join('. ') - this.badAlert(`Unable to save: ${errorMessage}`) - }) - break - case 401: - this.badAlert('You must be signed in to save changes.') - break - default: - this.badAlert('Error: Unable to save changes.') - } - }) + }).catch(error => this.handleError(error)) + } + + // Both save paths land here: the nested-form controller listens for autosave:success, so the + // create path has to announce it too or the expense rows stop reacting to saves. + handleSuccess () { + this.goodAlert() + const event = new CustomEvent('autosave:success', { bubbles: true }) // eslint-disable-line no-undef + this.element.dispatchEvent(event) + } + + handleError (error) { + console.error(error.status, error.statusText) + switch (error.status) { + case 504: + this.badAlert('Connection lost: Changes will be saved when connection is restored.') + break + case 422: + error.json().then(errorJson => { + console.error('errorJson', errorJson) + const errorMessage = errorJson.join('. ') + this.badAlert(`Unable to save: ${errorMessage}`) + }) + break + case 401: + this.badAlert('You must be signed in to save changes.') + break + default: + this.badAlert('Error: Unable to save changes.') + } } autosaveAlert () { diff --git a/app/javascript/controllers/casa_nested_form_controller.js b/app/javascript/controllers/casa_nested_form_controller.js index b742298b5d..91aafcaa34 100644 --- a/app/javascript/controllers/casa_nested_form_controller.js +++ b/app/javascript/controllers/casa_nested_form_controller.js @@ -1,4 +1,5 @@ import NestedForm from '@stimulus-components/rails-nested-form' +import { ensureCaseContact } from '../src/case_contact_draft' /** * Allows nested forms to be used with the autosave controller, @@ -25,6 +26,8 @@ export default class extends NestedForm { } } + static targets = ['confirmDialog'] + connect () { super.connect() @@ -82,8 +85,22 @@ export default class extends NestedForm { this.dispatchChangeEvent('add') } - /* Creates a new record for the added item (before submission). */ + /* Creates a new record for the added item (before submission). The case contact may not exist yet + -- the form persists on first save, not on open -- and the child needs a parent id, so bring the + parent into existence first. Without this the fetch below aborts on an empty parent id and the + expense is silently never saved. */ addAndCreate (e) { + const form = this.element.closest('form') + if (form && !form.dataset.caseContactId) { + ensureCaseContact(form) + .then(() => this.addAndCreateRow(e)) + .catch(error => console.error('Failed to create the case contact', error.status, error.statusText)) + return + } + this.addAndCreateRow(e) + } + + addAndCreateRow (e) { this.add(e) const items = this.element.querySelectorAll(this.wrapperSelectorValue) const addedItem = items[items.length - 1] @@ -144,8 +161,31 @@ export default class extends NestedForm { this.dispatchChangeEvent('remove') } - /* Destroys a record when removing the item (before submission). */ + /* Delete button: a saved (autosaved) expense confirms through the design-system dialog before + the API delete; brand-new, unsaved rows are removed without a prompt. */ destroyAndRemove (e) { + const wrapper = e.target.closest(this.wrapperSelectorValue) + const recordId = this.getRecordId(wrapper) + if (wrapper.dataset.newRecord === 'false' && recordId.length > 0 && this.hasConfirmDialogTarget) { + e.preventDefault() + this.pendingEvent = e + this.confirmDialogTarget.showModal() + } else { + this.performDestroyAndRemove(e) + } + } + + /* Confirm button inside the removal dialog. */ + confirmRemove () { + this.confirmDialogTarget.close() + if (this.pendingEvent) { + this.performDestroyAndRemove(this.pendingEvent) + this.pendingEvent = null + } + } + + /* Destroys a record when removing the item (before submission). */ + performDestroyAndRemove (e) { const wrapper = e.target.closest(this.wrapperSelectorValue) const recordId = this.getRecordId(wrapper) if (wrapper.dataset.newRecord === 'false' && (recordId.length > 0)) { @@ -184,11 +224,4 @@ export default class extends NestedForm { this.remove(e) // treat as typical removal } } - - confirmDestroyAndRemove (e) { - const text = 'Are you sure you want to remove this item?' - if (window.confirm(text)) { - this.destroyAndRemove(e) - } - } } diff --git a/app/javascript/controllers/case_contact_form_controller.js b/app/javascript/controllers/case_contact_form_controller.js index c712117db8..c0f31a6093 100644 --- a/app/javascript/controllers/case_contact_form_controller.js +++ b/app/javascript/controllers/case_contact_form_controller.js @@ -68,17 +68,38 @@ export default class extends Controller { clearMileage = () => { this.milesDrivenTarget.value = 0 - this.volunteerAddressTarget.value = '' + // structured address = several volunteerAddress targets (line 1 / line 2 / city / state / zip) + this.volunteerAddressTargets.forEach(el => { el.value = '' }) + } + + // When the case has multiple volunteers, the editor picks whose address to reimburse; fill the + // address fields from the selected option's saved address (data-address JSON), keyed off each + // input's id (case_contact_volunteer_address_). Empty parts clear the field. + pickReimbursementVolunteer = (e) => { + const option = e.target.selectedOptions[0] + let parts = {} + try { + parts = JSON.parse(option?.dataset.address || '{}') + } catch { + parts = {} + } + this.volunteerAddressTargets.forEach(el => { + const field = el.id.replace('case_contact_volunteer_address_', '') + el.value = parts[field] || '' + }) } setReimbursementFormVisibility = () => { + // Toggles Tailwind's `hidden` (display:none). The case-contact form is casadesign + // (Tailwind) now; Bootstrap's `d-none` is not defined there. This controller is used + // only by that form, so switching the class here is safe. if (this.wantDrivingReimbursementTarget.checked) { - this.reimbursementFormTarget.classList.remove('d-none') + this.reimbursementFormTarget.classList.remove('hidden') this.expenseDestroyTargets.forEach(el => (el.value = '0')) } else { this.clearExpenses() this.clearMileage() - this.reimbursementFormTarget.classList.add('d-none') + this.reimbursementFormTarget.classList.add('hidden') } } } diff --git a/app/javascript/controllers/chart_hover_controller.js b/app/javascript/controllers/chart_hover_controller.js new file mode 100644 index 0000000000..7524f93339 --- /dev/null +++ b/app/javascript/controllers/chart_hover_controller.js @@ -0,0 +1,107 @@ +import { Controller } from '@hotwired/stimulus' + +// Crosshair + tooltip for a server-rendered SVG line chart. Reads the chart +// geometry from the config value; every value is also in the table twin, so this +// is progressive enhancement, not the only way to read the data. +export default class extends Controller { + static values = { config: Object } + static targets = ['tip'] + + connect () { + this.cfg = this.configValue + this.svg = this.element.querySelector('svg') + if (!this.svg || !this.cfg || !this.cfg.series) return + const ns = 'http://www.w3.org/2000/svg' + this.cross = document.createElementNS(ns, 'line') + this.cross.setAttribute('stroke', '#94a3b8') + this.cross.setAttribute('stroke-width', '1') + this.cross.setAttribute('stroke-dasharray', '3 3') + this.cross.setAttribute('y1', this.cfg.plotTop) + this.cross.setAttribute('y2', this.cfg.plotBottom) + this.cross.style.opacity = '0' + this.cross.style.pointerEvents = 'none' + this.svg.appendChild(this.cross) + this.dots = this.cfg.series.map((s) => { + const dot = document.createElementNS(ns, 'circle') + dot.setAttribute('r', '4.5') + dot.setAttribute('fill', s.color) + dot.setAttribute('stroke', '#fff') + dot.setAttribute('stroke-width', '2') + dot.style.opacity = '0' + dot.style.pointerEvents = 'none' + this.svg.appendChild(dot) + return dot + }) + this.onMove = this.onMove.bind(this) + this.onLeave = this.onLeave.bind(this) + this.svg.addEventListener('pointermove', this.onMove) + this.svg.addEventListener('pointerleave', this.onLeave) + } + + disconnect () { + if (!this.svg) return + this.svg.removeEventListener('pointermove', this.onMove) + this.svg.removeEventListener('pointerleave', this.onLeave) + } + + onMove (event) { + const point = this.svg.createSVGPoint() + point.x = event.clientX + point.y = event.clientY + const x = point.matrixTransform(this.svg.getScreenCTM().inverse()).x + let index = 0 + let best = Infinity + this.cfg.xs.forEach((xv, idx) => { + const distance = Math.abs(xv - x) + if (distance < best) { + best = distance + index = idx + } + }) + const cx = this.cfg.xs[index] + this.cross.setAttribute('x1', cx) + this.cross.setAttribute('x2', cx) + this.cross.style.opacity = '1' + this.cfg.series.forEach((s, si) => { + this.dots[si].setAttribute('cx', cx) + this.dots[si].setAttribute('cy', s.ys[index]) + this.dots[si].style.opacity = '1' + }) + if (!this.hasTipTarget) return + const tip = this.tipTarget + tip.replaceChildren() + const month = document.createElement('div') + month.className = 'mb-1 text-[11px] font-bold text-slate-300' + month.textContent = this.cfg.labels[index] + tip.appendChild(month) + this.cfg.series.forEach((s) => { + const row = document.createElement('div') + row.className = 'flex items-center gap-1.5 leading-relaxed' + const key = document.createElement('span') + key.className = 'h-0.5 w-3.5 flex-none rounded' + key.style.background = s.color + const value = document.createElement('span') + value.className = 'font-bold tabular-nums' + value.textContent = s.values[index] + const name = document.createElement('span') + name.className = 'text-slate-300' + name.textContent = s.name + row.append(key, value, name) + tip.appendChild(row) + }) + tip.style.opacity = '1' + const rect = this.element.getBoundingClientRect() + let left = event.clientX - rect.left + 14 + if (left + tip.offsetWidth > rect.width) { + left = event.clientX - rect.left - tip.offsetWidth - 14 + } + tip.style.left = `${left}px` + tip.style.top = `${event.clientY - rect.top + 14}px` + } + + onLeave () { + this.cross.style.opacity = '0' + this.dots.forEach((dot) => { dot.style.opacity = '0' }) + if (this.hasTipTarget) this.tipTarget.style.opacity = '0' + } +} diff --git a/app/javascript/controllers/contact_topics_controller.js b/app/javascript/controllers/contact_topics_controller.js new file mode 100644 index 0000000000..41c575e009 --- /dev/null +++ b/app/javascript/controllers/contact_topics_controller.js @@ -0,0 +1,93 @@ +import { Controller } from '@hotwired/stimulus' +import { ensureCaseContact } from '../src/case_contact_draft' + +// Contact-topic checklist for the case-contact form (details). Every org contact topic is +// listed; checking one reveals its notes field and CREATES the ContactTopicAnswer right away +// (POST /contact_topic_answers, storing the new id) so the 2s autosave then only UPDATES it -- +// this is what keeps autosave from writing duplicate answers. Unchecking destroys the answer. +// Unchecked topics keep their fields `disabled` so they never submit an empty answer. +// +// Connects to data-controller="contact-topics" +export default class extends Controller { + static values = { route: String, caseContactId: Number } + static targets = ['dialog'] + + connect () { + this.headers = { 'Content-Type': 'application/json', Accept: 'application/json' } + const token = document.querySelector('meta[name="csrf-token"]') + if (token) { this.headers['X-CSRF-Token'] = token.content } // absent in the test env + } + + toggle (e) { + const group = e.target.closest('[data-topic-group]') + const notes = group.querySelector('[data-topic-notes]') + const fields = notes.querySelectorAll('input, textarea') + const idField = notes.querySelector('input[name*="[id]"]') + const textarea = notes.querySelector('textarea') + + if (e.target.checked) { + fields.forEach(field => { field.disabled = false }) + notes.classList.remove('hidden') + if (!idField.value) { this.create(group, idField) } + textarea.focus() + } else if (textarea.value.trim() && this.hasDialogTarget) { + // Has notes: confirm through the design-system , not a browser confirm(). Keep the + // topic checked until the user confirms; confirmRemove -> removeTopic does the real removal. + e.target.checked = true + this.pendingGroup = group + this.dialogTarget.showModal() + } else { + this.removeTopic(group) + } + } + + // Confirm button inside the removal dialog. + confirmRemove () { + if (this.pendingGroup) { + this.removeTopic(this.pendingGroup) + this.pendingGroup = null + } + this.dialogTarget.close() + } + + removeTopic (group) { + const notes = group.querySelector('[data-topic-notes]') + const idField = notes.querySelector('input[name*="[id]"]') + group.querySelector('input[type="checkbox"]').checked = false + if (idField.value) { this.destroy(idField) } + notes.classList.add('hidden') + notes.querySelectorAll('input, textarea').forEach(field => { field.disabled = true }) + } + + // Checking a topic is a real edit, so it is one of the things that brings the case contact into + // existence: the answer needs a case_contact_id to belong to. + create (group, idField) { + ensureCaseContact(this.element.closest('form')) + .then(caseContactId => this.createAnswer(group, idField, caseContactId)) + .catch(error => console.error('Failed to create the case contact', error.status, error.statusText)) + } + + createAnswer (group, idField, caseContactId) { + const value = group.querySelector('textarea').value + return fetch(this.routeValue, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + contact_topic_answer: { + contact_topic_id: group.dataset.topicId, + case_contact_id: caseContactId, + value + } + }) + }) + .then(response => response.ok ? response.json() : Promise.reject(response)) + .then(data => { idField.value = data.id }) + .catch(error => console.error('Failed to create contact topic answer', error.status, error.statusText)) + } + + destroy (idField) { + fetch(`${this.routeValue}/${idField.value}`, { method: 'DELETE', headers: this.headers }) + .then(response => { if (response.ok) { idField.value = '' } }) + .catch(error => console.error('Failed to destroy contact topic answer', error.status, error.statusText)) + } +} diff --git a/app/javascript/controllers/copy_court_orders_controller.js b/app/javascript/controllers/copy_court_orders_controller.js new file mode 100644 index 0000000000..7337e36fbe --- /dev/null +++ b/app/javascript/controllers/copy_court_orders_controller.js @@ -0,0 +1,45 @@ +import { Controller } from '@hotwired/stimulus' + +// Copy all court orders from a sibling case into the current one. The Copy button is always +// enabled; clicking it with no case selected shows an inline error, otherwise it opens the +// design-system (the `modal` controller centers it) to confirm, then PATCHes +// copy_court_orders and reloads so the copied orders and the flash appear. casa_app only: the +// Bootstrap court-date pages keep the legacy casa_case.js SweetAlert flow. +export default class extends Controller { + static targets = ['select', 'dialog', 'caseNumber', 'error'] + static values = { casaCaseId: String } + + open () { + if (this.selectTarget.value === '') { + if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden') + this.selectTarget.focus() + return + } + this.clearError() + this.caseNumberTarget.textContent = this.selectTarget.value + this.dialogTarget.showModal() + } + + clearError () { + if (this.hasErrorTarget) this.errorTarget.classList.add('hidden') + } + + async confirm () { + const token = document.querySelector('meta[name="csrf-token"]')?.content + const response = await fetch(`/casa_cases/${this.casaCaseIdValue}/copy_court_orders`, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': token, + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ case_number_cp: this.selectTarget.value }) + }) + + if (response.ok) { + window.location.reload() + } else { + this.dialogTarget.close() + } + } +} diff --git a/app/javascript/controllers/court_order_form_controller.js b/app/javascript/controllers/court_order_form_controller.js index 5a6a93d9c4..44aa3485a4 100644 --- a/app/javascript/controllers/court_order_form_controller.js +++ b/app/javascript/controllers/court_order_form_controller.js @@ -1,19 +1,35 @@ import NestedForm from '@stimulus-components/rails-nested-form' -import Swal from 'sweetalert2' +// Court-orders nested sub-form. Extends the shared nested-form controller: `add` +// clones a court-order row (prefilled from the standard-order select), and `remove` +// on an existing (persisted) order asks for confirmation first through the +// design-system (the `modal` controller centers it and handles the backdrop) +// rather than deleting outright. New, unsaved rows are removed without a prompt. export default class extends NestedForm { - // - static targets = ['selectedCourtOrder'] + static targets = ['selectedCourtOrder', 'confirmDialog'] remove (e) { const wrapper = e.target.closest(this.wrapperSelectorValue) if (wrapper.dataset.newRecord !== 'true' && wrapper.dataset.type === 'COURT_ORDER') { - this.removeCourtOrderWithConfirmation(e, wrapper) + e.preventDefault() + this.pendingWrapper = wrapper + this.confirmDialogTarget.showModal() } else { super.remove(e) } } + confirmRemove () { + const wrapper = this.pendingWrapper + if (wrapper) { + wrapper.style.display = 'none' + const destroyInput = wrapper.querySelector("input[name*='_destroy']") + if (destroyInput) destroyInput.value = '1' + this.pendingWrapper = null + } + this.confirmDialogTarget.close() + } + add (e) { super.add(e) const selectedValue = $(this.selectedCourtOrderTarget).val() @@ -23,32 +39,4 @@ export default class extends NestedForm { $textarea.val(selectedValue) } } - - removeCourtOrderWithConfirmation (e, wrapper) { - const text = 'Are you sure you want to remove this court order? Doing so will ' + - 'delete all records of it unless it was included in a previous court report.' - Swal.fire({ - icon: 'warning', - title: 'Delete court order?', - text, - showCloseButton: true, - showCancelButton: true, - focusConfirm: false, - - confirmButtonColor: '#d33', - cancelButtonColor: '#39c', - - confirmButtonText: 'Delete', - cancelButtonText: 'Go back' - }).then((result) => { - if (result.isConfirmed) { - this.removeCourtOrder(e, wrapper) - } - }) - } - - removeCourtOrder (e, wrapper) { - super.remove(e) - wrapper.classList.remove('d-flex') - } } diff --git a/app/javascript/controllers/court_report_controller.js b/app/javascript/controllers/court_report_controller.js new file mode 100644 index 0000000000..ed099c7c32 --- /dev/null +++ b/app/javascript/controllers/court_report_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus' + +// Generates a court report (docx) from the case-show modal. It posts the date +// range to the JSON endpoint (Rails wraps the flat body under case_court_report), +// shows a spinner while the docx is built, then opens the download in a new tab. +// The Tailwind + Stimulus replacement for the legacy jQuery handleGenerateReport. +export default class extends Controller { + static targets = ['form', 'timeZone', 'spinner', 'submit', 'error'] + + connect () { + if (this.hasTimeZoneTarget) { + this.timeZoneTarget.value = Intl.DateTimeFormat().resolvedOptions().timeZone + } + } + + async generate (event) { + event.preventDefault() + const form = this.formTarget + if (!form.reportValidity()) return + + this.setBusy(true) + try { + const response = await window.fetch(form.action, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(Object.fromEntries(new window.FormData(form))) + }) + const data = await response.json() + if (data.status !== 'ok') { + this.showError(data.error_messages) + return + } + window.open(data.link, '_blank') + } catch (error) { + this.showError('Something went wrong generating the report. Please try again.') + } finally { + this.setBusy(false) + } + } + + setBusy (busy) { + if (this.hasSpinnerTarget) this.spinnerTarget.classList.toggle('hidden', !busy) + if (this.hasSubmitTarget) this.submitTarget.disabled = busy + } + + showError (html) { + if (this.hasErrorTarget) { + this.errorTarget.innerHTML = html + this.errorTarget.classList.remove('hidden') + } + } +} diff --git a/app/javascript/controllers/disable_form_controller.js b/app/javascript/controllers/disable_form_controller.js index 7c6b4fdd61..8338a1ab0d 100644 --- a/app/javascript/controllers/disable_form_controller.js +++ b/app/javascript/controllers/disable_form_controller.js @@ -1,29 +1,24 @@ import { Controller } from '@hotwired/stimulus' +// Guards a form submit: if a watched input still holds an unallowed value (e.g. an unselected +// dropdown prompt), block the submit and reveal an inline error + focus the input -- instead of +// leaving a disabled submit button with no explanation. export default class extends Controller { - static targets = ['submitButton', 'input'] - static values = { - unallowed: { type: Array } - } - - static classes = ['disabled', 'enabled'] - - validate () { - let invalid = false - this.inputTargets.forEach(input => { - if (this.unallowedValue.includes(input.value)) { - invalid = true - } - }) + static targets = ['input', 'error'] + static values = { unallowed: { type: Array, default: [] } } - if (invalid) { - this.submitButtonTarget.disabled = true - this.submitButtonTarget.classList.add(this.disabledClass) - this.submitButtonTarget.classList.remove(...this.enabledClasses) - } else { - this.submitButtonTarget.disabled = false - this.submitButtonTarget.classList.remove(this.disabledClass) - this.submitButtonTarget.classList.add(...this.enabledClasses) + guard (event) { + const invalidInput = this.inputTargets.find((input) => this.unallowedValue.includes(input.value)) + if (invalidInput) { + event.preventDefault() + event.stopPropagation() + if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden') + invalidInput.focus() } } + + clearError () { + const invalidInput = this.inputTargets.find((input) => this.unallowedValue.includes(input.value)) + if (!invalidInput && this.hasErrorTarget) this.errorTarget.classList.add('hidden') + } } diff --git a/app/javascript/controllers/disclosure_controller.js b/app/javascript/controllers/disclosure_controller.js new file mode 100644 index 0000000000..0654834d90 --- /dev/null +++ b/app/javascript/controllers/disclosure_controller.js @@ -0,0 +1,23 @@ +import { Controller } from '@hotwired/stimulus' + +// Progressive disclosure for a collapsible panel (e.g. the Change Password / +// Change Email sections on the edit-profile page). The panel starts hidden and +// the trigger button toggles it. +export default class extends Controller { + static targets = ['panel', 'trigger', 'field'] + + toggle () { + const hidden = this.panelTarget.classList.toggle('hidden') + + if (this.hasTriggerTarget) { + this.triggerTarget.setAttribute('aria-expanded', String(!hidden)) + } + + // Optional `field` target: a hidden input that carries the open state through a form submit. + // Without it, a panel inside a form that re-renders (an auto-submitting filter bar, or a + // validation failure) has its state re-derived server-side and snaps shut under the user. + if (this.hasFieldTarget) { + this.fieldTarget.value = hidden ? '' : '1' + } + } +} diff --git a/app/javascript/controllers/dismiss_controller.js b/app/javascript/controllers/dismiss_controller.js index fecbf7353e..b2972e95ce 100644 --- a/app/javascript/controllers/dismiss_controller.js +++ b/app/javascript/controllers/dismiss_controller.js @@ -13,7 +13,7 @@ export default class extends Controller { .then(response => response.json()) .then(data => { if (data.status === 'ok') { - this.elementTarget.classList.add('d-none') + this.elementTarget.classList.add('d-none', 'hidden') } }) } diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000000..6a5c37d747 --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,44 @@ +import { Controller } from '@hotwired/stimulus' + +// Enhances a native
disclosure used as a menu: +// - only one dropdown is open at a time (opening one closes the others), +// - it closes on an outside click and on Escape (returning focus to the summary). +// Opening/closing is still the browser's native
toggle, so this is +// progressive enhancement — the menu works without JS. +export default class extends Controller { + connect () { + this.closeSiblings = this.closeSiblings.bind(this) + this.closeOnOutsideClick = this.closeOnOutsideClick.bind(this) + this.closeOnEscape = this.closeOnEscape.bind(this) + this.element.addEventListener('toggle', this.closeSiblings) + document.addEventListener('click', this.closeOnOutsideClick) + this.element.addEventListener('keydown', this.closeOnEscape) + } + + disconnect () { + this.element.removeEventListener('toggle', this.closeSiblings) + document.removeEventListener('click', this.closeOnOutsideClick) + this.element.removeEventListener('keydown', this.closeOnEscape) + } + + // When this menu opens, close every other open dropdown so only one is open. + closeSiblings () { + if (!this.element.open) return + document.querySelectorAll('details[data-controller~="dropdown"][open]').forEach((other) => { + if (other !== this.element) other.open = false + }) + } + + closeOnOutsideClick (event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.open = false + } + } + + closeOnEscape (event) { + if (event.key === 'Escape' && this.element.open) { + this.element.open = false + this.element.querySelector('summary')?.focus() + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 7bb572bdfb..9f6b7c19a1 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -1,53 +1,95 @@ // This file is auto-generated by ./bin/rails stimulus:manifest:update // Run that command whenever you add a new controller or create them with // ./bin/rails generate stimulus controllerName -import RailsNestedForm from '@stimulus-components/rails-nested-form' -import { application } from './application' +import { application } from "./application" -import AlertController from './alert_controller' +import AddToCalendarController from "./add_to_calendar_controller" +application.register("add-to-calendar", AddToCalendarController) -import AutosaveController from './autosave_controller' +import AlertController from "./alert_controller" +application.register("alert", AlertController) -import CasaNestedFormController from './casa_nested_form_controller' +import AutoSubmitController from "./auto_submit_controller" +application.register("auto-submit", AutoSubmitController) -import CaseContactFormController from './case_contact_form_controller' +import AutosaveController from "./autosave_controller" +application.register("autosave", AutosaveController) -import CourtOrderFormController from './court_order_form_controller' +import CasaNestedFormController from "./casa_nested_form_controller" +application.register("casa-nested-form", CasaNestedFormController) -import DisableFormController from './disable_form_controller' +import CaseContactFormController from "./case_contact_form_controller" +application.register("case-contact-form", CaseContactFormController) -import DismissController from './dismiss_controller' +import ChartHoverController from "./chart_hover_controller" +application.register("chart-hover", ChartHoverController) -import HelloController from './hello_controller' +import ContactTopicsController from "./contact_topics_controller" +application.register("contact-topics", ContactTopicsController) -import IconToggleController from './icon_toggle_controller' +import CopyCourtOrdersController from "./copy_court_orders_controller" +application.register("copy-court-orders", CopyCourtOrdersController) -import MultipleSelectController from './multiple_select_controller' +import CourtOrderFormController from "./court_order_form_controller" +application.register("court-order-form", CourtOrderFormController) -import NavbarController from './navbar_controller' +import CourtReportController from "./court_report_controller" +application.register("court-report", CourtReportController) -import SelectAllController from './select_all_controller' +import DisableFormController from "./disable_form_controller" +application.register("disable-form", DisableFormController) -import SidebarController from './sidebar_controller' +import DisclosureController from "./disclosure_controller" +application.register("disclosure", DisclosureController) -import SidebarGroupController from './sidebar_group_controller' +import DismissController from "./dismiss_controller" +application.register("dismiss", DismissController) -import TruncatedTextController from './truncated_text_controller' +import DropdownController from "./dropdown_controller" +application.register("dropdown", DropdownController) -application.register('nested-form', RailsNestedForm) -application.register('alert', AlertController) -application.register('autosave', AutosaveController) -application.register('casa-nested-form', CasaNestedFormController) -application.register('case-contact-form', CaseContactFormController) -application.register('court-order-form', CourtOrderFormController) -application.register('disable-form', DisableFormController) -application.register('dismiss', DismissController) -application.register('hello', HelloController) -application.register('icon-toggle', IconToggleController) -application.register('multiple-select', MultipleSelectController) -application.register('navbar', NavbarController) -application.register('select-all', SelectAllController) -application.register('sidebar', SidebarController) -application.register('sidebar-group', SidebarGroupController) -application.register('truncated-text', TruncatedTextController) +import HelloController from "./hello_controller" +application.register("hello", HelloController) + +import IconToggleController from "./icon_toggle_controller" +application.register("icon-toggle", IconToggleController) + +import LocalStorageResetController from "./local_storage_reset_controller" +application.register("local-storage-reset", LocalStorageResetController) + +import ModalController from "./modal_controller" +application.register("modal", ModalController) + +import MultipleSelectController from "./multiple_select_controller" +application.register("multiple-select", MultipleSelectController) + +import NavDrawerController from "./nav_drawer_controller" +application.register("nav-drawer", NavDrawerController) + +import NavbarController from "./navbar_controller" +application.register("navbar", NavbarController) + +import RevealController from "./reveal_controller" +application.register("reveal", RevealController) + +import SearchableSelectController from "./searchable_select_controller" +application.register("searchable-select", SearchableSelectController) + +import SelectAllController from "./select_all_controller" +application.register("select-all", SelectAllController) + +import SettingsNavController from "./settings_nav_controller" +application.register("settings-nav", SettingsNavController) + +import SidebarController from "./sidebar_controller" +application.register("sidebar", SidebarController) + +import SidebarGroupController from "./sidebar_group_controller" +application.register("sidebar-group", SidebarGroupController) + +import TruncatedTextController from "./truncated_text_controller" +application.register("truncated-text", TruncatedTextController) + +import TwilioController from "./twilio_controller" +application.register("twilio", TwilioController) diff --git a/app/javascript/controllers/local_storage_reset_controller.js b/app/javascript/controllers/local_storage_reset_controller.js new file mode 100644 index 0000000000..146c6ed843 --- /dev/null +++ b/app/javascript/controllers/local_storage_reset_controller.js @@ -0,0 +1,12 @@ +import { Controller } from '@hotwired/stimulus' + +// Removes a localStorage key on connect, e.g. discarding a saved case-contact +// draft once the contact has been created (this controller is rendered only on +// the success redirect). +export default class extends Controller { + static values = { key: String } + + connect () { + if (this.keyValue) window.localStorage.removeItem(this.keyValue) + } +} diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 0000000000..2ddf7f4a4f --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,28 @@ +import { Controller } from '@hotwired/stimulus' + +// A modal built on the native element. `open` calls showModal(), which +// traps focus, wires Escape-to-close, and makes the background inert; `close` +// closes it; a click on the backdrop (the itself, outside its panel) +// also closes it. No custom focus-trap or key handling to maintain. +export default class extends Controller { + static targets = ['dialog'] + static values = { openOnConnect: Boolean } + + connect () { + if (this.openOnConnectValue) this.open() + } + + open () { + this.dialogTarget.showModal() + } + + close () { + this.dialogTarget.close() + } + + backdropClose (event) { + if (event.target === this.dialogTarget) { + this.close() + } + } +} diff --git a/app/javascript/controllers/multiple_select_controller.js b/app/javascript/controllers/multiple_select_controller.js index 739c4dcaa6..a711acf08e 100644 --- a/app/javascript/controllers/multiple_select_controller.js +++ b/app/javascript/controllers/multiple_select_controller.js @@ -1,12 +1,26 @@ import { Controller } from '@hotwired/stimulus' import TomSelect from 'tom-select' +// Open the dropdown above the control when there isn't room below, so a field +// near the bottom of the page keeps its menu on-screen. `this` is the TomSelect +// instance when these run. +function onDropdownOpen (dropdown) { + const rect = this.control.getBoundingClientRect() + const needed = dropdown.offsetHeight || 240 + this.wrapper.classList.toggle('ts-flip-up', window.innerHeight - rect.bottom < needed && rect.top > needed) +} +function onDropdownClose () { + this.wrapper.classList.remove('ts-flip-up') +} + export default class extends Controller { static targets = ['select', 'option', 'item', 'hiddenItem', 'selectAllOption'] static values = { options: Array, selectedItems: Array, withOptions: Boolean, + submitOnClose: Boolean, + placeholder: String, placeholderTerm: { type: String, default: 'contact(s)' @@ -15,22 +29,112 @@ export default class extends Controller { } connect () { + // Read the native . + this.accessibleName = this.nativeAccessibleName() if (this.withOptionsValue) { this.createMultiSelectWithOptionGroups() } else { this.createBasicMultiSelect() } + this.nameNativeSelect() + if (this.submitOnCloseValue) this.deferSubmitUntilDropdownCloses() + } + + // An auto-submitting filter re-renders the whole page on `change`, which tears down the open menu: + // picking five contact types meant five page loads and five reopenings of the dropdown. Hold the + // submit while the menu is open and fire it once on close, so a selection session costs one render. + // A change with the menu already shut (the clear-all x) still submits immediately. + deferSubmitUntilDropdownCloses () { + const select = this.tomSelect + let pending = false + + select.on('change', () => { + if (select.isOpen) { + pending = true + } else { + this.submitForm() + } + }) + + select.on('dropdown_close', () => { + if (!pending) return + pending = false + this.submitForm() + }) + } + + submitForm () { + const form = this.element.closest('form') + if (!form) return + + // form.submit(), NOT requestSubmit(). The other filters on this bar submit through a jQuery + // handler that bypasses Turbo, so they re-render the whole page. requestSubmit() fires a real + // submit event, which Turbo intercepts and -- because the form targets a turbo-frame -- scopes to + // the RESULTS only. The card's Clear action and filter count live outside that frame, so they + // went stale after a multiselect change while every other filter updated them: the same bar + // behaving two different ways. Native submit keeps all of them consistent. + form.submit() + } + + // Distinct non-blank groups, in the order the options arrive (the Select/Unselect all pseudo-option + // carries a blank group and stays ungrouped, above the first header). + optgroupsFrom (options) { + const seen = [] + options.forEach((option) => { + if (option.group && !seen.includes(option.group)) seen.push(option.group) + }) + return seen.map((group) => ({ value: group, label: group })) + } + + // Re-name the native nameless -- and it stays in the accessibility tree + // (.ts-hidden-accessible clips it rather than display:none), so axe's select-name rule fails + // (critical). Stamping the pre-init name on as an aria-label gives it a name TomSelect can't take. + nameNativeSelect () { + if (this.accessibleName && !this.selectTarget.getAttribute('aria-label')) { + this.selectTarget.setAttribute('aria-label', this.accessibleName) + } + } + + // The native labelled only by aria-label (the rich + // Form::MultipleSelectComponent) doesn't render an input named only by its placeholder. + labelControlInput (tomSelect) { + if (this.accessibleName && tomSelect.control_input) { + tomSelect.control_input.setAttribute('aria-label', this.accessibleName) + } } createBasicMultiSelect () { - /* eslint-disable no-new */ - new TomSelect(this.selectTarget, { + const settings = { plugins: { remove_button: { title: 'Remove this item' + }, + // Clear-all inside the control, matching the searchable single-select. remove_button only + // gives a per-chip x, which is a chip-at-a-time chore once several are picked. + clear_button: { + title: 'Clear all selections' } - } - }) + }, + onDropdownOpen, + onDropdownClose + } + // A blank-load filter shows a placeholder ("Select or search supervisors", ...) until an item is + // picked; hidePlaceholder clears the prompt once a chip exists (industry standard -- a lingering + // placeholder next to selected chips reads as unfinished). + if (this.placeholderValue) { + settings.placeholder = this.placeholderValue + settings.hidePlaceholder = true + } + this.tomSelect = new TomSelect(this.selectTarget, settings) + this.labelControlInput(this.tomSelect) } createMultiSelectWithOptionGroups () { @@ -55,15 +159,18 @@ export default class extends Controller { let initItems = this.selectedItemsValue if (showAllOptionCheck) { const emptyItem = [' '] - initItems = hasInitialItems ? emptyItem.concat(this.selectedItemsValue) : orderedOptionVals + // Load blank (placeholder) when nothing is pre-selected; the dropdown's + // "Select/Unselect all" still selects everything on demand. + initItems = hasInitialItems ? emptyItem.concat(this.selectedItemsValue) : [] } const dropdownOptions = showAllOptionCheck ? [{ text: 'Select/Unselect all', subtext: '', value: ' ', group: '' }].concat(this.optionsValue) : this.optionsValue - /* eslint-disable no-new */ - new TomSelect(this.selectTarget, { + const select = new TomSelect(this.selectTarget, { + onDropdownOpen, + onDropdownClose, onItemRemove: function (value) { if (value === ' ') { this.clear() @@ -83,12 +190,21 @@ export default class extends Controller { className: 'btn text-white rounded-circle', label: '' }, + clear_button: { + title: 'Clear all selections' + }, checkbox_options: { checkedClassNames: ['form-check-input', 'form-check-input--checked'], uncheckedClassNames: ['form-check-input', 'form-check-input--unchecked'] } }, options: dropdownOptions, + // Render the groups the options already carry. Without optgroups/optgroupField TomSelect uses + // `group` for SEARCH only and shows a flat list, so this menu threw its grouping away while the + // filter's showed it -- the same data, two different menus. + optgroups: this.optgroupsFrom(dropdownOptions), + optgroupField: 'group', + lockOptgroupOrder: true, items: initItems, placeholder, hidePlaceholder: true, @@ -110,5 +226,7 @@ export default class extends Controller { } } }) + this.tomSelect = select + this.labelControlInput(select) } } diff --git a/app/javascript/controllers/nav_drawer_controller.js b/app/javascript/controllers/nav_drawer_controller.js new file mode 100644 index 0000000000..5867c62b56 --- /dev/null +++ b/app/javascript/controllers/nav_drawer_controller.js @@ -0,0 +1,27 @@ +import { Controller } from '@hotwired/stimulus' + +// Mobile navigation drawer for the casa_app + all_casa_admin shells. Below the +// `lg` breakpoint the sidebar is off-canvas (translated -100%); this toggles it +// in/out with a dimmed backdrop and locks body scroll while open. On `lg+` the +// sidebar is static and the toggle button is hidden, so this is inert there. +// Replaces the inline +<%# Thank-you dialog (auto-opens on the success redirect; also clears the saved draft) %> +<% if params[:success].present? %> + <%= render Dialog::GroupComponent.new(label: "Case contact created", open_on_connect: true, controllers: "local-storage-reset", data: {local_storage_reset_key_value: "casa-contact-form"}) do %> + <%= render Dialog::BodyComponent.new(centered: true) do %> +
+

Case contact created

+

<%= thank_you_message %>

+ <% case when_do_we_have_court_dates(@casa_case) + when "future" %> +

Please enter the previous court date if you know it so court reports can be generated with the correct case contacts.

+ <% when "past" %> +

Please enter the next court date if you know it so court reports can be generated with the correct case contacts.

+ <% when "none" %> +

Please enter any past or future court dates if you know them so court reports can be generated with the correct case contacts.

+ <% end %> + <% end %> + <%= render Dialog::FooterComponent.new(align: :center) do %> + + <% end %> + <% end %> +<% end %> diff --git a/app/views/casa_org/_contact_topics.html.erb b/app/views/casa_org/_contact_topics.html.erb index 747435379f..9becfca6ba 100644 --- a/app/views/casa_org/_contact_topics.html.erb +++ b/app/views/casa_org/_contact_topics.html.erb @@ -1,65 +1,33 @@ -
-
-
-
-
-

Contact Topics

-
-
-
containing a link that toggles the + # sort direction, plus a double-caret indicator whose active half is brand-coloured. + # Sorting is server-side, so the link preserves the current query (filters) and + # resets the page. `column` must be whitelisted by the controller. + def sortable_header(label, column, sort:, direction:) + active = column.to_s == sort.to_s + state = active ? direction : "none" + aria = {"asc" => "ascending", "desc" => "descending"}.fetch(state, "none") + next_direction = (active && direction == "asc") ? "desc" : "asc" + href = url_for(request.query_parameters.merge("sort" => column.to_s, "direction" => next_direction).except("page")) + # items-start (not items-center) + a one-line-tall caret box keeps the caret pinned to the + # first line of the label, so every column's caret shares a baseline even when a long label + # wraps to two lines (items-center would re-center the caret on the taller wrapped label, + # leaving the row of carets jagged). + link_class = "inline-flex items-start rounded text-xs font-semibold #{active ? "text-slate-900" : "text-slate-600"} hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" + content_tag(:th, class: "px-4 py-3 text-left align-top", "aria-sort": aria) do + link_to(href, class: link_class) { safe_join([label, sort_caret(state)]) } + end + end + + def sort_caret(state) + up = caret_color(state, "asc") + down = caret_color(state, "desc") + raw(%()) + end + + # Brand for the active half, light for the other half of a sorted column, mid when unsorted. + def caret_color(state, half) + return "#94a3b8" if state == "none" + (state == half) ? "#4f46e5" : "#cbd5e1" + end +end diff --git a/app/javascript/__tests__/add_to_calendar_button.test.js b/app/javascript/__tests__/add_to_calendar_button.test.js deleted file mode 100644 index a9bfc26d5d..0000000000 --- a/app/javascript/__tests__/add_to_calendar_button.test.js +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-env jest */ -/** - * @jest-environment jsdom - */ - -require('jest') - -// Mock the add-to-calendar-button module -jest.mock('add-to-calendar-button', () => ({})) - -describe('add_to_calendar_button', () => { - let mockJQuery - - beforeEach(() => { - // Clear the document body - document.body.innerHTML = '' - - // Mock jQuery - mockJQuery = jest.fn((callback) => { - if (typeof callback === 'function') { - callback() - } - }) - global.$ = mockJQuery - }) - - afterEach(() => { - jest.resetModules() - delete global.$ - }) - - const createCalendarButton = (dataset = {}) => { - const div = document.createElement('div') - div.className = 'cal-btn' - Object.assign(div.dataset, { - title: 'Court Hearing', - start: '2025-11-15', - end: '2025-11-15', - tooltip: 'Add to calendar', - ...dataset - }) - return div - } - - describe('createCalendarEvents', () => { - test('creates add-to-calendar-button elements for all cal-btn divs', () => { - // Setup - const calBtn1 = createCalendarButton() - const calBtn2 = createCalendarButton({ - title: 'Court Date 2', - start: '2025-12-01', - end: '2025-12-01' - }) - document.body.appendChild(calBtn1) - document.body.appendChild(calBtn2) - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const buttons = document.querySelectorAll('add-to-calendar-button') - expect(buttons.length).toBe(2) - }) - - test('sets correct attributes on the calendar button', () => { - // Setup - const calBtn = createCalendarButton({ - title: 'Important Meeting', - start: '2025-11-20', - end: '2025-11-20', - tooltip: 'Add this event' - }) - document.body.appendChild(calBtn) - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const button = document.querySelector('add-to-calendar-button') - expect(button.getAttribute('name')).toBe('Important Meeting') - expect(button.getAttribute('startDate')).toBe('2025-11-20') - expect(button.getAttribute('endDate')).toBe('2025-11-20') - expect(button.getAttribute('description')).toBe('Important Meeting') - expect(button.getAttribute('options')).toBe("'Apple','Google','iCal','Microsoft365','Outlook.com','Yahoo'") - expect(button.getAttribute('timeZone')).toBe('currentBrowser') - expect(button.getAttribute('lightMode')).toBe('bodyScheme') - expect(button.title).toBe('Add this event') - }) - - test('clears existing content in cal-btn div', () => { - // Setup - const calBtn = createCalendarButton() - calBtn.innerHTML = '

Old content

' - document.body.appendChild(calBtn) - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const oldContent = calBtn.querySelector('p') - expect(oldContent).toBeNull() - const button = calBtn.querySelector('add-to-calendar-button') - expect(button).not.toBeNull() - }) - - test('handles empty dataset gracefully', () => { - // Setup - const div = document.createElement('div') - div.className = 'cal-btn' - document.body.appendChild(div) - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const button = document.querySelector('add-to-calendar-button') - expect(button).not.toBeNull() - expect(button.getAttribute('name')).toBe('undefined') - expect(button.getAttribute('startDate')).toBe('undefined') - expect(button.getAttribute('endDate')).toBe('undefined') - }) - - test('does nothing when no cal-btn elements exist', () => { - // Setup - document.body.innerHTML = '
' - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const buttons = document.querySelectorAll('add-to-calendar-button') - expect(buttons.length).toBe(0) - }) - - test('calls createCalendarEvents on DOM ready', () => { - // Setup - const calBtn = createCalendarButton() - document.body.appendChild(calBtn) - - // Execute - require('../src/add_to_calendar_button') - - // Verify jQuery was called - expect(mockJQuery).toHaveBeenCalled() - expect(mockJQuery).toHaveBeenCalledWith(expect.any(Function)) - - // Verify the calendar button was created - const button = document.querySelector('add-to-calendar-button') - expect(button).not.toBeNull() - }) - - test('preserves cal-btn class on parent div', () => { - // Setup - const calBtn = createCalendarButton() - document.body.appendChild(calBtn) - - // Execute - require('../src/add_to_calendar_button') - - // Verify - const divs = document.querySelectorAll('div.cal-btn') - expect(divs.length).toBe(1) - expect(divs[0].querySelector('add-to-calendar-button')).not.toBeNull() - }) - }) -}) diff --git a/app/javascript/__tests__/add_to_calendar_controller.test.js b/app/javascript/__tests__/add_to_calendar_controller.test.js new file mode 100644 index 0000000000..0034eb2955 --- /dev/null +++ b/app/javascript/__tests__/add_to_calendar_controller.test.js @@ -0,0 +1,58 @@ +/* eslint-env jest, browser */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import AddToCalendarController from '../controllers/add_to_calendar_controller' + +describe('add_to_calendar_controller', () => { + let application + let capturedIcs + let clicked + + const mount = async (html) => { + document.body.innerHTML = html + application = Application.start() + application.register('add-to-calendar', AddToCalendarController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + beforeEach(() => { + capturedIcs = null + clicked = false + const RealBlob = global.Blob + jest.spyOn(global, 'Blob').mockImplementation((parts, opts) => { + capturedIcs = parts[0] + return new RealBlob(parts, opts) + }) + global.URL.createObjectURL = jest.fn(() => 'blob:mock') + global.URL.revokeObjectURL = jest.fn() + jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { clicked = true }) + }) + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + jest.restoreAllMocks() + }) + + test('downloads an .ics event built from the court-date values', async () => { + await mount(` + + `) + + document.querySelector('button').click() + + expect(clicked).toBe(true) + expect(global.URL.createObjectURL).toHaveBeenCalled() + expect(capturedIcs).toContain('BEGIN:VCALENDAR') + expect(capturedIcs).toContain('SUMMARY:Court Date CINA-1') + expect(capturedIcs).toContain('DTSTART;VALUE=DATE:20251115') + expect(capturedIcs).toContain('DTEND;VALUE=DATE:20251116') + expect(capturedIcs).toContain('END:VEVENT') + }) +}) diff --git a/app/javascript/__tests__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js deleted file mode 100644 index 3803cc4052..0000000000 --- a/app/javascript/__tests__/dashboard.test.js +++ /dev/null @@ -1,697 +0,0 @@ -/* eslint-env jest */ -/** - * @jest-environment jsdom - */ - -import Swal from 'sweetalert2' -import { defineCaseContactsTable } from '../src/dashboard' -import { fireSwalFollowupAlert } from '../src/case_contact' -jest.mock('sweetalert2', () => ({ - __esModule: true, - default: { fire: jest.fn() } -})) - -jest.mock('../src/case_contact', () => ({ - fireSwalFollowupAlert: jest.fn() -})) - -// Mock DataTable -const mockDataTable = jest.fn() -$.fn.DataTable = mockDataTable - -describe('defineCaseContactsTable', () => { - let tableElement - - beforeEach(() => { - // Reset mocks - mockDataTable.mockClear() - - // Set up DOM - document.body.innerHTML = ` - - - - - - - - - - - - - - - - - -
DateCaseRelationshipMediumCreated ByContactedTopicsDraft
- ` - - tableElement = $('table#case_contacts') - }) - - describe('DataTable initialization', () => { - it('initializes DataTable on the case_contacts table', () => { - defineCaseContactsTable() - - expect(mockDataTable).toHaveBeenCalledTimes(1) - expect(mockDataTable.mock.instances[0][0]).toBe(tableElement[0]) - }) - - it('configures DataTable with server-side processing', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.serverSide).toBe(true) - expect(config.processing).toBe(true) - expect(config.searching).toBe(true) - }) - - it('disables autoWidth so columns do not expand to oversized fixed widths', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.autoWidth).toBe(false) - expect(config.scrollX).toBeUndefined() - }) - - it('sets default sort to Date column descending', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.order).toEqual([[2, 'desc']]) - }) - - it('disables ordering on bell, chevron, and ellipsis columns', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.columnDefs).toEqual([ - { orderable: false, targets: [0, 1, 10] } - ]) - }) - }) - - describe('AJAX configuration', () => { - it('uses the data-source URL from the table', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.ajax.url).toBe('/case_contacts/new_design/datatable.json') - expect(config.ajax.type).toBe('POST') - expect(config.ajax.dataType).toBe('json') - }) - - it('includes error handler for AJAX requests', () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() - - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - const mockError = 'Network error' - const mockCode = 500 - - config.ajax.error({}, mockError, mockCode) - - expect(consoleErrorSpy).toHaveBeenCalledWith('DataTable error:', mockError, mockCode) - - consoleErrorSpy.mockRestore() - }) - }) - - describe('column configurations', () => { - let columns - - beforeEach(() => { - defineCaseContactsTable() - columns = mockDataTable.mock.calls[0][0].columns - }) - - it('configures 11 columns', () => { - expect(columns).toHaveLength(11) - }) - - describe('Bell icon column (index 0)', () => { - it('is not orderable or searchable', () => { - expect(columns[0].orderable).toBe(false) - expect(columns[0].searchable).toBe(false) - }) - - it('renders filled bell icon when has_followup is "true"', () => { - const rendered = columns[0].render('true', 'display', {}) - - expect(rendered).toBe('') - }) - - it('renders faded bell icon when has_followup is "false"', () => { - const rendered = columns[0].render('false', 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Chevron icon column (index 1)', () => { - it('is not orderable or searchable', () => { - expect(columns[1].orderable).toBe(false) - expect(columns[1].searchable).toBe(false) - }) - - it('renders chevron-down icon as an accessible button', () => { - const rendered = columns[1].render(null, 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Date column (index 2)', () => { - it('uses occurred_at data field', () => { - expect(columns[2].data).toBe('occurred_at') - expect(columns[2].name).toBe('occurred_at') - }) - - it('renders date string or empty string', () => { - expect(columns[2].render('January 15, 2024')).toBe('January 15, 2024') - expect(columns[2].render(null)).toBe('') - expect(columns[2].render('')).toBe('') - }) - }) - - describe('Case column (index 3)', () => { - it('is not orderable', () => { - expect(columns[3].orderable).toBe(false) - }) - - it('renders link to casa_case when data exists', () => { - const data = { id: '123', case_number: 'CASA-2024-001' } - const rendered = columns[3].render(data, 'display', {}) - - expect(rendered).toBe('CASA-2024-001') - }) - - it('renders empty string when casa_case is null', () => { - expect(columns[3].render(null, 'display', {})).toBe('') - }) - - it('renders empty string when casa_case has no id', () => { - const data = { id: null, case_number: 'CASA-2024-001' } - - expect(columns[3].render(data, 'display', {})).toBe('') - }) - }) - - describe('Relationship (Contact Types) column (index 4)', () => { - it('is not orderable', () => { - expect(columns[4].orderable).toBe(false) - }) - - it('renders contact types string', () => { - expect(columns[4].render('Family, School')).toBe('Family, School') - expect(columns[4].render(null)).toBe('') - }) - }) - - describe('Medium column (index 5)', () => { - it('renders medium type', () => { - expect(columns[5].render('In-person')).toBe('In-person') - expect(columns[5].render('Text/Email')).toBe('Text/Email') - expect(columns[5].render(null)).toBe('') - }) - }) - - describe('Created By column (index 6)', () => { - it('is not orderable', () => { - expect(columns[6].orderable).toBe(false) - }) - - it('renders empty string when creator is null', () => { - expect(columns[6].render(null, 'display', {})).toBe('') - }) - - it('renders link to volunteer edit page for volunteers', () => { - const data = { - id: '456', - display_name: 'John Doe', - role: 'Volunteer' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('John Doe') - }) - - it('renders link to supervisor edit page for supervisors', () => { - const data = { - id: '789', - display_name: 'Jane Smith', - role: 'Supervisor' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('Jane Smith') - }) - - it('renders link to users edit page for casa admins', () => { - const data = { - id: '999', - display_name: 'Admin User', - role: 'Casa Admin' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('Admin User') - }) - }) - - describe('Contacted column (index 7)', () => { - it('is not orderable', () => { - expect(columns[7].orderable).toBe(false) - }) - - it('renders checkmark icon when contact was made', () => { - const row = { contact_made: 'true', duration_minutes: null } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('') - }) - - it('renders cross icon when contact was not made', () => { - const row = { contact_made: 'false', duration_minutes: null } - const rendered = columns[7].render('false', 'display', row) - - expect(rendered).toContain('') - }) - - it('includes formatted duration when present', () => { - const row = { contact_made: 'true', duration_minutes: 90 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(01:30)') - }) - - it('formats duration with leading zeros', () => { - const row = { contact_made: 'true', duration_minutes: 5 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(00:05)') - }) - - it('handles hours and minutes correctly', () => { - const row = { contact_made: 'true', duration_minutes: 125 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(02:05)') - }) - - it('does not include duration when not present', () => { - const row = { contact_made: 'true', duration_minutes: null } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).not.toContain('(') - }) - }) - - describe('Topics column (index 8)', () => { - it('is not orderable', () => { - expect(columns[8].orderable).toBe(false) - }) - - it('renders each topic as a pill badge', () => { - const rendered = columns[8].render(['Topic 1', 'Topic 2']) - expect(rendered).toContain('Topic 1') - expect(rendered).toContain('Topic 2') - }) - - it('renders empty string when there are no topics', () => { - expect(columns[8].render(null)).toBe('') - expect(columns[8].render([])).toBe('') - }) - - it('shows only the first two topics when there are more than two', () => { - const rendered = columns[8].render(['A', 'B', 'C', 'D']) - expect(rendered).toContain('>A<') - expect(rendered).toContain('>B<') - expect(rendered).not.toContain('>C<') - expect(rendered).not.toContain('>D<') - }) - - it('shows a +N More badge for overflow topics', () => { - const rendered = columns[8].render(['A', 'B', 'C', 'D']) - expect(rendered).toContain('+2 More') - }) - - it('does not show an overflow badge when there are two or fewer topics', () => { - expect(columns[8].render(['A', 'B'])).not.toContain('More') - expect(columns[8].render(['A'])).not.toContain('More') - }) - }) - - describe('Draft column (index 9)', () => { - it('is not orderable', () => { - expect(columns[9].orderable).toBe(false) - }) - - it('renders Draft badge when is_draft is true', () => { - const rendered = columns[9].render(true, 'display', {}) - - expect(rendered).toBe('Draft') - }) - - it('renders empty string when is_draft is false', () => { - const rendered = columns[9].render(false, 'display', {}) - - expect(rendered).toBe('') - }) - - it('handles string "true" as truthy', () => { - const rendered = columns[9].render('true', 'display', {}) - - expect(rendered).toBe('Draft') - }) - - it('handles string "false" as falsy (explicit check for "true")', () => { - const rendered = columns[9].render('false', 'display', {}) - - // With explicit check for === true || === "true", string "false" should not render badge - expect(rendered).toBe('') - }) - - it('handles empty string as falsy', () => { - const rendered = columns[9].render('', 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Ellipsis menu column (index 10)', () => { - it('is not orderable or searchable', () => { - expect(columns[10].orderable).toBe(false) - expect(columns[10].searchable).toBe(false) - }) - - it('renders a button toggle with aria-label containing the contact date', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('class="fas fa-ellipsis-v"') - expect(rendered).toContain('aria-label="Actions for case contact on July 01, 2024"') - expect(rendered).toContain('type="button"') - }) - - it('renders the ellipsis icon as aria-hidden', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('aria-hidden="true"') - }) - - it('renders Edit item when can_edit is "true"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('href="/case_contacts/1/edit"') - expect(rendered).toContain('Edit') - }) - - it('renders Edit as disabled when can_edit is "false"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('Edit') - expect(rendered).toContain('disabled') - expect(rendered).toContain('aria-disabled="true"') - expect(rendered).not.toContain('href="/case_contacts/1/edit"') - }) - - it('renders Delete item when can_destroy is "true"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-delete-action') - expect(rendered).toContain('data-id="1"') - expect(rendered).toContain('Delete') - }) - - it('renders Delete as disabled when can_destroy is "false"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('Delete') - expect(rendered).toContain('disabled') - expect(rendered).toContain('aria-disabled="true"') - expect(rendered).not.toContain('cc-delete-action') - }) - - it('renders Set Reminder when followup_id is empty', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-set-reminder-action') - expect(rendered).toContain('Set Reminder') - expect(rendered).not.toContain('Resolve Reminder') - }) - - it('renders Resolve Reminder when followup_id is present', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '42' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-resolve-reminder-action') - expect(rendered).toContain('data-followup-id="42"') - expect(rendered).toContain('Resolve Reminder') - expect(rendered).not.toContain('Set Reminder') - }) - - it('always renders the reminder menu item', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toMatch(/Set Reminder|Resolve Reminder/) - }) - }) - }) - - describe('click handlers', () => { - let mockAjaxReload - let mockTableInstance - - const clickActionButton = (action, attrs = {}) => { - const dataAttrs = Object.entries(attrs).map(([k, v]) => `data-${k}="${v}"`).join(' ') - $('table#case_contacts tbody').append( - `
- - - - - - - - - <% @contact_topics.each do |contact_topic| %> - <% id = "contact_topic-#{contact_topic.id}" %> - - - - - - - <%= render(Modal::GroupComponent.new(id: id)) do |component| %> - <% component.with_header(text: "Delete Contact Topic?", id: id) %> - <% component.with_body(text: [ - "This topic and its related questions will be deleted and will no longer be presented while filling out case contacts.", - "This will not affect case contacts that have already been created."]) %> - <% component.with_footer do %> - <%= link_to soft_delete_contact_topic_path(contact_topic), method: :delete, - class: "btn-sm main-btn danger-btn btn-hover ms-auto" do %> - - Delete Court Report Topic - <% end %> - <% end %> - <% end %> - <% end %> - -
QuestionDetailsActive?
- <%= contact_topic.question %> - <%= contact_topic.details %> - <%= contact_topic.active ? "Yes" : "No" %> - - <%= render(DropdownMenuComponent.new(menu_title: "Actions Menu", hide_label: true)) do %> -
  • <%= link_to "Edit", edit_contact_topic_path(contact_topic), class: "dropdown-item" %>
  • -
  • <%= render(Modal::OpenLinkComponent.new(text: "Delete", target: id, klass: "dropdown-item")) %>
  • - <% end %> -
    - - + <% end %> diff --git a/app/views/casa_org/_contact_type_groups.html.erb b/app/views/casa_org/_contact_type_groups.html.erb index da99dab0f0..0575b2bd30 100644 --- a/app/views/casa_org/_contact_type_groups.html.erb +++ b/app/views/casa_org/_contact_type_groups.html.erb @@ -1,57 +1,46 @@ -
    -
    -
    -
    -
    -

    Contact Type Groups

    -
    -
    - diff --git a/app/views/casa_org/_contact_types.html.erb b/app/views/casa_org/_contact_types.html.erb index 1dd6b6e0e4..85c2eac56b 100644 --- a/app/views/casa_org/_contact_types.html.erb +++ b/app/views/casa_org/_contact_types.html.erb @@ -1,57 +1,52 @@ -
    -
    -
    -
    -
    -

    Contact Types

    -
    -
    - diff --git a/app/views/casa_org/_custom_org_links.html.erb b/app/views/casa_org/_custom_org_links.html.erb index 3d73f663e6..2cfd501c31 100644 --- a/app/views/casa_org/_custom_org_links.html.erb +++ b/app/views/casa_org/_custom_org_links.html.erb @@ -1,65 +1,68 @@ -
    -
    -
    -
    -
    -

    Custom Links

    -
    -
    - -
    -
    -
    - - +<%# Org-settings Custom Links list (casa_app; rendered only by casa_org/edit). %> +
    +
    + + <%= link_to new_custom_org_link_path, class: button_classes(:secondary) do %> + New custom link + <% end %> +
    +
    + + + + + + + + + + <% if @custom_org_links.blank? %> + + + + <% else %> + <% @custom_org_links.each do |custom_link| %> - - - - + + + + - - - <% if @custom_org_links.blank? %> - - - - <% else %> - <% @custom_org_links.each do |custom_link| %> - - - - - - - <% end %> - <% end %> - -
    Display textURLActive?Actions
    No custom links have been added for this organization.
    Display TextURLActive?Actions<%= custom_link.text %><%= truncate(custom_link.url, length: 90) %><%= custom_link.active ? "Yes" : "No" %> +
    + <%= link_to edit_custom_org_link_path(custom_link), class: ghost_class do %> Edit<% end %> + <%= render "shared/confirm_button", url: custom_org_link_path(custom_link), title: "Delete custom link?", message: "This custom link will be removed from your organization.", confirm_label: "Delete", trigger_label: "Delete", trigger_icon: "bi bi-trash", trigger_class: ghost_class(:danger) %> +
    +
    - No custom links have been added for this organization. -
    <%= custom_link.text %><%= truncate(custom_link.url, length: 90) %><%= custom_link.active ? "Yes" : "No" %> - <%= link_to edit_custom_org_link_path(custom_link) do %> -
    - -
    - <% end %> - <%= link_to custom_org_link_path(custom_link), method: :delete do %> -
    - -
    - <% end %> -
    -
    -
    + <% end %> + <% end %> + + +
    + +
    + <% if @custom_org_links.blank? %> +

    No custom links have been added for this organization.

    + <% else %> + <% @custom_org_links.each do |custom_link| %> +
    +

    <%= custom_link.text %>

    +
    +
    +
    URL
    +
    <%= custom_link.url %>
    +
    +
    +
    Active?
    +
    <%= custom_link.active ? "Yes" : "No" %>
    +
    +
    +
    + <%= link_to edit_custom_org_link_path(custom_link), class: ghost_class do %> Edit<% end %> + <%= render "shared/confirm_button", url: custom_org_link_path(custom_link), title: "Delete custom link?", message: "This custom link will be removed from your organization.", confirm_label: "Delete", trigger_label: "Delete", trigger_icon: "bi bi-trash", trigger_class: ghost_class(:danger) %> +
    +
    + <% end %> + <% end %>
    diff --git a/app/views/casa_org/_hearing_types.html.erb b/app/views/casa_org/_hearing_types.html.erb index a7a66c6174..a78cadf765 100644 --- a/app/views/casa_org/_hearing_types.html.erb +++ b/app/views/casa_org/_hearing_types.html.erb @@ -1,57 +1,52 @@ -
    -
    -
    -
    -
    -

    Hearing Types

    -
    -
    - diff --git a/app/views/casa_org/_judges.html.erb b/app/views/casa_org/_judges.html.erb index c476e27639..5d5bba4529 100644 --- a/app/views/casa_org/_judges.html.erb +++ b/app/views/casa_org/_judges.html.erb @@ -1,53 +1,46 @@ -
    -
    -
    -
    -
    -

    Judge

    -
    -
    - diff --git a/app/views/casa_org/_languages.html.erb b/app/views/casa_org/_languages.html.erb index 705de0b204..50d48c8f2f 100644 --- a/app/views/casa_org/_languages.html.erb +++ b/app/views/casa_org/_languages.html.erb @@ -1,48 +1,48 @@ -
    -
    -
    -

    Languages

    -

    - A list of languages volunteers can choose from to add to their profile to let supervisors and admins know they can speak the language. -

    - <%= link_to new_language_path, class: "btn-sm main-btn primary-btn btn-hover" do %> - - New Language - <% end %> - -
    - - - - - - - - - - - - - - <% languages.each do |lang| %> - - - - - <% end %> - -
    NameActions
    English (default language)
    - <%= lang.name %> - - <%= link_to edit_language_path(lang) do %> -
    - -
    - <% end %> -
    -
    +<%# Org-settings Languages list (casa_app; rendered only by casa_org/edit). %> +
    +
    +
    + +

    Languages volunteers can add to their profile so supervisors and admins know they can speak them.

    + <%= link_to new_language_path, class: button_classes(:secondary) do %> + New language + <% end %> +
    + + +
    +
    +

    English (default language)

    +
    + <% languages.each do |lang| %> +
    +

    <%= lang.name %>

    +
    + <%= link_to edit_language_path(lang), class: ghost_class do %> Edit<% end %> +
    +
    + <% end %>
    diff --git a/app/views/casa_org/_learning_hour_topics.html.erb b/app/views/casa_org/_learning_hour_topics.html.erb index c53925f5bc..b005fc2740 100644 --- a/app/views/casa_org/_learning_hour_topics.html.erb +++ b/app/views/casa_org/_learning_hour_topics.html.erb @@ -1,50 +1,38 @@ -
    -
    -
    -
    -
    -

    Learning Topic

    -
    -
    - -
    -
    -
    - - - - - +<%# Org-settings Learning Topics list (casa_app; rendered only by casa_org/edit). %> +
    +
    +

    Learning topic

    + <%= link_to new_learning_hour_topic_path, class: button_classes(:secondary) do %> + New learning topic + <% end %> +
    +
    NameActions
    + + + + + + + + <% @learning_hour_topics.each do |learning_hour_topic| %> + + + - - - <% @learning_hour_topics.each do |learning_hour_topic| %> - - + <% end %> + +
    NameActions
    <%= learning_hour_topic.name %><%= link_to edit_learning_hour_topic_path(learning_hour_topic), class: ghost_class do %> Edit<% end %>
    - <%= learning_hour_topic.name %> -
    +
    - - <%= link_to edit_learning_hour_topic_path(learning_hour_topic) do %> -
    - -
    - <% end %> - - - <% end %> - - +
    + <% @learning_hour_topics.each do |learning_hour_topic| %> +
    +

    <%= learning_hour_topic.name %>

    +
    + <%= link_to edit_learning_hour_topic_path(learning_hour_topic), class: ghost_class do %> Edit<% end %> +
    -
    + <% end %>
    diff --git a/app/views/casa_org/_learning_hour_types.html.erb b/app/views/casa_org/_learning_hour_types.html.erb index 07bdfbc6eb..50c969e6c6 100644 --- a/app/views/casa_org/_learning_hour_types.html.erb +++ b/app/views/casa_org/_learning_hour_types.html.erb @@ -1,53 +1,46 @@ -
    -
    -
    -
    -
    -

    Type of Learning

    -
    -
    - diff --git a/app/views/casa_org/_placement_types.html.erb b/app/views/casa_org/_placement_types.html.erb index fdf190f58b..346f2af374 100644 --- a/app/views/casa_org/_placement_types.html.erb +++ b/app/views/casa_org/_placement_types.html.erb @@ -1,49 +1,38 @@ -
    -
    -
    -
    -
    -

    Placement Types

    -
    -
    - +<%# Org-settings Placement Types list (casa_app; rendered only by casa_org/edit). %> +
    +
    + + <%= link_to new_placement_type_path, class: button_classes(:secondary) do %> + New placement type + <% end %> +
    + + +
    + <% placement_types.each do |placement_type| %> +
    +

    <%= placement_type.name %>

    +
    + <%= link_to edit_placement_type_path(placement_type), class: ghost_class do %> Edit<% end %>
    -
    - - - - - - - - - <% placement_types.each do |placement_type| %> - - - - - <% end %> - -
    NameActions
    - <%= placement_type.name %> - - <%= link_to edit_placement_type_path(placement_type) do %> -
    - -
    - <% end %> -
    -
    -
    + <% end %>
    diff --git a/app/views/casa_org/_sent_emails.html.erb b/app/views/casa_org/_sent_emails.html.erb index 23106c6b03..d6dd02c0b0 100644 --- a/app/views/casa_org/_sent_emails.html.erb +++ b/app/views/casa_org/_sent_emails.html.erb @@ -1,37 +1,49 @@ -
    -
    -
    -

    Sent Emails

    -
    - - - - - - - - - - - <% @sent_emails.each do |sent_email| %> - - - - - - - <% end %> - -
    Mailer TypeCategoryRecipientTime Sent
    - <%= sent_email.mailer_type %> - - <%= sent_email.category %> - - <%= sent_email.user.display_name %> <<%= sent_email.sent_address %>> - - <%= to_user_timezone(sent_email.created_at).strftime("%l:%M%P %d %b %Y") %> -
    +<%# Org-settings Sent Emails list (casa_app; rendered only by casa_org/edit). Cell content + + the id/time format are locked by the view spec; only the wrapper/table styling changed. %> +
    + + + +
    + <% @sent_emails.each do |sent_email| %> +
    +

    <%= sent_email.mailer_type %>

    +
    +
    +
    Category
    +
    <%= sent_email.category %>
    +
    +
    +
    Recipient
    +
    <%= formatted_name(sent_email.user.display_name) %> <<%= sent_email.sent_address %>>
    +
    +
    +
    Time sent
    +
    <%= to_user_timezone(sent_email.created_at).strftime("%l:%M%P %d %b %Y") %>
    +
    +
    -
    + <% end %>
    diff --git a/app/views/casa_org/_settings_frame.html.erb b/app/views/casa_org/_settings_frame.html.erb new file mode 100644 index 0000000000..e157365997 --- /dev/null +++ b/app/views/casa_org/_settings_frame.html.erb @@ -0,0 +1,24 @@ +<%# Two-pane settings frame for the standalone admin pages (casa_admins / mileage_rates / banners / + imports), so they match casa_org/edit: a "Settings" header + the persistent sub-nav rail (current + item highlighted via `active`) + the page content in the right pane. On mobile the rail is hidden, + so a "Back to settings" link is the return path. `active` = this page's rail key. Rendered via + `render layout: "casa_org/settings_frame", locals: {active: ...} do ... end`. %> +
    +
    +
    +

    Settings

    +

    Organization profile, case setup, court details, and administration.

    +
    +
    + <%= render "casa_org/settings_rail", panels: false, active: active %> +
    +
    + <%= link_to edit_casa_org_path(current_organization), class: "inline-flex items-center gap-1 text-sm font-medium text-slate-500 hover:text-slate-700" do %> + Back to settings + <% end %> +
    + <%= yield %> +
    +
    +
    +
    diff --git a/app/views/casa_org/_settings_rail.html.erb b/app/views/casa_org/_settings_rail.html.erb new file mode 100644 index 0000000000..ba72cf8ff1 --- /dev/null +++ b/app/views/casa_org/_settings_rail.html.erb @@ -0,0 +1,39 @@ +<%# Shared settings sub-nav rail -- TEXT-ONLY (icons add noise in a dense grouped settings list, and + a repeated icon reads as clutter; the primary sidebar keeps its icons). `panels: true` on + casa_org/edit -- section items switch in-page panels via the settings-nav Stimulus controller + (which drives the active highlight). `panels: false` on the standalone admin pages -- section + items navigate to the settings page and open that section via the URL hash, and `active` + statically highlights the current item. %> +<% panels = local_assigns.fetch(:panels, false) %> +<% active = local_assigns.fetch(:active, nil) %> +<% base = "block rounded-lg px-3 py-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500" %> +<% idle = "text-slate-600 hover:bg-slate-100" %> +<% on = "bg-brand-50 font-semibold text-brand-700" %> +<% group_label = "px-3 pb-1 text-xs font-semibold text-slate-500" %> +<% sections = [ + ["Organization", [["Details", "organization-details"], ["Languages", "languages"], ["Custom links", "custom-org-links"]]], + ["Case setup", [["Contact types", "contact-types"], ["Contact topics", "case-contact-topics"], ["Placement types", "placement-types"]]], + ["Court", [["Hearing types", "hearing-types"], ["Judges", "judges"], ["Sent emails", "sent-emails"]]], + ["Volunteer setup", [["Learning hours", "learning-hours"]]] + ] %> +<% admin_items = [["Manage admins", casa_admins_path, "manage-admins"], ["Mileage rates", mileage_rates_path, "mileage-rates"], ["Banners", banners_path, "banners"], ["Imports", imports_path, "imports"]] %> + diff --git a/app/views/casa_org/edit.html.erb b/app/views/casa_org/edit.html.erb index 427b7b5b38..1ea4a5cd58 100644 --- a/app/views/casa_org/edit.html.erb +++ b/app/views/casa_org/edit.html.erb @@ -1,183 +1,251 @@ -
    -
    -
    -
    -

    Editing CASA Organization

    -
    -
    -
    -
    -
    - <%= form_with(model: current_organization, local: true) do |form| %> - <%= render "/shared/error_messages", resource: current_organization %> -
    - <%= form.label :name, "Name" %> - <%= form.text_field :name, class: "form-control", required: true %> -
    -
    - <%= form.label :display_name, "Display name" %> - <%= form.text_field :display_name, class: "form-control" %> -
    -
    - <%= form.label :address, "Address" %> - <%= form.text_field :address, class: "form-control" %> -
    -
    - <%= form.label :logo, "Logo" %> - <%= form.file_field :logo, class: "form-control h-auto", accept: ".png,.gif,.jpg,.jpeg,.webp,.svg" %> -
    -
    - <%= form.label :court_report_template, "Court report template" %> -
    -
    - <%= form.file_field :court_report_template, class: "form-control" %> -
    -
    - <% if current_organization.court_report_template.attached? %> - <% ActiveStorage::Current.url_options = { host: request.base_url } %> - <%= link_to 'Download Current Template', current_organization.court_report_template.url(only_path: true), class: "btn btn-info" %> - <% end %> -
    -
    -
    -
    -

    Organization Features

    -
    - <%= form.check_box :show_driving_reimbursement, class: 'form-check-input' %> - <%= form.label :show_driving_reimbursement, "Show driving reimbursement", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :learning_topic_active, class: 'form-check-input' %> - <%= form.label :learning_topic_active, "Enable Learning Topic", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :other_duties_enabled, class: 'form-check-input' %> - <%= form.label :other_duties_enabled, "Enable Other Duties", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :twilio_enabled, class: 'form-check-input accordionTwilio' %> - <%= form.label :twilio_enabled, "Enable Twilio", class: 'form-check-label mb-2' %> +<% content_for :page_title, "Organization settings" %> +<%# casa_org#edit (organization settings) on the casa_app shell, as a master-detail settings area: + the shared settings rail (settings_nav controller) selects one section at a time. Progressive + enhancement -- with JS off, every section stays visible (a plain scroll); the controller + collapses to a single desktop panel / mobile accordion and honors the URL hash, so deep links + (e.g. the case-contact form -> #case-contact-topics) still open the right section. The + Administration group is direct links to standalone admin pages (which share the settings frame + via casa_org/_settings_frame). Section anchor ids + entity partials unchanged. %> +<% card = "p-5 sm:p-6 lg:rounded-2xl lg:border lg:border-slate-200 lg:bg-white lg:shadow-sm" %> +<% section_title = "text-xl font-bold tracking-tight text-slate-900" %> +<% label_class = "mb-1.5 block text-sm font-medium text-slate-700" %> +<% req = required_marker %> +<% opt = optional_marker %> +<% input_class = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% file_class = "block w-full rounded-lg border border-slate-300 text-sm text-slate-900 shadow-sm file:mr-3 file:border-0 file:bg-slate-100 file:px-3.5 file:py-2.5 file:text-sm file:font-medium file:text-slate-700 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% check_class = "mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-brand-600 focus:ring-brand-500" %> +<% mobile_group = "px-1 pb-1 text-xs font-semibold text-slate-400 lg:hidden" %> +<%# On mobile each section is ONE card: `sec` supplies the card chrome, `acc_btn` is its header row, + `panel_body` adds the divider under the header, and the inner panels (the `card` local + the + section partials) drop their card chrome below lg so they read as the card body, not a 2nd card. %> +<% acc_btn = "flex w-full items-center justify-between gap-3 px-5 py-4 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500 lg:hidden" %> +<% sec = "overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm lg:overflow-visible lg:rounded-none lg:border-0 lg:bg-transparent lg:shadow-none" %> +<% panel_body = "border-t border-slate-100 lg:border-t-0 lg:space-y-6" %> +<% admin_pages = [["Manage admins", casa_admins_path], ["Mileage rates", mileage_rates_path], ["Banners", banners_path], ["Imports", imports_path]] %> + +
    +
    +
    +

    Settings

    +

    Organization profile, case setup, court details, and administration.

    - <%# Twilio Form Begin %> -
    -
    -
    - <%= form.label :twilio_phone_number, "Twilio Phone Number" %> - <%= form.text_field :twilio_phone_number, class: "form-control", required: true %> + +
    + <%= render "casa_org/settings_rail", panels: true %> + +
    +

    Organization

    + +
    + +
    +
    + <%= form_with model: current_organization, local: true, class: "space-y-5" do |form| %> + <%= render "shared/form_errors", resource: current_organization %> + +
    + <%= form.label :name, class: label_class do %>Name <%= req %><% end %> + <%= form.text_field :name, required: true, class: input_class %> +
    +
    + <%= form.label :display_name, class: label_class do %>Display name <%= opt %><% end %> + <%= form.text_field :display_name, class: input_class %> +
    +
    + <%= form.label :address, class: label_class do %>Address <%= opt %><% end %> + <%= form.text_field :address, class: input_class %> +
    +
    + <%= form.label :logo, class: label_class do %>Logo <%= opt %><% end %> + <%= form.file_field :logo, accept: ".png,.gif,.jpg,.jpeg,.webp,.svg", class: file_class %> +
    +
    + <%= form.label :court_report_template, class: label_class do %>Court report template <%= opt %><% end %> +
    + <%= form.file_field :court_report_template, class: "#{file_class} flex-1" %> + <% if current_organization.court_report_template.attached? %> + <% ActiveStorage::Current.url_options = {host: request.base_url} %> + <%= link_to "Download current template", current_organization.court_report_template.url(only_path: true), class: button_classes(:secondary) %> + <% end %> +
    +
    + +
    +

    Organization features

    +
    + + + + <% if Flipper.enabled?(:show_additional_expenses) %> + + <% end %> +
    +
    + +
    + <% twilio_on = current_organization.twilio_enabled %> + +
    "> +
    + <%= form.label :twilio_phone_number, "Twilio phone number", class: label_class %> + <%= form.text_field :twilio_phone_number, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_account_sid, "Twilio account SID", class: label_class %> + <%= form.text_field :twilio_account_sid, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_api_key_sid, "Twilio API key SID", class: label_class %> + <%= form.text_field :twilio_api_key_sid, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_api_key_secret, "Twilio API key secret", class: label_class %> + <%= form.text_field :twilio_api_key_secret, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    +
    + +
    + <%= button_tag type: "submit", class: button_classes(:primary) do %> + Submit + <% end %> +
    + <% end %> +
    -
    - <%= form.label :twilio_account_sid, "Twilio Account SID" %> - <%= form.text_field :twilio_account_sid, class: "form-control", required: true %> +
    + +
    + +
    + <%= render "languages", languages: current_organization.languages %>
    -
    - <%= form.label :twilio_api_key_sid, "Twilio API Key SID" %> - <%= form.text_field :twilio_api_key_sid, class: "form-control", required: true %> +
    + + + +

    Case setup

    + +
    + +
    + + <%= render "contact_type_groups", contact_type_groups: @contact_type_groups %> + <%= render "contact_types", contact_types: @contact_types %>
    -
    -
    - <%# Twilio Form End %> - <% if Flipper.enabled?(:show_additional_expenses) %> -
    - <%= form.check_box :additional_expenses_enabled, class: 'form-check-input' %> - <%= form.label :additional_expenses_enabled, "Volunteers can add Other Expenses", class: 'form-check-label mb-2' %> -
    - <% end %> -
    - <%= button_tag( - type: "submit", - class: "btn-sm main-btn primary-btn btn-hover" - ) do %> - Submit - <% end %> -
    - <% end %> -
    -
    - <%= render "languages", languages: current_organization.languages %> -
    -
    - <%= render "custom_org_links" %> -
    - -
    -
    -
    -
    -

    - Manage Contact Types -

    -
    -
    -
    -
    -
    - <%= render "contact_type_groups", contact_type_groups: @contact_type_groups %> - <%= render "contact_types", contact_types: @contact_types %> -
    - -
    -
    -
    -
    -

    - Manage Court Details -

    -
    -
    -
    -
    -
    - <%= render "hearing_types" %> - <%= render "judges" %> - <%= render "sent_emails" %> -
    -
    -
    -
    -
    -

    - Manage Learning Hours -

    -
    -
    -
    -
    -
    - <%= render "learning_hour_types" %> -
    -
    - <%= render "learning_hour_topics" %> -
    -
    -
    -
    -
    -

    - Manage Case Contact Topics -

    -
    -
    -
    -
    -
    - <%= render "contact_topics" %> -
    + + +
    + +
    + + <%= render "contact_topics" %> +
    +
    + +
    + +
    + + <%= render "placement_types", placement_types: @placement_types %> +
    +
    -
    -
    -
    -
    -

    - Manage Case Placement Types -

    +

    Court

    + +
    + +
    + <%= render "hearing_types" %> +
    +
    + +
    + +
    + <%= render "judges" %> +
    +
    + +
    + +
    + <%= render "sent_emails" %> +
    +
    + +

    Volunteer setup

    + +
    + +
    + + <%= render "learning_hour_types" %> + <%= render "learning_hour_topics" %> +
    +
    + + <%# Administration = direct links to standalone admin pages (which share the settings frame). + On mobile the rail is hidden, so each renders as a tappable card matching the section + headers -- text-only, with a chevron-right (navigate) vs the sections' chevron-down (expand). %> +

    Administration

    + <% admin_pages.each do |label, path| %> + <%= link_to path, class: "flex items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500 lg:hidden" do %> + <%= label %> + + <% end %> + <% end %>
    -
    - <%= render "placement_types", placement_types: @placement_types %> -
    diff --git a/app/views/case_assignments/index.html.erb b/app/views/case_assignments/index.html.erb deleted file mode 100644 index ddcef964a5..0000000000 --- a/app/views/case_assignments/index.html.erb +++ /dev/null @@ -1,55 +0,0 @@ -

    <%= "Editing #{@volunteer.display_name}" %>

    - -
    - -<% if @volunteer.casa_cases %> - - - - - - - - - - <% @volunteer.case_assignments.each do |assignment| %> - - - - - - <% end %> - -
    Case NumberTransition Aged YouthActions
    - Case Number - <%= assignment.casa_case.case_number %> - - Transition Aged Youth - <%= assignment.casa_case.decorate.transition_aged_youth %> - - <%= button_to "Unassign Volunteer", - volunteer_case_assignment_path(@volunteer, assignment), - method: :delete, - class: "btn btn-danger" %> -
    -<% end %> - -
    - -
    -
    -

    Assign a New Volunteer

    - - <%= form_with(model: CaseAssignment.new, url: volunteer_case_assignments_path) do |form| %> -
    - - -
    - <%= form.submit "Assign Case", class: 'btn btn-primary' %> - <% end %> -
    -
    diff --git a/app/views/case_contacts/_case_contact.html.erb b/app/views/case_contacts/_case_contact.html.erb index 152b4db64a..39c02e3a5b 100644 --- a/app/views/case_contacts/_case_contact.html.erb +++ b/app/views/case_contacts/_case_contact.html.erb @@ -1,85 +1,98 @@ -
    -
    -
    -
    -
    - <%= contact.decorate.medium_icon_classes %>"> -
    -
    - "> - <%= "[DELETE] " if policy(contact).restore? && contact.deleted? %> - <%= contact.decorate.contact_groups %> - - <% if !contact.active? %> - Draft - <% end %> - <% if contact.want_driving_reimbursement? %> - <% decorated_contact = contact.decorate %> - - <%= render BadgeComponent.new( - text: decorated_contact.reimbursement_status_text, - type: decorated_contact.reimbursement_status_badge_type, - rounded: true, - margin: false - ) %> - - <% end %> - <%= link_to("undelete", restore_case_contact_path(contact.id), method: :post, data: { turbo: false }, - class: "btn btn-info") if policy(contact).restore? && contact.deleted? %> -
    -
    - <%= contact.decorate.contact_types %> -
    - <%= contact.decorate.subheading %> -
    -
    -
      - <% contact.contact_topic_answers.reject { _1.value.blank? }.each do |answer| %> -
    • <%= render TruncatedTextComponent.new(answer.value, label: answer.contact_topic.question) %>
    • - <% end %> - <% if contact.notes %> -
    • <%= render TruncatedTextComponent.new(contact.notes, label: "Additional Notes") %>
    • - <% end %> -
    -
    -
    -
    +<%# Case-contact card (casa_app). Rendered by case_contacts#index and #drafts (both casa_app), + so it is rewritten in place rather than twinned. Keeps inert class hooks the specs use + (.container-fluid wrapper, .full-card, .card-title) and the reminder JS hooks in + _followup. The card sets NO bottom margin -- inter-card spacing comes from the parent's + space-y-4 (a child y-margin overrides the zero-specificity space-y and collapses the gap). + Topic answers + notes live in ONE native
    "Show details" disclosure + (full text, no per-line truncation) so reading a note is one click, not many. Edit/Delete/ + undelete keep data-turbo:false. %> +<% pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium" %> +
    +
    +
    + <%# Header row: contact groups as the title + status pills. The medium (how the contact + happened) is a plain-text fact in the meta line below (subheading), not an icon. %> +
    +

    <%= "[DELETE] " if policy(contact).restore? && contact.deleted? %><%= contact.decorate.contact_groups %>

    + <% unless contact.active? %> + Draft + <% end %> + <% if contact.want_driving_reimbursement? %> + <%# Reimbursement status (main #7055): rendered as a casadesign pill (amber = pending, + emerald = complete), not the Bootstrap BadgeComponent. ml-auto right-aligns it. %> + <% reimbursement_pill = (contact.decorate.reimbursement_status_badge_type == :success) ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700" %> + <%= contact.decorate.reimbursement_status_text %> + <% end %> + <% if policy(contact).restore? && contact.deleted? %> + <%= link_to "undelete", restore_case_contact_path(contact.id), method: :post, data: {turbo: false}, class: "#{pill} bg-sky-50 text-sky-700 hover:bg-sky-100" %> + <% end %>
    -
    - <% if Pundit.policy(current_user, contact).update? %> - <%= render "case_contacts/followup", contact: contact, followup: contact.requested_followup %> -
    - <%= link_to edit_case_contact_path(contact), class: "text-danger", data: { turbo: false } do %> - Edit + +

    <%= contact.decorate.contact_types %>

    +

    <%= contact.decorate.subheading %>

    + + <%# Topic answers + notes in ONE disclosure: a single "Show details" toggle reveals the + whole block at full length, instead of a separate truncated "read more" per line + (too many clicks). dt/dd follow the fact-list convention (muted label, dark value) + and match the new-design table's expandable detail panel. %> + <% answers = contact.contact_topic_answers.reject { _1.value.blank? } %> + <% if answers.any? || contact.notes.present? %> +
    + + Show details + + + +
    + <% answers.each do |answer| %> +
    +
    <%= answer.contact_topic.question %>
    +
    <%= answer.value %>
    +
    <% end %> -
    + <% if contact.notes.present? %> +
    +
    Additional notes
    +
    <%= contact.notes %>
    +
    + <% end %> + +
    + <% end %> + + <%= render "case_contacts/reminder_indicator", followup: contact.requested_followup %> + +
    + <% if Pundit.policy(current_user, contact).update? %> + <% if contact.active? %> + <%= render "case_contacts/reminder_control", contact: contact, followup: contact.requested_followup %> + <% end %> + <%= link_to edit_case_contact_path(contact), data: {turbo: false}, class: button_classes(:secondary) do %> + Edit + <% end %> <% end %> -
    <% if policy(contact).destroy? && !contact.deleted? %> - <%= link_to case_contact_path(contact.id), class: "main-btn btn-sm danger-btn btn-hover", method: :delete, data: { turbo: false } do %> - Delete - <% end %> + <%= link_to case_contact_path(contact.id), method: :delete, data: {turbo: false}, class: "#{button_classes(:danger_outline)} ml-auto" do %> + Delete + <% end %> <% end %> -
    -
    + +
    Created by: - <% if policy(contact).edit? %> - <% if current_user.volunteer? %> - <%= contact.creator&.display_name %> + <% creator_name = formatted_name(contact.creator&.display_name) %> + <% if policy(contact).edit? && !current_user.volunteer? %> + <% if contact.creator&.supervisor? %> + <%= link_to creator_name, edit_supervisor_path(contact.creator), data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> + <% elsif contact.creator&.casa_admin? %> + <%= link_to creator_name, edit_users_path, data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> <% else %> - <% if contact.creator&.supervisor? %> - <%= link_to contact.creator&.display_name, edit_supervisor_path(contact.creator), data: { turbo: false } %> - <% elsif contact.creator&.casa_admin? %> - <%= link_to contact.creator&.display_name, edit_users_path, data: { turbo: false } %> - <% else %> - <%= link_to contact.creator&.display_name, edit_volunteer_path(contact.creator), data: { turbo: false } %> - <% end %> + <%= link_to creator_name, edit_volunteer_path(contact.creator), data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> <% end %> <% else %> - <%= contact.creator&.display_name %> + <%= creator_name %> <% end %> -
    +
    diff --git a/app/views/case_contacts/_confirm_note_content_dialog.html.erb b/app/views/case_contacts/_confirm_note_content_dialog.html.erb deleted file mode 100644 index 894e9ab0ef..0000000000 --- a/app/views/case_contacts/_confirm_note_content_dialog.html.erb +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/app/views/case_contacts/_followup.html.erb b/app/views/case_contacts/_followup.html.erb deleted file mode 100644 index 6b1459b459..0000000000 --- a/app/views/case_contacts/_followup.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -
    - <% if followup %> - <%= button_to resolve_followup_path(followup), method: :patch, class: "main-btn btn-sm success-btn btn-hover", id:"resolve", data: { turbo: false } do %> - Resolve Reminder - <% end %> - <% else %> - - <% end %> -
    diff --git a/app/views/case_contacts/_reminder_control.html.erb b/app/views/case_contacts/_reminder_control.html.erb new file mode 100644 index 0000000000..f21137dff9 --- /dev/null +++ b/app/views/case_contacts/_reminder_control.html.erb @@ -0,0 +1,30 @@ +<%# Shared reminder control for a case-contact card (case-show + case-contacts index): a + "Resolve reminder" button_to when a follow-up is requested, else a "Set reminder" trigger + opening a design-system Dialog to create the follow-up with an optional note. Creating or + resolving it emails BOTH the volunteer (contact creator) and the setter -- see FollowupService + / CaseContacts::FollowupsController. Replaced a SweetAlert2 popup; UI covered by + followups/create_spec (:js). Locals: contact, followup. %> +<% if followup %> + <%= button_to resolve_followup_path(followup), method: :patch, id: "resolve", data: {turbo: false}, form_class: "inline", class: button_classes(:secondary) do %> + Resolve reminder + <% end %> +<% else %> + <%= render Dialog::GroupComponent.new(label: "Set a reminder for this case contact", wrapper_class: "contents") do |dialog| %> + <% dialog.with_trigger do %> + <%= button_tag type: "button", data: {action: "modal#open"}, class: button_classes(:secondary) do %> + Set reminder + <% end %> + <% end %> + <%= form_with url: case_contact_followups_path(contact), method: :post, data: {turbo: false} do %> + <%= render Dialog::HeaderComponent.new(title: "Set a reminder", icon: "bi bi-calendar-plus", variant: :info) %> + <%= render Dialog::BodyComponent.new do %> + <%= label_tag "note_#{contact.id}", "Optional: add a note about what followup is needed", class: "block text-sm font-medium text-slate-700" %> + <%= text_area_tag :note, nil, id: "note_#{contact.id}", rows: 3, placeholder: "Type your note here...", class: "mt-1.5 block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> + <% end %> + <%= render Dialog::FooterComponent.new do %> + + <%= button_tag "Save reminder", type: "submit", class: button_classes(:primary) %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/case_contacts/_reminder_indicator.html.erb b/app/views/case_contacts/_reminder_indicator.html.erb new file mode 100644 index 0000000000..2b16b0d951 --- /dev/null +++ b/app/views/case_contacts/_reminder_indicator.html.erb @@ -0,0 +1,12 @@ +<%# Shared amber pending-follow-up callout for a case-contact card: the note + who set it + when, + so the reminder's content is visible where you act (not just a swapped button). Local: followup + (the requested follow-up, or nil). %> +<% if followup %> +
    + +
    +

    Reminder set<% if followup.creator %> by <%= formatted_name(followup.creator.display_name) %><% end %> · <%= followup.created_at.strftime("%b %-d, %Y") %>

    + <% if followup.note.present? %>

    <%= followup.note %>

    <% end %> +
    +
    +<% end %> diff --git a/app/views/case_contacts/case_contacts_new_design/_actions.html.erb b/app/views/case_contacts/case_contacts_new_design/_actions.html.erb new file mode 100644 index 0000000000..d57f2ad177 --- /dev/null +++ b/app/views/case_contacts/case_contacts_new_design/_actions.html.erb @@ -0,0 +1,62 @@ +<%# Per-row actions for the case-contacts (new design) casa_app page. One source of truth for + both twins: layout: :row renders compact icon buttons for the desktop table cell, + layout: :bar renders labeled buttons for the mobile card footer. Inline (not a dropdown) so + nothing is clipped by the table's overflow-x-auto; the delete confirm + set-reminder note are + the native Dialog:: suite (top layer, never clipped). Gating mirrors the _case_contact card — + Edit + reminders require update?, Delete requires destroy?. followups#create and + case_contacts#destroy both redirect back, so the actions stay on this list. Duplicate Dialog + instances across the desktop/mobile twins are safe (each is scoped by its own modal controller). %> +<% row = layout == :row %> +<% ghost = "grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" %> +<% ghost_danger = "grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500" %> +<% requested_followup = contact.requested_followup %> + +<% if policy(contact).update? %> + <% if row %> + <%= link_to edit_case_contact_path(contact), data: {turbo: false}, class: ghost, "aria-label": "Edit contact", title: "Edit" do %><% end %> + <% else %> + <%= link_to edit_case_contact_path(contact), data: {turbo: false}, class: button_classes(:secondary) do %> Edit<% end %> + <% end %> + + <% if requested_followup %> + <% if row %> + <%= button_to resolve_followup_path(requested_followup), method: :patch, form_class: "inline-flex", data: {turbo: false}, class: ghost, "aria-label": "Resolve reminder", title: "Resolve reminder" do %><% end %> + <% else %> + <%= button_to resolve_followup_path(requested_followup), method: :patch, form_class: "inline-flex", data: {turbo: false}, class: button_classes(:secondary) do %> Resolve reminder<% end %> + <% end %> + <% else %> + <%= render Dialog::GroupComponent.new(label: "Set a reminder") do |dialog| %> + <% dialog.with_trigger do %> + <% if row %> + <%= tag.button type: "button", data: {action: "modal#open"}, class: ghost, "aria-label": "Set reminder", title: "Set reminder" do %><% end %> + <% else %> + <%= tag.button type: "button", data: {action: "modal#open"}, class: button_classes(:secondary) do %> Set reminder<% end %> + <% end %> + <% end %> + <%= form_with url: case_contact_followups_path(contact), method: :post, data: {turbo: false} do |f| %> + <%= render Dialog::HeaderComponent.new(title: "Set a reminder", icon: "bi bi-calendar-plus") %> + <%= render Dialog::BodyComponent.new do %> +

    Optional: add a note about what follow-up is needed.

    + <%= f.label :note, "Note", class: "sr-only" %> + <%= f.text_area :note, rows: 3, class: "mt-3 block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> + <% end %> + <%= render Dialog::FooterComponent.new do %> + + <%= f.submit "Save reminder", class: button_classes(:primary) %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> + +<% if policy(contact).destroy? %> + <%= render "shared/confirm_button", + url: case_contact_path(contact), + title: "Delete this contact?", + message: "This case contact will be permanently deleted. This can't be undone.", + confirm_label: "Yes, delete", + trigger_icon: "bi bi-trash", + trigger_label: (row ? nil : "Delete"), + aria_label: (row ? "Delete contact" : nil), + trigger_class: (row ? ghost_danger : button_classes(:danger_outline)) %> +<% end %> diff --git a/app/views/case_contacts/case_contacts_new_design/_creator.html.erb b/app/views/case_contacts/case_contacts_new_design/_creator.html.erb new file mode 100644 index 0000000000..c4190dff31 --- /dev/null +++ b/app/views/case_contacts/case_contacts_new_design/_creator.html.erb @@ -0,0 +1,16 @@ +<%# Created-by name for the case-contacts new-design page. Dark identifying text (not a brand + nav link); admins/supervisors get a link to the creator's edit page, matching the + _case_contact card. Names are first+last only (formatted_name, no honorific). %> +<% name = formatted_name(contact.creator&.display_name) %> +<% link_class = "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> +<% if policy(contact).edit? && !current_user.volunteer? && contact.creator %> + <% if contact.creator.supervisor? %> + <%= link_to name, edit_supervisor_path(contact.creator), data: {turbo: false}, class: link_class %> + <% elsif contact.creator.casa_admin? %> + <%= link_to name, edit_users_path, data: {turbo: false}, class: link_class %> + <% else %> + <%= link_to name, edit_volunteer_path(contact.creator), data: {turbo: false}, class: link_class %> + <% end %> +<% else %> + <%= name %> +<% end %> diff --git a/app/views/case_contacts/case_contacts_new_design/index.html.erb b/app/views/case_contacts/case_contacts_new_design/index.html.erb index f9da61b526..a98c177b16 100644 --- a/app/views/case_contacts/case_contacts_new_design/index.html.erb +++ b/app/views/case_contacts/case_contacts_new_design/index.html.erb @@ -1,227 +1,296 @@ -
    -

    Case Contacts

    +<% content_for :page_title, "Case contacts" %> +<%# case_contacts/new_design on casa_app (feature flag :new_case_contact_table). Bespoke + server-rendered table (retired the jQuery serverSide DataTable): server-side filter scopes + + ?sort= + Pagy, matching the cases-index reference. Filter is a `disclosure` panel that + auto-submits (GET). Each row expands (multi- `disclosure`) to its topic answers + notes; + per-row actions are inline (see _actions). Card-stacks below md. The table drops the + id="case_contacts" hook so dashboard.js's defineCaseContactsTable no-ops here. %> +<% th = "px-4 py-3 text-left text-xs font-semibold text-slate-600 align-top" %> +<% td = "px-4 py-3 align-top text-slate-700" %> +<% label_class = "mb-1.5 block text-sm font-medium text-slate-700" %> +<% select_class = "block w-full appearance-none rounded-lg border border-slate-300 bg-white py-2.5 pl-3.5 pr-9 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% input_class = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% chevron = "bi bi-chevron-down pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500" %> +<% check_class = "h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500" %> +<% pill = "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600" %> +<% draft_pill = "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600" %> +<% filters_active = [params[:occurred_starting_at], params[:occurred_ending_at], params[:casa_case_ids], params[:contact_type_ids], params[:contact_medium], params[:contact_made], params[:no_drafts]].any?(&:present?) %> -
    -
    - - - -
    - - <%= link_to new_case_contact_path, class: "main-btn primary-btn btn-sm btn-hover" do %> - - New Case Contact - <% end %> +
    +
    +

    Case contacts

    + <%= link_to new_case_contact_path, class: button_classes(:primary) do %> New case contact<% end %>
    -