diff --git a/README.md b/README.md index e97214602..5e6bf4fca 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Eligibility made easy Eligibility Made Easy (Emmy) using Consent-Based Verification (CBV) is a prototype that allows benefit applicants to verify their income directly using payroll providers. It is currently being piloted for testing and validation purposes. - ## Project Vision + ## Project Vision Eligibility Made Easy (Emmy) is a project to allow applicants to verify their income and community engagement directly using payroll providers and educational records. Emmy was developed and is supported by CMS in order to offer states a drop-in component for their application process to allow applicants to apply for state benefits more easily. This is part of a more comprehensive process to [improve data services for benefits delivery](https://assets.performance.gov/cx/files/OMB-CX-LifeExperience-FFS-ImprovingData.pdf). - ## Project Mission -Emmy uses consent-based verification (CBV) with multiple data sources, making the process much faster and more efficient than a simple document-upload service. CBV enables additional cost avoidance by optimizing manual document review processes. Rather than having to process incorrect or blurry documents, consent-based verification produces an easily consumed report with essential information. Verification information is returned in a standardized format easy for other systems to process (JSON), allowing integration with existing state systems. + ## Project Mission +Emmy uses consent-based verification (CBV) with multiple data sources, making the process much faster and more efficient than a simple document-upload service. CBV enables additional cost avoidance by optimizing manual document review processes. Rather than having to process incorrect or blurry documents, consent-based verification produces an easily consumed report with essential information. Verification information is returned in a standardized format easy for other systems to process (JSON), allowing integration with existing state systems. Emmy is under active development by CMS, with new updates released on a 2-week cadence. # Core Team @@ -110,15 +110,28 @@ To run database migrations on the test environment that is used by rpec tests, r ### JSON API Testing +To acceptance test the JSON API, you can run the independent **reference server implementation**. + 1. **Create an API key for the agency you want to test:** ```bash cd app - rails 'users:create_api_token[agency_name]' + bin/rails 'users:create_api_token[agency_id]' ``` 2. **Run the standalone test receiver:** ```bash - JSON_API_KEY=$(rails runner "puts User.api_key_for_agency('agency_name')") ruby lib/json_api_receiver.rb + JSON_API_KEY=$(bin/rails runner "puts User.api_key_for_agency('agency_id')") ruby lib/json_api_receiver.rb + ``` + +3. **Configure Emmy App to POST to the reference server.** Add this to your `.env.local`: + ```bash + # For testing LA SFTP against sinatra reference implementation + LA_LDH_TRANSMISSION_METHOD=json_and_pdf + LA_LDH_INCOME_REPORT_URL=http://localhost:4567 + LA_LDH_PDF_API_URL=http://localhost:4567/pdf + LA_LDH_INCOME_REPORT_APIKEY=foo + LA_LDH_INCLUDE_REPORT_PDF=false + LA_LDH_INCOME_REPORT_ACCOUNTCODE=foobar ``` This starts a standalone test server on port 4567 that logs incoming JSON data and verifies HMAC signatures. The receiver is completely independent and can be used as a reference implementation for agencies building their own JSON API endpoints. @@ -453,9 +466,6 @@ See [GOVERNANCE.md](./GOVERNANCE.md) If you have ideas for how we can improve or add to our capacity building efforts and methods for welcoming people into our community, please let us know by sending an email to: ffs at nava pbc dot com. If you would like to comment on the tool itself, please let us know by filing an **issue on our GitHub repository.** -## Glossary -Information about terminology and acronyms used in this documentation may be found in [GLOSSARY.md](GLOSSARY.md). - ## Policies ### Open Source Policy @@ -491,4 +501,4 @@ This project is in the public domain within the United States, and copyright and All contributions to this project will be released under the CC0 dedication. By submitting a pull request or issue, you are agreeing to comply with this waiver of copyright interest. ## Core team -See [COMMUNITY.md](./COMMUNITY.md). \ No newline at end of file +See [COMMUNITY.md](./COMMUNITY.md). diff --git a/app/.env b/app/.env index 292f5798e..914df30b7 100644 --- a/app/.env +++ b/app/.env @@ -11,6 +11,7 @@ # ############################################################################## LA_LDH_PINWHEEL_ENVIRONMENT=sandbox SANDBOX_PINWHEEL_ENVIRONMENT=sandbox +RESEARCH_PINWHEEL_ENVIRONMENT=sandbox MAINTENANCE_MODE=false LA_LDH_WEEKLY_REPORT_RECIPIENTS=test@email.com @@ -22,6 +23,7 @@ SUPPORTED_PROVIDERS=pinwheel,argyle DOMAIN_NAME=localhost LA_LDH_DOMAIN_NAME=la.localhost SANDBOX_DOMAIN_NAME=localhost +RESEARCH_DOMAIN_NAME=research.localhost PINWHEEL_API_TOKEN_SANDBOX=API secret ARGYLE_SANDBOX_WEBHOOK_SECRET=Webhook Secret diff --git a/app/AGENTS.md b/app/AGENTS.md index 7759279f2..eb4034e03 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -26,7 +26,7 @@ When developing on the Rails app, ensure you are always in the `app` subdirector - JS/TS: Prettier (`tabWidth: 2`, double quotes, no semicolons, `printWidth: 100`) via `npm run format` or `npm run format:precommit`. - Tests follow `_spec.rb` / `.test.ts`; favor descriptive, imperative example names. Use snake_case for Ruby, camelCase for JS, kebab-case for Stimulus files. Prefer `let` for object setup, `before` blocks for shared session/context setup, and `Timecop` for time freezing in controller specs (using `around` blocks). - ERB/HTML: Put each HTML tag on its own line (opening tag, contents, closing tag) for readability and avoid `usa-prose` classes unless required by design. -- Do not use margin or padding utility helpers (e.g., `margin-bottom-*`, `padding-*`) unless explicitly requested. +- Layout and spacing: Prefer USWDS / project utility classes in ERB for one-off layout and spacing (e.g., `display-flex`, `flex-justify-center`, `margin-top-*`, `padding-*`). Add SCSS when the same rules repeat across elements, when a named class carries semantic meaning (states, variants), or when styling is too complex or token-heavy to express cleanly as utilities. ## Testing Guidelines - Add coverage for new endpoints, logic, and service objects; exercise eligibility and payroll edge cases. diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 7304ae8e0..8860c533c 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -119,12 +119,12 @@ GEM aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - axe-core-api (4.11.1) + axe-core-api (4.11.2) dumb_delegator ostruct virtus - axe-core-rspec (4.11.1) - axe-core-api (= 4.11.1) + axe-core-rspec (4.11.2) + axe-core-api (= 4.11.2) dumb_delegator ostruct virtus @@ -215,7 +215,7 @@ GEM tzinfo factory_bot (6.5.6) activesupport (>= 6.1.0) - faker (3.6.1) + faker (3.8.0) i18n (>= 1.8.11, < 2) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) @@ -267,7 +267,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.19.3) + json (2.19.4) json-logic-rb (0.1.5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) @@ -298,7 +298,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.3) + minitest (6.0.5) drb (~> 2.0) prism (~> 1.5) mission_control-jobs (1.1.0) @@ -333,7 +333,7 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.2) - newrelic_rpm (10.2.0) + newrelic_rpm (10.4.0) logger nio4r (2.7.5) nokogiri (1.19.2-aarch64-linux-gnu) @@ -346,8 +346,8 @@ GEM racc (~> 1.4) orm_adapter (0.5.0) ostruct (0.6.3) - parallel (1.27.0) - parser (3.3.10.2) + parallel (2.0.1) + parser (3.3.11.1) ast (~> 2.4.1) racc pdf-reader (2.15.1) @@ -461,7 +461,7 @@ GEM redis-client (>= 0.22.0) redis-client (0.26.4) connection_pool - regexp_parser (2.11.3) + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) responders (3.2.0) @@ -486,11 +486,11 @@ GEM rspec-mocks (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) - rubocop (1.86.0) + rubocop (1.86.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) @@ -579,7 +579,7 @@ GEM uri (1.1.1) useragent (0.16.11) vcr (6.4.0) - view_component (4.6.0) + view_component (4.7.0) actionview (>= 7.1.0) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -608,7 +608,7 @@ GEM wkhtmltopdf-binary (0.12.6.10) xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.38) + yard (0.9.42) zeitwerk (2.7.5) PLATFORMS @@ -646,7 +646,7 @@ DEPENDENCIES ed25519 erb_lint factory_bot (~> 6.5) - faker (~> 3.6) + faker (~> 3.8) faraday (~> 2.14.1) gpgme (~> 2.0) i18n-tasks (~> 1.1) diff --git a/app/app/assets/stylesheets/application.postcss.css b/app/app/assets/stylesheets/application.postcss.css index a2594d88b..6c6a39ef7 100644 --- a/app/app/assets/stylesheets/application.postcss.css +++ b/app/app/assets/stylesheets/application.postcss.css @@ -5,6 +5,7 @@ @forward "activity_hub.scss"; @forward "cbv.scss"; @forward "demo-launcher.scss"; +@forward "document_uploads.scss"; /* Import styling from ViewComponents */ @forward "../../components/activity_flow_progress_indicator/activity_flow_progress_indicator.scss"; diff --git a/app/app/assets/stylesheets/cbv.scss b/app/app/assets/stylesheets/cbv.scss index 272cf50ef..cb1898389 100644 --- a/app/app/assets/stylesheets/cbv.scss +++ b/app/app/assets/stylesheets/cbv.scss @@ -532,24 +532,3 @@ input#invitation_link { font-weight: bold; color: #005EA2; } - -/* - * Document upload page - */ -.document-uploads__preview { - display: flex; - align-items: center; -} -.document-uploads__preview-icon { - box-sizing: content-box; - color: color("gray-cool-40"); - flex-shrink: 0; - height: units(4); - padding: units(1); - width: units(4); -} -.document-uploads__preview-filename { - @include u-font("sans", 2); - overflow: hidden; - word-break: break-word; -} diff --git a/app/app/assets/stylesheets/demo-launcher.scss b/app/app/assets/stylesheets/demo-launcher.scss index d6a14060c..1066f686c 100644 --- a/app/app/assets/stylesheets/demo-launcher.scss +++ b/app/app/assets/stylesheets/demo-launcher.scss @@ -106,9 +106,42 @@ margin: 0; } + .demo-launcher__launch-section { + padding-top: units(2); + border-top: 1px solid color("base-lighter"); + } + + .demo-launcher__share-widget { + margin-top: units(2); + padding: units(2); + border: 1px solid color("base-lighter"); + border-radius: radius("md"); + background-color: color("base-lightest"); + } + + .demo-launcher__share-row { + display: grid; + gap: units(1); + align-items: end; + + @include at-media("tablet") { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .usa-input, + .usa-button { + margin: 0; + } + } + + .demo-launcher__share-status { + margin-top: units(1); + margin-bottom: 0; + } + // -- Left column: test scenarios -- .demo-launcher__test-scenarios { - margin-top: units(8); + margin-top: 0; } .demo-launcher__scenario-radios { @@ -186,4 +219,10 @@ flex-shrink: 0; } } + + .demo-launcher__renewal-required { + &.demo-launcher__renewal-required--hidden { + display: none; + } + } } diff --git a/app/app/assets/stylesheets/document_uploads.scss b/app/app/assets/stylesheets/document_uploads.scss new file mode 100644 index 000000000..54444b884 --- /dev/null +++ b/app/app/assets/stylesheets/document_uploads.scss @@ -0,0 +1,57 @@ +@forward "uswds"; +@use "uswds" as *; + +/* + * Document uploads + */ +.document-uploads { + margin-bottom: units(4); + margin-top: units(4); +} +.document-uploads__heading { + margin-bottom: units(2); + margin-top: 0; +} +.document-uploads__list { + list-style: none; + margin: 0; + padding: 0; +} +.document-uploads__item { + align-items: center; + border-bottom: 1px solid color("gray-cool-20"); + display: flex; + gap: units(2); + justify-content: space-between; + padding-bottom: units(1); + padding-top: units(1); +} +.document-uploads__file { + align-items: center; + display: flex; + gap: units(2); + min-width: 0; +} +.document-uploads__icon { + background-color: color("blue-warm-5"); + border: 1px solid color("blue-warm-20v"); + border-radius: radius("md"); + box-sizing: content-box; + color: color("primary"); + flex-shrink: 0; + height: 1.5rem; + padding: units(1); + width: 1.5rem; +} +.document-uploads__filename { + @include u-font("sans", 4); + line-height: line-height("sans", 4); + overflow: hidden; + word-break: break-word; +} +.document-uploads__remove-link { + color: color("secondary-dark"); + flex-shrink: 0; + @include u-font("sans", 4); + line-height: line-height("sans", 4); +} diff --git a/app/app/components/activity_flow_header_component.rb b/app/app/components/activity_flow_header_component.rb index b94082441..a1d87e88e 100644 --- a/app/app/components/activity_flow_header_component.rb +++ b/app/app/components/activity_flow_header_component.rb @@ -8,4 +8,8 @@ def initialize(title:, exit_url:, back_url: nil) @exit_url = exit_url @back_url = back_url end + + def confirm_on_exit? + helpers.params[:from_edit].blank? + end end diff --git a/app/app/components/activity_flow_header_component/activity_flow_header_component.html.erb b/app/app/components/activity_flow_header_component/activity_flow_header_component.html.erb index d8f347bf8..609835428 100644 --- a/app/app/components/activity_flow_header_component/activity_flow_header_component.html.erb +++ b/app/app/components/activity_flow_header_component/activity_flow_header_component.html.erb @@ -1,6 +1,9 @@
+ data-activity-flow-header-exit-url-value="<%= exit_url %>" + data-activity-flow-header-confirm-on-exit-value="<%= confirm_on_exit? %>"> + <% close_icon_href = helpers.uswds_sprite_icon_href("close") %> + <% back_icon_href = helpers.uswds_sprite_icon_href("navigate_before") %>
@@ -11,7 +14,7 @@ class="activity-header-title__exit-link"> <%= t("activities.activity_header_component.exit") %>
@@ -49,7 +52,7 @@ aria-label="Close this window" data-close-modal>
@@ -69,7 +72,7 @@
<%= link_to back_url, class: "back-nav__link" do %> <%= t("activities.activity_header_component.back") %> <% end %> diff --git a/app/app/components/activity_flow_progress_indicator.rb b/app/app/components/activity_flow_progress_indicator.rb index ff9dae553..0d5091e06 100644 --- a/app/app/components/activity_flow_progress_indicator.rb +++ b/app/app/components/activity_flow_progress_indicator.rb @@ -2,28 +2,35 @@ class ActivityFlowProgressIndicator < ViewComponent::Base def self.from_calculator( progress_calculator, variant: :application, - required_month_count: nil + show_unit_toggle: false, + display_variant: :default ) new( monthly_calculation_results: progress_calculator.monthly_results, variant: variant, - required_month_count: required_month_count + required_month_count: progress_calculator.required_month_count, + show_unit_toggle: show_unit_toggle, + display_variant: display_variant ) end def initialize( monthly_calculation_results:, variant: :application, - required_month_count: nil + required_month_count: nil, + show_unit_toggle: false, + display_variant: :default ) @monthly_calculation_results = monthly_calculation_results @renewal = variant == :renewal + @review = display_variant == :review @required_month_count = normalize_required_month_count(required_month_count) + @show_unit_toggle = show_unit_toggle end - def percent_complete(monthly_result) - progress_value = progress_value_for(monthly_result) - threshold_value = completion_threshold_for(monthly_result) + def percent_complete(monthly_result, unit:) + progress_value = progress_value_for(monthly_result, unit:) + threshold_value = completion_threshold_for(unit:) [ (100.0 * progress_value) / threshold_value, @@ -40,28 +47,40 @@ def format_hours(hours) hours.round(1) end - def display_progress_amount(monthly_result) - if display_dollars?(monthly_result) + def display_progress_amount(monthly_result, unit:) + if unit == :dollars format_dollar_amount(monthly_result.total_earnings_cents) else format_hours(monthly_result.total_hours) end end - def display_completion_threshold(monthly_result) - if display_dollars?(monthly_result) + def display_completion_threshold(monthly_result, unit:) + if unit == :dollars format_dollar_amount(earnings_completion_threshold) else hours_completion_threshold end end - def display_hours_unit?(monthly_result) = !display_dollars?(monthly_result) + def display_hours_unit?(unit:) = unit == :hours def multi_month? = monthly_calculation_results.length > 1 def complete_month_count = monthly_calculation_results.count(&:meets_requirements) + def completed_months_label + if renewal? + t("activity_flow_progress_indicator.renewal_months_completed", complete: complete_month_count, required: required_month_count) + else + t("activity_flow_progress_indicator.application_months_completed", complete: complete_month_count, total: total_month_count) + end + end + + def collapsed? = review? && complete? + + def review? = @review + def total_month_count = monthly_calculation_results.length def ordered_monthly_calculation_results = @ordered_monthly_calculation_results ||= monthly_calculation_results.sort_by(&:month) @@ -82,23 +101,31 @@ def reporting_window_start_month = ordered_monthly_calculation_results.first&.mo def reporting_window_end_month = ordered_monthly_calculation_results.last&.month - private - attr_reader :monthly_calculation_results, :required_month_count + def show_unit_toggle? = @show_unit_toggle + + def can_toggle_units? = show_unit_toggle? && ordered_monthly_calculation_results.any? { |result| !result.meets_requirements } - def display_dollars?(monthly_result) - monthly_result.default_unit == :dollars + def toggle_label(unit) + if multi_month? + unit == :hours ? t("activity_flow_progress_indicator.see_progress_in_dollars") : t("activity_flow_progress_indicator.see_progress_in_hours") + else + unit == :hours ? t("activity_flow_progress_indicator.switch_to_dollars") : t("activity_flow_progress_indicator.switch_to_hours") + end end - def progress_value_for(monthly_result) - if display_dollars?(monthly_result) + private + attr_reader :monthly_calculation_results, :required_month_count + + def progress_value_for(monthly_result, unit:) + if unit == :dollars monthly_result.total_earnings_cents.to_f else monthly_result.total_hours.to_f end end - def completion_threshold_for(monthly_result) - if display_dollars?(monthly_result) + def completion_threshold_for(unit:) + if unit == :dollars earnings_completion_threshold else hours_completion_threshold diff --git a/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.erb b/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.erb index eba98b485..349a2ef41 100644 --- a/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.erb +++ b/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.erb @@ -1,84 +1,164 @@ -<%= render(Uswds::Card.new(class: "activity-flow-progress-indicator__card")) do |card| %> - <% card.with_body do %> -
-

- <% if renewal? %> - <% if complete? %> - - <% end %> - <%= t(".renewal_months_completed", complete: complete_month_count, required: required_month_count) %> - <% elsif multi_month? %> - <% if complete? %> - - <% end %> - <%= t(".application_months_completed", complete: complete_month_count, total: total_month_count) %> - <% else %> - <% first_month = ordered_monthly_calculation_results.first.month %> - <%= t(".title", month: l(first_month, format: :month)) %> - <% end %> -

+<% review_width_class = review? ? "maxw-table" : nil %> - <% if renewal_requires_subset_months? %> -

- <% if reporting_window_start_month && reporting_window_end_month %> - <%= t( - ".renewal_subtitle", - required: required_month_count, - start_month: l(reporting_window_start_month, format: :month), - end_month: l(reporting_window_end_month, format: :month) - ) %> +<% if collapsed? %> + <% success_icon_href = helpers.uswds_sprite_icon_href("check_circle") %> +

+

+ + <%= completed_months_label %> +

+
+<% else %> + <% card_classes = [ "activity-flow-progress-indicator__card", review_width_class ] %> + <% card_classes << "activity-flow-progress-indicator__card--review" if review? %> + <%= render(Uswds::Card.new(class: card_classes.join(" "))) do |card| %> + <% card.with_body do %> + <% success_icon_href = helpers.uswds_sprite_icon_href("check_circle") %> +
+ data-controller="progress-indicator-units" + data-progress-indicator-units-unit-value="hours" + <% end %> + > +

+ <% if review? || renewal? || multi_month? %> + <% if complete? %> + + <% end %> + <%= completed_months_label %> <% else %> - <%= t(".renewal_subtitle_no_range", required: required_month_count) %> + <% first_month = ordered_monthly_calculation_results.first.month %> + <%= t(".title", month: l(first_month, format: :month)) %> <% end %> -

- <% end %> +

- <% ordered_monthly_calculation_results.each do |monthly_result| %> -
-
-
-
+ <% if renewal_requires_subset_months? && !review? %> +

+ <% if reporting_window_start_month && reporting_window_end_month %> + <%= t( + ".renewal_subtitle", + required: required_month_count, + start_month: l(reporting_window_start_month, format: :month), + end_month: l(reporting_window_end_month, format: :month) + ) %> + <% else %> + <%= t(".renewal_subtitle_no_range", required: required_month_count) %> + <% end %> +

+ <% end %> -
- - <% if multi_month? %> - <% if monthly_result.meets_requirements %> - + <% ordered_monthly_calculation_results.each do |monthly_result| %> + <% completed_unit = monthly_result.default_unit || :hours %> +
+ <% if monthly_result.meets_requirements %> +
+
+ <% else %> + <% [ :hours, :dollars ].each do |unit| %> +
hidden<% end %> + > +
<% end %> - <%= l(monthly_result.month, format: :month) %> <% end %> - +
- - - <% if monthly_result.meets_requirements && !multi_month? %> - +
+ + <% if multi_month? %> + <% if monthly_result.meets_requirements %> + + <% end %> + <%= l(monthly_result.month, format: :month) %> + <% elsif can_toggle_units? %> + <% [ :hours, :dollars ].each do |unit| %> + + <% end %> <% end %> + - <%= display_progress_amount(monthly_result) %> + + <% if monthly_result.meets_requirements %> + + <% if !multi_month? %> + + <% end %> + + <%= display_progress_amount(monthly_result, unit: completed_unit) %> + + / + <%= display_completion_threshold(monthly_result, unit: completed_unit) %> + <% if display_hours_unit?(unit: completed_unit) %> + <%= t(".hours") %> + <% end %> + <% else %> + <% [ :hours, :dollars ].each do |unit| %> + hidden<% end %> + > + + <%= display_progress_amount(monthly_result, unit: unit) %> + + / + <%= display_completion_threshold(monthly_result, unit: unit) %> + <% if display_hours_unit?(unit: unit) %> + <%= t(".hours") %> + <% end %> + + <% end %> + <% end %> - / - <%= display_completion_threshold(monthly_result) %> - <% if display_hours_unit?(monthly_result) %> - <%= t(".hours") %> +
+ <% end %> + + <% if can_toggle_units? && multi_month? %> +
+ <% [ :hours, :dollars ].each do |unit| %> + <% end %> - -
- <% end %> -
+
+ <% end %> +
+ <% end %> <% end %> <% end %> diff --git a/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.scss b/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.scss index 41dd26840..d581a0c9d 100644 --- a/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.scss +++ b/app/app/components/activity_flow_progress_indicator/activity_flow_progress_indicator.scss @@ -16,14 +16,18 @@ } } -.activity-flow-progress-indicator__title { - @include u-font("sans", "lg"); +.activity-flow-progress-indicator--review-collapsed { + background-color: color("success-lighter"); + border: 1px solid color("success-dark"); + border-radius: radius("md"); + margin-top: units(2); + padding: units(3); +} +.activity-flow-progress-indicator__title { align-items: center; display: flex; - font-weight: font-weight("bold"); gap: units(1); - line-height: 1.3; margin: 0; } @@ -34,13 +38,6 @@ margin: units(1) 0 units(2); } -.activity-flow-progress-indicator__months-completed { - @include u-font("sans", "md"); - - font-weight: font-weight("bold"); - margin: 0 0 units(2); -} - .activity-flow-progress-indicator__progress-bar { background-color: color("primary-lighter"); border-radius: 4px; @@ -96,3 +93,8 @@ vertical-align: text-bottom; width: 18px; } + +[data-progress-indicator-units-target="toggle"][hidden], +[data-progress-indicator-units-target="unitContent"][hidden] { + display: none !important; +} diff --git a/app/app/components/document_uploads_component.html.erb b/app/app/components/document_uploads_component.html.erb new file mode 100644 index 000000000..ea02e76be --- /dev/null +++ b/app/app/components/document_uploads_component.html.erb @@ -0,0 +1,25 @@ +
+ <%= content_tag :"h#{heading_level}", heading_text, id: "document-uploads-heading", class: "document-uploads__heading" %> + +
    + <% documents.each do |document| %> +
  • +
    + + + <%= filename_for(document) %> + +
    + + <% if show_remove_file? && remove_path_for(document).present? %> + <%= link_to t("activities.document_uploads.remove_file"), + remove_path_for(document), + class: "usa-link document-uploads__remove-link", + data: { turbo_method: :delete } %> + <% end %> +
  • + <% end %> +
+
diff --git a/app/app/components/document_uploads_component.rb b/app/app/components/document_uploads_component.rb new file mode 100644 index 000000000..0f640f452 --- /dev/null +++ b/app/app/components/document_uploads_component.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class DocumentUploadsComponent < ViewComponent::Base + def initialize(documents:, show_remove_file: false, heading_level: 2) + @documents = documents + @show_remove_file = show_remove_file + @heading_level = heading_level + end + + private + + attr_reader :documents, :heading_level + + def heading_text + I18n.t("activities.document_uploads.heading", document_count: documents.count) + end + + def show_remove_file? + @show_remove_file + end + + def filename_for(document) + document.fetch(:filename) + end + + def remove_path_for(document) + document[:remove_path] + end + + def icon_path + helpers.uswds_sprite_icon_href("file_present") + end +end diff --git a/app/app/components/link_with_icon_component.rb b/app/app/components/link_with_icon_component.rb index 06af2f22f..f23cde768 100644 --- a/app/app/components/link_with_icon_component.rb +++ b/app/app/components/link_with_icon_component.rb @@ -32,8 +32,7 @@ def link_options def icon_svg return unless icon - icon_sprite_path = helpers.asset_path("@uswds/uswds/dist/img/sprite.svg") - icon_path = "#{icon_sprite_path}##{icon}" + icon_path = helpers.uswds_sprite_icon_href(icon) content_tag(:svg, class: "usa-icon", "aria-hidden": true, focusable: false, role: "img") do tag.use("", href: icon_path) end diff --git a/app/app/components/uswds/icon_list.html.erb b/app/app/components/uswds/icon_list.html.erb index e2c7548b3..40273fb15 100644 --- a/app/app/components/uswds/icon_list.html.erb +++ b/app/app/components/uswds/icon_list.html.erb @@ -3,7 +3,7 @@
  • diff --git a/app/app/controllers/activities/activities_controller.rb b/app/app/controllers/activities/activities_controller.rb index 5ca837aec..8289dfab2 100644 --- a/app/app/controllers/activities/activities_controller.rb +++ b/app/app/controllers/activities/activities_controller.rb @@ -5,12 +5,12 @@ def index @flow.save end - @community_service_activities = @flow.volunteering_activities.order(created_at: :desc) - @work_programs_activities = @flow.job_training_activities.order(created_at: :desc) - @education_activities = @flow.education_activities.order(created_at: :desc) + @community_service_activities = @flow.volunteering_activities.published.order(created_at: :desc) + @work_programs_activities = @flow.job_training_activities.published.order(created_at: :desc) + @education_activities = @flow.education_activities.published.order(created_at: :desc) - @employment_payroll_accounts = @flow.payroll_accounts.order(created_at: :desc).select(&:sync_succeeded?) - @employment_activities = @flow.employment_activities.order(created_at: :desc) + @employment_payroll_accounts = @flow.payroll_accounts.published.order(created_at: :desc).select(&:sync_succeeded?) + @employment_activities = @flow.employment_activities.published.order(created_at: :desc) @persisted_report = PersistedReportAdapter.new(@flow) if @employment_payroll_accounts.any? end end diff --git a/app/app/controllers/activities/document_uploads_controller.rb b/app/app/controllers/activities/document_uploads_controller.rb index cd5f77115..5a9046321 100644 --- a/app/app/controllers/activities/document_uploads_controller.rb +++ b/app/app/controllers/activities/document_uploads_controller.rb @@ -2,7 +2,7 @@ class Activities::DocumentUploadsController < Activities::BaseController before_action :set_activity before_action :set_back_url, only: %i[new] - helper_method :upload_path, :document_upload_preview_url + helper_method :upload_path, :remove_document_upload_path def new end @@ -20,6 +20,12 @@ def create end end + def destroy + @activity.document_uploads_attachments.find(params[:id]).purge + + redirect_to upload_page_path + end + private def document_upload_params @@ -109,12 +115,67 @@ def upload_path end end - def document_upload_preview_url(attachment) - return unless attachment.previewable? + def upload_page_path + if params[:community_service_id] + new_activities_flow_community_service_document_upload_path( + community_service_id: @activity, + from_edit: params[:from_edit].presence + ) + elsif params[:job_training_id] + new_activities_flow_job_training_document_upload_path( + job_training_id: @activity, + from_edit: params[:from_edit].presence + ) + elsif params[:education_id] + new_activities_flow_education_document_upload_path( + education_id: @activity, + from_edit: params[:from_edit].presence + ) + elsif params[:employment_id] + new_activities_flow_income_employment_document_upload_path( + employment_id: @activity, + from_edit: params[:from_edit].presence + ) + else + raise <<~ERROR + No activity param matched in DocumentUploadsController#upload_page_path. + Make sure to add it there if you're adding DocumentUploadable to a + new activity type. + ERROR + end + end - attachment.preview(resize_to_limit: [ 40, 40 ]).processed.url - rescue StandardError => e - Rails.logger.warn("Document upload preview unavailable for attachment #{attachment.id}: #{e.class}: #{e.message}") - nil + def remove_document_upload_path(attachment) + if params[:community_service_id] + activities_flow_community_service_document_upload_path( + community_service_id: @activity, + id: attachment, + from_edit: params[:from_edit].presence + ) + elsif params[:job_training_id] + activities_flow_job_training_document_upload_path( + job_training_id: @activity, + id: attachment, + from_edit: params[:from_edit].presence + ) + elsif params[:education_id] + activities_flow_education_document_upload_path( + education_id: @activity, + id: attachment, + from_edit: params[:from_edit].presence + ) + elsif params[:employment_id] + activities_flow_income_employment_document_upload_path( + employment_id: @activity, + id: attachment, + from_edit: params[:from_edit].presence + ) + else + raise <<~ERROR + No activity param matched in DocumentUploadsController#remove_document_upload_path. + Make sure to add it there if you're adding DocumentUploadable to a + new activity type. + ERROR + end end end diff --git a/app/app/controllers/activities/education_controller.rb b/app/app/controllers/activities/education_controller.rb index 1155889c2..afa79f5ff 100644 --- a/app/app/controllers/activities/education_controller.rb +++ b/app/app/controllers/activities/education_controller.rb @@ -35,6 +35,7 @@ def show if @education_activity.sync_failed? || @education_activity.sync_no_enrollments? redirect_to activities_flow_education_error_path elsif @education_activity.sync_succeeded? && !testing_synchronization_page? + @education_activity.publish! unless @education_activity.partially_self_attested? redirect_to education_sync_success_path else # sync is still in progress — render the polling page @@ -58,6 +59,7 @@ def update education_id: @education_activity, id: 0 ) else + @education_activity.publish! redirect_to after_activity_path end else @@ -87,6 +89,7 @@ def sync elsif @wait_time < ARTIFICIAL_DELAY && !testing_synchronization_page? render turbo_stream: turbo_stream.replace(:synchronization, partial: "status") else + @education_activity.publish! unless @education_activity.partially_self_attested? render turbo_stream: turbo_stream.action(:redirect, education_sync_success_path) end end @@ -100,7 +103,12 @@ def review def save_review @education_activity.update(review_params) - redirect_to @education_activity.fully_self_attested? ? activities_flow_root_path : after_activity_path + @education_activity.publish! + if @education_activity.fully_self_attested? + redirect_to activities_flow_root_path + else + redirect_to after_activity_path + end end def error @@ -169,7 +177,7 @@ def testing_synchronization_page? end def create_fully_self_attested_activity - @education_activity = @flow.education_activities.new(fully_self_attested_education_params) + @education_activity = @flow.education_activities.new(fully_self_attested_education_params.merge(draft: true)) @education_activity.data_source = :fully_self_attested if @education_activity.save redirect_to edit_activities_flow_education_month_path(education_id: @education_activity, id: 0) @@ -179,7 +187,7 @@ def create_fully_self_attested_activity end def create_validated_activity - @education_activity = @flow.education_activities.create + @education_activity = @flow.education_activities.create(draft: true) NscSynchronizationJob.perform_later(@education_activity.id) redirect_to activities_flow_education_path(id: @education_activity.id) end diff --git a/app/app/controllers/activities/employment_controller.rb b/app/app/controllers/activities/employment_controller.rb index abe55f030..e5e35ca40 100644 --- a/app/app/controllers/activities/employment_controller.rb +++ b/app/app/controllers/activities/employment_controller.rb @@ -17,7 +17,7 @@ def new end def create - @employment_activity = @flow.employment_activities.new(employment_activity_params) + @employment_activity = @flow.employment_activities.new(employment_activity_params.merge(draft: true)) if @employment_activity.save redirect_to edit_activities_flow_income_employment_month_path(employment_id: @employment_activity, id: 0) else @@ -49,6 +49,7 @@ def review def save_review @employment_activity.update(review_params) + @employment_activity.publish! redirect_to after_activity_path end diff --git a/app/app/controllers/activities/income/payment_details_controller.rb b/app/app/controllers/activities/income/payment_details_controller.rb index f41b19730..56c108dcf 100644 --- a/app/app/controllers/activities/income/payment_details_controller.rb +++ b/app/app/controllers/activities/income/payment_details_controller.rb @@ -43,6 +43,7 @@ def update end @payroll_account.update(payroll_account_params) + @payroll_account.publish! redirect_to next_path end diff --git a/app/app/controllers/activities/job_training_controller.rb b/app/app/controllers/activities/job_training_controller.rb index 286aa0969..ddbd91fed 100644 --- a/app/app/controllers/activities/job_training_controller.rb +++ b/app/app/controllers/activities/job_training_controller.rb @@ -16,7 +16,7 @@ def new end def create - @job_training_activity = @flow.job_training_activities.new(job_training_activity_params) + @job_training_activity = @flow.job_training_activities.new(job_training_activity_params.merge(draft: true)) if @job_training_activity.save redirect_to edit_activities_flow_job_training_month_path(job_training_id: @job_training_activity, id: 0) else @@ -45,6 +45,7 @@ def review def save_review @job_training_activity.update(review_params) + @job_training_activity.publish! redirect_to after_activity_path end diff --git a/app/app/controllers/activities/submit_controller.rb b/app/app/controllers/activities/submit_controller.rb index f28c50c47..ae87a84a4 100644 --- a/app/app/controllers/activities/submit_controller.rb +++ b/app/app/controllers/activities/submit_controller.rb @@ -9,9 +9,9 @@ def show private def render_pdf - @community_service_activities = @flow.volunteering_activities.order(date: :desc, created_at: :desc) - @work_programs_activities = @flow.job_training_activities.order(created_at: :desc) - @education_activities = @flow.education_activities.order(created_at: :desc) + @community_service_activities = @flow.volunteering_activities.published.order(date: :desc, created_at: :desc) + @work_programs_activities = @flow.job_training_activities.published.order(created_at: :desc) + @education_activities = @flow.education_activities.published.order(created_at: :desc) @submission_timestamp = submission_timestamp @total_hours = progress_calculator.overall_result.total_hours diff --git a/app/app/controllers/activities/summary_controller.rb b/app/app/controllers/activities/summary_controller.rb index 1fbe21383..32003fbbc 100644 --- a/app/app/controllers/activities/summary_controller.rb +++ b/app/app/controllers/activities/summary_controller.rb @@ -32,11 +32,11 @@ def mark_as_completed end def load_summary_data - @community_service_activities = @flow.volunteering_activities.order(created_at: :asc) - @work_programs_activities = @flow.job_training_activities.order(created_at: :asc) - @education_activities = @flow.education_activities.order(created_at: :asc) - @employment_payroll_accounts = @flow.payroll_accounts.select(&:sync_succeeded?) - @self_attested_employment_activities = @flow.employment_activities.order(created_at: :asc) + @community_service_activities = @flow.volunteering_activities.published.order(created_at: :asc) + @work_programs_activities = @flow.job_training_activities.published.order(created_at: :asc) + @education_activities = @flow.education_activities.published.order(created_at: :asc) + @employment_payroll_accounts = @flow.payroll_accounts.published.select(&:sync_succeeded?) + @self_attested_employment_activities = @flow.employment_activities.published.order(created_at: :asc) @persisted_report = PersistedReportAdapter.new(@flow) @all_activities = build_activities_list end diff --git a/app/app/controllers/activities/volunteering_controller.rb b/app/app/controllers/activities/volunteering_controller.rb index 8da990e49..ad15ca461 100644 --- a/app/app/controllers/activities/volunteering_controller.rb +++ b/app/app/controllers/activities/volunteering_controller.rb @@ -16,7 +16,7 @@ def new end def create - @volunteering_activity = @flow.volunteering_activities.new(volunteering_activity_params) + @volunteering_activity = @flow.volunteering_activities.new(volunteering_activity_params.merge(draft: true)) if @volunteering_activity.save redirect_to edit_activities_flow_community_service_month_path(community_service_id: @volunteering_activity, id: 0) else @@ -45,6 +45,7 @@ def review def save_review @volunteering_activity.update(review_params) + @volunteering_activity.publish! redirect_to after_activity_path end diff --git a/app/app/controllers/demo_launcher_controller.rb b/app/app/controllers/demo_launcher_controller.rb index 69326f9f3..db14ce489 100644 --- a/app/app/controllers/demo_launcher_controller.rb +++ b/app/app/controllers/demo_launcher_controller.rb @@ -6,18 +6,11 @@ def show end def create - flow_type = params[:flow_type] - client_agency_id = params[:client_agency_id] - launch_type = params[:launch_type] - overrides = if flow_type == "cbv" - params.permit(:demo_timeout).select { |_, v| v.present? } - else - params.permit(:reporting_window, :reporting_window_months, :reporting_window_start, :demo_timeout).select { |_, v| v.present? } - end - - if overrides[:reporting_window_start].present? - overrides[:reporting_window_start] = normalize_date_param(overrides[:reporting_window_start]) - end + flow_type = launcher_params[:flow_type] + client_agency_id = launcher_params[:client_agency_id] + launch_type = launcher_params[:launch_type] + test_scenario = launcher_params[:test_scenario] + overrides = launch_overrides(flow_type) url = if flow_type == "cbv" if launch_type == "generic" @@ -25,17 +18,21 @@ def create else build_cbv_tokenized_url(client_agency_id, overrides) end - elsif params[:test_scenario].in?(FAKE_SCENARIO_KEYS) - build_fake_test_scenario_url(params[:test_scenario], client_agency_id, overrides) - elsif params[:test_scenario].present? - build_test_scenario_url(params[:test_scenario], client_agency_id, overrides) + elsif test_scenario.in?(FAKE_SCENARIO_KEYS) + build_fake_test_scenario_url(test_scenario, client_agency_id, overrides) + elsif test_scenario.present? + build_test_scenario_url(test_scenario, client_agency_id, overrides) elsif launch_type == "generic" build_generic_url(client_agency_id, overrides) else build_tokenized_url(client_agency_id, overrides) end - redirect_to url, allow_other_host: true + if request.format.json? + render json: { url: url } + else + redirect_to url, allow_other_host: true + end end private @@ -59,6 +56,36 @@ def normalize_date_param(date_str) end end + def launch_overrides(flow_type) + overrides = if flow_type == "cbv" + launcher_params.slice(:demo_timeout).select { |_, v| v.present? } + else + allowed_overrides = [ :reporting_window, :reporting_window_months, :reporting_window_start, :demo_timeout ] + allowed_overrides << :renewal_required_months if launcher_params[:reporting_window] == "renewal" + launcher_params.slice(*allowed_overrides).select { |_, v| v.present? } + end + + if overrides[:reporting_window_start].present? + overrides[:reporting_window_start] = normalize_date_param(overrides[:reporting_window_start]) + end + + overrides + end + + def launcher_params + params.fetch(:demo_launcher, params).permit( + :test_scenario, + :flow_type, + :client_agency_id, + :reporting_window, + :reporting_window_months, + :renewal_required_months, + :reporting_window_start, + :demo_timeout, + :launch_type + ) + end + def build_cbv_generic_url(client_agency_id, overrides) Rails.application.routes.url_helpers.cbv_flow_new_url( client_agency_id: client_agency_id, diff --git a/app/app/controllers/feedbacks_controller.rb b/app/app/controllers/feedbacks_controller.rb index 0d3a82e03..77dee7c05 100644 --- a/app/app/controllers/feedbacks_controller.rb +++ b/app/app/controllers/feedbacks_controller.rb @@ -2,13 +2,14 @@ class FeedbacksController < ApplicationController include ApplicationHelper def show - cbv_flow = session[:flow_id] ? CbvFlow.find_by(id: session[:flow_id]) : nil + flow = session[:flow_id] ? flow_class.find_by(id: session[:flow_id]) : nil + @client_agency_id = flow&.cbv_applicant&.client_agency_id event_name = params[:form] == "survey" ? "ApplicantClickedFeedbackSurveyLink" : "ApplicantClickedFeedbackLink" attributes = { referer: params[:referer], - cbv_flow_id: cbv_flow&.id, - cbv_applicant_id: cbv_flow&.cbv_applicant_id, - client_agency_id: cbv_flow&.cbv_applicant&.client_agency_id + cbv_flow_id: flow&.id, + cbv_applicant_id: flow&.cbv_applicant_id, + client_agency_id: @client_agency_id } event_logger.track(event_name, request, { @@ -23,20 +24,22 @@ def show def redirect_path if params[:form] == "survey" - survey_form_url + append_prefill_params(survey_form_url, ApplicationHelper::SURVEY_FORM_SESSION_ID_ENTRY) else - append_prefill_params(feedback_form_url) + append_prefill_params(feedback_form_url, ApplicationHelper::FEEDBACK_FORM_DEVICE_ID_ENTRY) end end - def append_prefill_params(url) + def append_prefill_params(url, entry_key) device_id = cookies.permanent.signed[:device_id] return url if device_id.blank? + identifier = [ @client_agency_id, device_id ].compact.join("/") + uri = URI.parse(url) params = URI.decode_www_form(uri.query || "") params << [ "usp", "pp_url" ] - params << [ ApplicationHelper::FEEDBACK_FORM_DEVICE_ID_ENTRY, device_id ] + params << [ entry_key, identifier ] uri.query = URI.encode_www_form(params) uri.to_s end diff --git a/app/app/controllers/flow_controller.rb b/app/app/controllers/flow_controller.rb index 97ac8f882..b5efe0b34 100644 --- a/app/app/controllers/flow_controller.rb +++ b/app/app/controllers/flow_controller.rb @@ -150,11 +150,15 @@ def normalize_token(token) def apply_demo_overrides return unless internal_environment? - if params[:reporting_window_months].present? && @flow.is_a?(ActivityFlow) - @flow.update!(reporting_window_months: params[:reporting_window_months].to_i) + if params[:reporting_window_months].present? + @flow.set_reporting_window_months!(params[:reporting_window_months]) end - if params[:reporting_window_start].present? && @flow.is_a?(ActivityFlow) + if params[:renewal_required_months].present? + @flow.set_required_month_count!(params[:renewal_required_months]) + end + + if params[:reporting_window_start].present? @flow.shift_reporting_window_start!(params[:reporting_window_start]) end diff --git a/app/app/controllers/webhooks/argyle/events_controller.rb b/app/app/controllers/webhooks/argyle/events_controller.rb index 014aea5c6..acb76a7de 100644 --- a/app/app/controllers/webhooks/argyle/events_controller.rb +++ b/app/app/controllers/webhooks/argyle/events_controller.rb @@ -22,6 +22,7 @@ def create payroll_account = @flow.payroll_accounts.find_or_create_by(type: :argyle, aggregator_account_id: account_id) do |new_payroll_account| new_payroll_account.synchronization_status = :in_progress new_payroll_account.supported_jobs = Aggregators::Webhooks::Argyle.get_supported_jobs + new_payroll_account.draft = true if @flow.is_a?(ActivityFlow) end webhook_event = create_webhook_event_for_account(params["event"], payroll_account) diff --git a/app/app/controllers/webhooks/pinwheel/events_controller.rb b/app/app/controllers/webhooks/pinwheel/events_controller.rb index 809349823..913f85b5a 100644 --- a/app/app/controllers/webhooks/pinwheel/events_controller.rb +++ b/app/app/controllers/webhooks/pinwheel/events_controller.rb @@ -13,6 +13,7 @@ class Webhooks::Pinwheel::EventsController < ApplicationController def create @payroll_account = @cbv_flow.payroll_accounts.find_or_create_by(type: :pinwheel, aggregator_account_id: params["payload"]["account_id"]) do |new_payroll_account| new_payroll_account.supported_jobs = get_supported_jobs(params["payload"]["platform_id"]) + new_payroll_account.draft = true if @cbv_flow.is_a?(ActivityFlow) end @webhook_event = WebhookEvent.create!( diff --git a/app/app/helpers/activities_helper.rb b/app/app/helpers/activities_helper.rb index c68c3a755..a29d8dbe8 100644 --- a/app/app/helpers/activities_helper.rb +++ b/app/app/helpers/activities_helper.rb @@ -1,8 +1,9 @@ module ActivitiesHelper - def activity_hub_state(any_activities_added:, monthly_results:) + def activity_hub_state(any_activities_added:, monthly_results:, required_month_count: monthly_results.length) return :empty unless any_activities_added - monthly_results.all?(&:meets_requirements) ? :completed : :in_progress + complete_month_count = monthly_results.count(&:meets_requirements) + complete_month_count >= required_month_count ? :completed : :in_progress end def activity_hub_title_key(state) @@ -53,7 +54,7 @@ def employment_cards(payroll_accounts, aggregator_report, reporting_range) { name: employer_name, months: months, - edit_path: activities_flow_income_payment_details_path(user: { account_id: account.aggregator_account_id }) + edit_path: activities_flow_income_payment_details_path(user: { account_id: account.aggregator_account_id }, from_edit: 1) } end end diff --git a/app/app/helpers/application_helper.rb b/app/app/helpers/application_helper.rb index 23e8fd0c7..49214783b 100644 --- a/app/app/helpers/application_helper.rb +++ b/app/app/helpers/application_helper.rb @@ -31,6 +31,14 @@ def activity_type_enabled?(type) current_agency&.activity_types&.[](type.to_sym) end + def uswds_sprite_icon_href(icon_name) + "#{asset_path("@uswds/uswds/dist/img/sprite.svg")}##{icon_name}" + end + + def uswds_icon_image_path(icon_name) + asset_path("@uswds/uswds/dist/img/usa-icons/#{icon_name}.svg") + end + # Render a translation that is specific to the current client agency. Define # client agency-specific translations as: # @@ -81,8 +89,9 @@ def agency_translation(i18n_base_key, **options) end APPLICANT_FEEDBACK_FORM = "https://docs.google.com/forms/d/e/1FAIpQLSdTdLUdhZbn6JcHvR_SN6zbIKhhPfVvs6aeJHz6UrZ9j-83AA/viewform" - APPLICANT_SURVEY_FORM = "https://forms.gle/M9jVQNue96rjQTbD9" + APPLICANT_SURVEY_FORM = "https://docs.google.com/forms/d/e/1FAIpQLSeQsXvKS3KIKEedwcGxv-c2Qa11hvgr3vyZp1YdLsrQ1Td-qQ/viewform" FEEDBACK_FORM_DEVICE_ID_ENTRY = "entry.1176961978" + SURVEY_FORM_SESSION_ID_ENTRY = "entry.818699989" def feedback_form_url APPLICANT_FEEDBACK_FORM diff --git a/app/app/javascript/controllers/activity_flow_header_controller.js b/app/app/javascript/controllers/activity_flow_header_controller.js index 64787b993..dd2eacf96 100644 --- a/app/app/javascript/controllers/activity_flow_header_controller.js +++ b/app/app/javascript/controllers/activity_flow_header_controller.js @@ -1,7 +1,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static values = { exitUrl: String } + static values = { exitUrl: String, confirmOnExit: Boolean } connect() { this.isDirty = false @@ -24,7 +24,7 @@ export default class extends Controller { handleExit(event) { event.preventDefault() - if (this.isDirty) { + if (this.confirmOnExitValue || this.isDirty) { this.element.querySelector("[data-open-modal]").click() } else { window.location.href = this.exitUrlValue diff --git a/app/app/javascript/controllers/demo_launcher_controller.js b/app/app/javascript/controllers/demo_launcher_controller.js index f0a30da80..a82f3656b 100644 --- a/app/app/javascript/controllers/demo_launcher_controller.js +++ b/app/app/javascript/controllers/demo_launcher_controller.js @@ -1,7 +1,21 @@ import { Controller } from "@hotwired/stimulus" +import { fetchInternal } from "../utilities/fetchInternal" export default class extends Controller { - static targets = ["monthButtons", "monthsInput", "ceOnly", "datePickerWrapper", "genericButton"] + static targets = [ + "monthButtons", + "monthsInput", + "ceOnly", + "datePickerWrapper", + "genericButton", + "renewalRequiredField", + "tokenizedButton", + "shareWidget", + "shareLabel", + "copyButton", + "generatedUrl", + "openGeneratedLink", + ] connect() { const selectedFlow = this.element.querySelector("input[name=flow_type]:checked") @@ -43,6 +57,60 @@ export default class extends Controller { this.highlightButton(months) } + invalidateShareLink(event) { + if (event.target === this.generatedUrlTarget) return + + this.shareWidgetTarget.hidden = true + this.generatedUrlTarget.value = "" + this.openGeneratedLinkTarget.href = "#" + this.resetCopyButton() + } + + async generateShareLink(event) { + event.preventDefault() + + const button = event.currentTarget + const launchType = button.value + button.disabled = true + + try { + const formData = new FormData(this.element) + formData.set("launch_type", launchType) + const { url } = await fetchInternal(this.element.action, { + method: this.element.method.toUpperCase(), + headers: { + Accept: "application/json", + }, + body: JSON.stringify(Object.fromEntries(formData.entries())), + credentials: "same-origin", + }) + this.generatedUrlTarget.value = url + this.openGeneratedLinkTarget.href = url + this.shareLabelTarget.textContent = `${this.humanizeLaunchType(launchType)} link` + this.shareWidgetTarget.hidden = false + this.resetCopyButton() + } catch (_error) { + this.resetCopyButton() + } finally { + button.disabled = false + } + } + + async copyGeneratedLink() { + const url = this.generatedUrlTarget.value + if (!url) return + + try { + await navigator.clipboard.writeText(url) + this.showCopiedState() + } catch (_error) { + this.generatedUrlTarget.focus() + this.generatedUrlTarget.select() + document.execCommand("copy") + this.showCopiedState() + } + } + // private applyFlowType(value) { @@ -105,11 +173,37 @@ export default class extends Controller { this.genericButtonTarget.disabled = disabled } + humanizeLaunchType(launchType) { + return launchType === "generic" ? "Generic" : "Tokenized" + } + + resetCopyButton() { + if (!this.hasCopyButtonTarget) return + + this.copyButtonTarget.textContent = "Copy link" + this.copyButtonTarget.classList.add("usa-button--outline") + this.copyButtonTarget.classList.remove("usa-button--success") + } + + showCopiedState() { + if (!this.hasCopyButtonTarget) return + + this.copyButtonTarget.textContent = "Link copied" + this.copyButtonTarget.classList.remove("usa-button--outline") + this.copyButtonTarget.classList.add("usa-button--success") + } + applyWindow(value) { if (value === "renewal") { + if (this.hasRenewalRequiredFieldTarget) { + this.renewalRequiredFieldTarget.classList.remove("demo-launcher__renewal-required--hidden") + } this.monthButtonsTarget.classList.add("demo-launcher__month-buttons--hidden") this.monthsInputTarget.value = "6" } else { + if (this.hasRenewalRequiredFieldTarget) { + this.renewalRequiredFieldTarget.classList.add("demo-launcher__renewal-required--hidden") + } this.monthButtonsTarget.classList.remove("demo-launcher__month-buttons--hidden") this.monthsInputTarget.value = "2" this.highlightButton("2") diff --git a/app/app/javascript/controllers/index.js b/app/app/javascript/controllers/index.js index 799eac47f..63867cb94 100644 --- a/app/app/javascript/controllers/index.js +++ b/app/app/javascript/controllers/index.js @@ -13,6 +13,7 @@ import DemoLauncherController from "./demo_launcher_controller.js" import HoursInputController from "./hours_input_controller.js" import SelfEmployedController from "./self_employed_controller.js" import ActivityFlowHeaderController from "./activity_flow_header_controller.js" +import ProgressIndicatorUnitsController from "./progress_indicator_units_controller.js" application.register("cbv-employer-search", CbvEmployerSearch) application.register("polling", PollingController) @@ -27,6 +28,7 @@ application.register("demo-launcher", DemoLauncherController) application.register("hours-input", HoursInputController) application.register("self-employed", SelfEmployedController) application.register("activity-flow-header", ActivityFlowHeaderController) +application.register("progress-indicator-units", ProgressIndicatorUnitsController) Turbo.StreamActions.redirect = function () { Turbo.visit(this.target) diff --git a/app/app/javascript/controllers/progress_indicator_units_controller.js b/app/app/javascript/controllers/progress_indicator_units_controller.js new file mode 100644 index 000000000..9c624dfc0 --- /dev/null +++ b/app/app/javascript/controllers/progress_indicator_units_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["toggle", "unitContent"] + static values = { unit: String } + + connect() { + this.render() + } + + toggle(event) { + const wasFocused = this.toggleTargets.includes(document.activeElement) + this.unitValue = event.currentTarget.dataset.nextUnit + this.render() + if (wasFocused) { + this.toggleTargets.find((toggle) => !toggle.hidden)?.focus() + } + } + + render() { + this.toggleTargets.forEach((toggle) => { + toggle.hidden = toggle.dataset.unit !== this.unitValue + }) + + this.unitContentTargets.forEach((content) => { + content.hidden = content.dataset.unit !== this.unitValue + }) + } +} diff --git a/app/app/jobs/draft_cleanup_job.rb b/app/app/jobs/draft_cleanup_job.rb new file mode 100644 index 000000000..61a9a2f2b --- /dev/null +++ b/app/app/jobs/draft_cleanup_job.rb @@ -0,0 +1,11 @@ +class DraftCleanupJob < ApplicationJob + CUTOFF = 24.hours + + def perform + cutoff = CUTOFF.ago + [ VolunteeringActivity, JobTrainingActivity, EmploymentActivity, EducationActivity ].each do |klass| + klass.where(draft: true, created_at: ...cutoff).destroy_all + end + PayrollAccount.where(draft: true, created_at: ...cutoff).destroy_all + end +end diff --git a/app/app/models/activity.rb b/app/app/models/activity.rb index 6aa37d7a8..f1e5c7751 100644 --- a/app/app/models/activity.rb +++ b/app/app/models/activity.rb @@ -5,6 +5,12 @@ class Activity < ApplicationRecord enum :data_source, { self_attested: "self_attested", validated: "validated" }, default: :self_attested + scope :published, -> { where(draft: false) } + + def publish! + update!(draft: false) + end + validate :date_within_reporting_window def date=(value) diff --git a/app/app/models/activity_flow.rb b/app/app/models/activity_flow.rb index 5bdc5f3ac..9584ad50e 100644 --- a/app/app/models/activity_flow.rb +++ b/app/app/models/activity_flow.rb @@ -12,7 +12,7 @@ class ActivityFlow < Flow has_many :payroll_accounts, as: :flow, dependent: :destroy has_many :activity_flow_monthly_summaries, dependent: :destroy - before_create :set_default_reporting_window + before_create :set_default_reporting_window, :set_default_renewal_required_months def self.create_from_invitation(invitation, device_id, params = {}) create( @@ -59,17 +59,16 @@ def reporting_window_display "#{start_display} - #{end_display}" end - def complete? - completed_at.present? + def any_activities_added? + volunteering_activities.published.exists? || + job_training_activities.published.exists? || + education_activities.published.exists? || + employment_activities.published.exists? || + payroll_accounts.published.exists? end - def any_activities_added? - education_activities.where.associated(:nsc_enrollment_terms).exists? || - education_activities.fully_self_attested.exists? || - volunteering_activities.exists? || - job_training_activities.exists? || - employment_activities.exists? || - payroll_accounts.exists? + def complete? + completed_at.present? end def invitation_id @@ -100,6 +99,21 @@ def renewal_reporting_window? reporting_window_type == "renewal" end + def required_month_count + window_months = reporting_window_months || calculate_reporting_window_months + return window_months unless renewal_reporting_window? + + renewal_required_months || window_months + end + + def set_required_month_count!(requested_count) + update!(renewal_required_months: requested_count) + end + + def set_reporting_window_months!(requested_months) + update!(reporting_window_months: requested_months.to_i) + end + private def set_default_reporting_window @@ -112,4 +126,11 @@ def calculate_reporting_window_months client_agency = Rails.application.config.client_agencies[cbv_applicant&.client_agency_id] client_agency&.application_reporting_months || 1 end + + def set_default_renewal_required_months + return unless renewal_reporting_window? + + client_agency = Rails.application.config.client_agencies[cbv_applicant&.client_agency_id] + self.renewal_required_months ||= client_agency&.renewal_required_months || reporting_window_months + end end diff --git a/app/app/models/activity_flow_monthly_summary.rb b/app/app/models/activity_flow_monthly_summary.rb index dd6ebe58f..491a82e84 100644 --- a/app/app/models/activity_flow_monthly_summary.rb +++ b/app/app/models/activity_flow_monthly_summary.rb @@ -13,7 +13,7 @@ class ActivityFlowMonthlySummary < ApplicationRecord # calculator and views expect (account_id => month => data). # Returns nil when data is incomplete so callers know to fall back to the API. def self.load_complete_summary_data(activity_flow:) - synced_accounts = activity_flow.payroll_accounts.select(&:sync_succeeded?) + synced_accounts = activity_flow.payroll_accounts.published.select(&:sync_succeeded?) return nil if synced_accounts.empty? months = activity_flow.reporting_months.map(&:beginning_of_month) diff --git a/app/app/models/cbv_applicant/research.rb b/app/app/models/cbv_applicant/research.rb new file mode 100644 index 000000000..c49f29f8f --- /dev/null +++ b/app/app/models/cbv_applicant/research.rb @@ -0,0 +1,10 @@ +class CbvApplicant::Research < CbvApplicant + VALID_ATTRIBUTES = %i[ + case_number + date_of_birth + ] + + has_redactable_fields( + date_of_birth: :date + ) +end diff --git a/app/app/models/flow.rb b/app/app/models/flow.rb index 24ad1b89d..df071b452 100644 --- a/app/app/models/flow.rb +++ b/app/app/models/flow.rb @@ -34,4 +34,16 @@ def after_payroll_sync_succeeded(_payroll_account, _report) def activity_month_order_oldest_first? false end + + def set_required_month_count!(_requested_count) + nil + end + + def set_reporting_window_months!(_requested_months) + nil + end + + def shift_reporting_window_start!(_date_str) + nil + end end diff --git a/app/app/models/payroll_account.rb b/app/app/models/payroll_account.rb index c985e133d..3b9763a82 100644 --- a/app/app/models/payroll_account.rb +++ b/app/app/models/payroll_account.rb @@ -11,7 +11,8 @@ def self.sti_class_for(type_name) belongs_to :flow, polymorphic: true validates :flow, presence: true - has_many :webhook_events + has_many :webhook_events, dependent: :destroy + has_many :activity_flow_monthly_summaries, dependent: :destroy enum :data_source, { self_attested: "self_attested", validated: "validated" }, default: :validated @@ -22,6 +23,12 @@ def self.sti_class_for(type_name) failed: "failed" # defines the method: sync_failed? }, prefix: "sync" + scope :published, -> { where(draft: false) } + + def publish! + update!(draft: false) + end + # Returns whether we have received all expected webhooks for the sync # process, regardless of whether any of them are errors. # diff --git a/app/app/models/payroll_account/argyle.rb b/app/app/models/payroll_account/argyle.rb index d16665a44..98b6d7f11 100644 --- a/app/app/models/payroll_account/argyle.rb +++ b/app/app/models/payroll_account/argyle.rb @@ -1,4 +1,6 @@ class PayrollAccount::Argyle < PayrollAccount + before_destroy :safely_delete_aggregator_account + scope :awaiting_fully_synced_webhook, -> do joins(<<~SQL).where(webhook_events: { id: nil }) LEFT OUTER JOIN webhook_events @@ -59,14 +61,22 @@ def sync_started_at account_connected_at || created_at end - def redact! + def safely_delete_aggregator_account + delete_aggregator_account + rescue => ex + Rails.logger.error "Unable to delete Argyle account #{aggregator_account_id}: #{ex.message}" + end + + def delete_aggregator_account argyle_environment = Rails.application.config.client_agencies[flow.cbv_applicant.client_agency_id].argyle_environment argyle = Aggregators::Sdk::ArgyleService.new(argyle_environment) - begin - argyle.delete_account_api(account: aggregator_account_id) - rescue Faraday::ResourceNotFound - # Account already deleted on Argyle's side — safe to proceed with local redaction - end + argyle.delete_account_api(account: aggregator_account_id) + rescue Faraday::ResourceNotFound + # Account already deleted on Argyle's side + end + + def redact! + delete_aggregator_account update_column(:additional_information, Redactable::REDACTION_REPLACEMENTS[:string]) touch(:redacted_at) rescue => ex diff --git a/app/app/services/activity_flow_progress_calculator.rb b/app/app/services/activity_flow_progress_calculator.rb index e1f32fb0a..cffa41e27 100644 --- a/app/app/services/activity_flow_progress_calculator.rb +++ b/app/app/services/activity_flow_progress_calculator.rb @@ -9,17 +9,18 @@ class ActivityFlowProgressCalculator def initialize(activity_flow) @activity_flow = activity_flow - @volunteering_activities = activity_flow.volunteering_activities - @job_training_activities = activity_flow.job_training_activities - @education_activities = activity_flow.education_activities - @employment_activities = activity_flow.employment_activities + @required_month_count = activity_flow.required_month_count + @volunteering_activities = activity_flow.volunteering_activities.published + @job_training_activities = activity_flow.job_training_activities.published + @education_activities = activity_flow.education_activities.published + @employment_activities = activity_flow.employment_activities.published end def overall_result OverallResult.new( total_hours: total_hours, - meets_requirements: each_month_meets_threshold?, - meets_routing_requirements: each_month_meets_threshold_with_validated_data? + meets_requirements: required_months_meet_threshold?, + meets_routing_requirements: required_months_meet_threshold_with_validated_data? ) end @@ -42,6 +43,8 @@ def reporting_months @activity_flow.reporting_months end + attr_reader :required_month_count + private def total_hours @@ -58,22 +61,22 @@ def education_hours reporting_months.sum { |month_start| education_hours_for_month(month_start) } end - def each_month_meets_threshold? - reporting_months.all? do |month_start| + def required_months_meet_threshold? + reporting_months.count do |month_start| hours_for_month(month_start) >= PER_MONTH_HOURS_THRESHOLD || earnings_for_month(month_start) >= PER_MONTH_EARNINGS_THRESHOLD - end - end - - def default_unit_for_month(hours:, earnings_cents:) - earnings_cents >= PER_MONTH_EARNINGS_THRESHOLD && hours < PER_MONTH_HOURS_THRESHOLD ? :dollars : :hours + end >= required_month_count end - def each_month_meets_threshold_with_validated_data? - reporting_months.all? do |month_start| + def required_months_meet_threshold_with_validated_data? + reporting_months.count do |month_start| validated_hours_for_month(month_start) >= PER_MONTH_HOURS_THRESHOLD || validated_earnings_for_month(month_start) >= PER_MONTH_EARNINGS_THRESHOLD - end + end >= required_month_count + end + + def default_unit_for_month(hours:, earnings_cents:) + earnings_cents >= PER_MONTH_EARNINGS_THRESHOLD && hours < PER_MONTH_HOURS_THRESHOLD ? :dollars : :hours end def hours_for_month(month_start) @@ -199,7 +202,10 @@ def validated_volunteering_hours_for_month(month_start) end def validated_account_ids - @validated_account_ids ||= @activity_flow.payroll_accounts.select(&:validated?).map(&:aggregator_account_id).compact + @validated_account_ids ||= @activity_flow.payroll_accounts.published + .select(&:validated?) + .map(&:aggregator_account_id) + .compact end def monthly_summaries diff --git a/app/app/services/aggregators/aggregator_reports/aggregator_report.rb b/app/app/services/aggregators/aggregator_reports/aggregator_report.rb index ce71ef66e..87608979e 100644 --- a/app/app/services/aggregators/aggregator_reports/aggregator_report.rb +++ b/app/app/services/aggregators/aggregator_reports/aggregator_report.rb @@ -95,6 +95,12 @@ def income_report pay_period_start: paystub.pay_period_start, pay_period_end: paystub.pay_period_end, pay_gross: paystub.gross_pay_amount || 0, + gross_pay_list: (paystub.earnings || []).map do |earning| + { + type: earning.category, + amount: earning.amount + } + end, pay_gross_ytd: paystub.gross_pay_ytd, pay_net: paystub.net_pay_amount, hours_paid: paystub.hours, diff --git a/app/app/views/activities/activities/_activity_section.html.erb b/app/app/views/activities/activities/_activity_section.html.erb index 50b6a0bd8..d2b478346 100644 --- a/app/app/views/activities/activities/_activity_section.html.erb +++ b/app/app/views/activities/activities/_activity_section.html.erb @@ -3,7 +3,7 @@

    <%= section_title %>

    <%= button_to add_path, class: "usa-button usa-button--outline", method: :get do %> <%= t("activities.hub.add") %> <% end %> diff --git a/app/app/views/activities/activities/index.html.erb b/app/app/views/activities/activities/index.html.erb index 3ddf04d0d..9f91c08cc 100644 --- a/app/app/views/activities/activities/index.html.erb +++ b/app/app/views/activities/activities/index.html.erb @@ -1,6 +1,7 @@ <% any_activities_added = @flow.any_activities_added? %> <% monthly_results = progress_calculator.monthly_results %> -<% state = activity_hub_state(any_activities_added:, monthly_results:) %> +<% required_month_count = progress_calculator.required_month_count %> +<% state = activity_hub_state(any_activities_added:, monthly_results:, required_month_count:) %> <% variant = @flow.renewal_reporting_window? ? :renewal : :application %> <% title_key = activity_hub_title_key(state) %> <% description_key = activity_hub_description_key(state) %> @@ -27,10 +28,10 @@

    <% if variant == :renewal %> - <% months_required_key = monthly_results.length < @flow.reporting_window_months ? "activities.hub.empty_state_months_required_any" : "activities.hub.empty_state_months_required_all" %> + <% months_required_key = required_month_count < @flow.reporting_window_months ? "activities.hub.empty_state_months_required_any" : "activities.hub.empty_state_months_required_all" %>

    <%= t("activities.hub.empty_state_months_required_label") %> - <%= t(months_required_key, required_month_count: monthly_results.length) %> + <%= t(months_required_key, required_month_count: required_month_count) %>

    <% end %> @@ -44,11 +45,13 @@ <% end %> <% if any_activities_added %> + <% show_progress_unit_toggle = @employment_activities.any? || @employment_payroll_accounts.any? %>
    <%= render( ActivityFlowProgressIndicator.from_calculator( progress_calculator, - variant: variant + variant: variant, + show_unit_toggle: show_progress_unit_toggle ) ) %>
    diff --git a/app/app/views/activities/document_uploads/new.html.erb b/app/app/views/activities/document_uploads/new.html.erb index 66214e338..a95f75510 100644 --- a/app/app/views/activities/document_uploads/new.html.erb +++ b/app/app/views/activities/document_uploads/new.html.erb @@ -29,34 +29,30 @@ <% end %> +<% if @activity.document_uploads.any? %> + <%= render DocumentUploadsComponent.new( + documents: @activity.document_uploads.map do |attachment| + { + filename: attachment.filename.to_s, + remove_path: remove_document_upload_path(attachment) + } + end, + show_remove_file: true + ) %> +<% end %> + <%= form_with(builder: UswdsFormBuilder, scope: :activity, model: @activity, url: upload_path, method: :post) do |f| %> <% if @activity.document_uploads.any? %> -

    Uploaded Documents:

    - -
      - <% @activity.document_uploads.each do |attachment| %> - <%# Add hidden fields for the existing uploads so Rails won't delete them if the form is submitted a second time: %> - <%= f.hidden_field :document_uploads, multiple: true, value: attachment.signed_id %> - -
    • - <% if (preview_url = document_upload_preview_url(attachment)) %> - <%= preview_url %> - <% else %> - - <% end %> - - <%= attachment.filename %> - -
    • - <% end %> -
    + <% @activity.document_uploads.each do |attachment| %> + <%# Keep existing uploads attached when the form is re-submitted with new files. %> + <%= f.hidden_field :document_uploads, multiple: true, value: attachment.signed_id %> + <% end %> <% end %> <%= f.file_field :document_uploads, multiple: true, label: t(".input_label"), + label_class: "font-heading-lg text-bold", hint: t(".input_hint"), accept: "image/*,.pdf" %> diff --git a/app/app/views/activities/summary/show.html.erb b/app/app/views/activities/summary/show.html.erb index 4fd91e8cf..825dcf027 100644 --- a/app/app/views/activities/summary/show.html.erb +++ b/app/app/views/activities/summary/show.html.erb @@ -29,9 +29,16 @@ <% end %>

    -<%= render(ActivityFlowProgressIndicator.from_calculator(progress_calculator)) %> +<% show_progress_unit_toggle = @self_attested_employment_activities.any? || @employment_payroll_accounts.any? %> -
    +<%= render( + ActivityFlowProgressIndicator.from_calculator( + progress_calculator, + variant: @flow.renewal_reporting_window? ? :renewal : :application, + show_unit_toggle: show_progress_unit_toggle, + display_variant: :review + ) +) %>
    <% @all_activities.each_with_index do |activity_data, index| %> diff --git a/app/app/views/application/_header.html.erb b/app/app/views/application/_header.html.erb index 0ef4f097d..6d86dcc73 100644 --- a/app/app/views/application/_header.html.erb +++ b/app/app/views/application/_header.html.erb @@ -53,7 +53,7 @@