diff --git a/app/assets/stylesheets/pages/case_contacts_form.scss b/app/assets/stylesheets/pages/case_contacts_form.scss index 1616481ff4..81b06d2382 100644 --- a/app/assets/stylesheets/pages/case_contacts_form.scss +++ b/app/assets/stylesheets/pages/case_contacts_form.scss @@ -23,6 +23,18 @@ border-bottom-right-radius: 0; } + h2 { + font-size: 28px; + } + + h3 { + font-size: medium; + } + + legend { + font-size: medium; + } + .case-contacts-form { &-buttons { display: flex; diff --git a/app/controllers/additional_expenses_controller.rb b/app/controllers/additional_expenses_controller.rb new file mode 100644 index 0000000000..f62068d21f --- /dev/null +++ b/app/controllers/additional_expenses_controller.rb @@ -0,0 +1,32 @@ +class AdditionalExpensesController < ApplicationController + def create + @additional_expense = AdditionalExpense.new(additional_expense_params) + authorize @additional_expense + + respond_to do |format| + if @additional_expense.save + format.json { render json: @additional_expense, status: :created } + else + format.json { render json: @additional_expense.errors, status: :unprocessable_entity } + end + end + end + + def destroy + @additional_expense = AdditionalExpense.find(params[:id]) + authorize @additional_expense + + @additional_expense.destroy! + + respond_to do |format| + format.json { head :no_content } + end + end + + private + + def additional_expense_params + params.require(:additional_expense) + .permit(:case_contact_id, :other_expense_amount, :other_expenses_describe) + end +end diff --git a/app/controllers/case_contacts/form_controller.rb b/app/controllers/case_contacts/form_controller.rb index dcc08ad4eb..e69819c8ac 100644 --- a/app/controllers/case_contacts/form_controller.rb +++ b/app/controllers/case_contacts/form_controller.rb @@ -36,7 +36,7 @@ def update if @case_contact.update(case_contact_params) render json: @case_contact, status: :ok else - render json: @case_contact.errors, status: :unprocessable_entity + render json: @case_contact.errors.full_messages, status: :unprocessable_entity end end end diff --git a/app/javascript/controllers/autosave_controller.js b/app/javascript/controllers/autosave_controller.js index b4fa64a36b..fc47154738 100644 --- a/app/javascript/controllers/autosave_controller.js +++ b/app/javascript/controllers/autosave_controller.js @@ -29,12 +29,29 @@ export default class extends Controller { }).then(response => { if (response.ok) { this.goodAlert() + const event = new CustomEvent('autosave:success', { bubbles: true }) // eslint-disable-line no-undef + this.element.dispatchEvent(event) } else { - if (response.status === 504) { + 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.') - } else { + 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.') - } } }) } diff --git a/app/javascript/controllers/casa_nested_form_controller.js b/app/javascript/controllers/casa_nested_form_controller.js index 636806c07c..516bbc2d8b 100644 --- a/app/javascript/controllers/casa_nested_form_controller.js +++ b/app/javascript/controllers/casa_nested_form_controller.js @@ -3,10 +3,10 @@ import NestedForm from '@stimulus-components/rails-nested-form' // Connects to data-controller="casa-nested-form" // Allows nested forms to be used with autosave controller, adding and deleting records -// so that autosave updates do not create/destroy nested records multiple times. +// so that autosave does not create/destroy nested records multiple times. // add() & remove() are kept standard, can be used without autosave just fine: // no values are necessary in that case. -// Created for the CaseContact form, see its usage there. +// Created for the CaseContact form (details), see usage there. export default class extends NestedForm { static values = { route: String, // path to create/destroy a record, e.g. "/contact_topic_answers" @@ -29,15 +29,45 @@ export default class extends NestedForm { headers.append('X-CSRF-Token', tokenTag.content) } this.headers = headers + + document.addEventListener('autosave:success', (e) => { + this.onAutosaveSuccess(e) + }) + } + + getRecordId (wrapper) { + const recordInput = wrapper.querySelector('input[name*="id"]') + if (!recordInput) { + console.warn('id input not found for nested item:', wrapper) + return '' + } + return recordInput.value + } + + /* removes any items that have been marked destroy: true on autosave:success */ + /* can be marked for destroy elsewhere, as in case_contact_form_controller hide reimbursement */ + onAutosaveSuccess (_e) { + const wrappers = this.element.querySelectorAll(this.wrapperSelectorValue) + wrappers.forEach(wrapper => { + const destroyInput = wrapper.querySelector("input[name*='_destroy']") + if (!destroyInput) { + console.warn('Destroy input not found for nested item:', wrapper) + return + } + if (destroyInput.value === '1') { + // autosave has already destroyed the record, remove the element from DOM + wrapper.remove() + } + }) } /* Adds item to the form. Item will not be created until form submission. */ - add = (e) => { + add (e) { super.add(e) } /* Creates a new record for the added item (before submission). */ - addAndCreate = (e) => { + addAndCreate (e) { this.add(e) const items = this.element.querySelectorAll(this.wrapperSelectorValue) const addedItem = items[items.length - 1] @@ -83,7 +113,6 @@ export default class extends NestedForm { const idField = document.querySelector(`#${idAttr}`) idField.setAttribute('value', data.id) addedItem.dataset.newRecord = false - addedItem.dataset.recordId = data.id }) .catch(error => { console.error(error.status, error.statusText) @@ -99,9 +128,9 @@ export default class extends NestedForm { } /* Destroys a record when removing the item (before submission). */ - destroyAndRemove = (e) => { + destroyAndRemove (e) { const wrapper = e.target.closest(this.wrapperSelectorValue) - const recordId = wrapper.dataset.recordId + const recordId = this.getRecordId(wrapper) if (wrapper.dataset.newRecord === 'false' && (recordId.length > 0)) { fetch(`${this.routeValue}/${recordId}`, { method: 'DELETE', @@ -109,9 +138,8 @@ export default class extends NestedForm { }) .then(response => { if (response.ok) { - // deleted from db; update as non-persisted, remove as usual. + // destroy successful; remove as if new record wrapper.dataset.newRecord = true - wrapper.dataset.recordId = '' this.remove(e) } else { return Promise.reject(response) @@ -119,13 +147,19 @@ export default class extends NestedForm { }) .catch(error => { console.error(error.status, error.statusText) - error.json().then(errorJson => { - console.error('errorJson', errorJson) - }) + if (error.status === 404) { + // NOT FOUND: already deleted -> remove as if new record + wrapper.dataset.newRecord = true + this.remove(e) + } else { + error.json().then(errorJson => { + console.error('errorJson', errorJson) + }) + } }) } else { console.warn( - 'Not enough information to destroy record:', { + 'Conflicting information while trying to destroy record:', { wrapperDatasetNewRecord: wrapper.dataset.newRecord, recordId } diff --git a/app/javascript/controllers/case_contact_form_controller.js b/app/javascript/controllers/case_contact_form_controller.js index 3c2e7565e5..86f9c28ad5 100644 --- a/app/javascript/controllers/case_contact_form_controller.js +++ b/app/javascript/controllers/case_contact_form_controller.js @@ -3,8 +3,7 @@ import { Controller } from '@hotwired/stimulus' // Connects to data-controller="case-contact-form" export default class extends Controller { static targets = [ - 'expenseAmount', - 'expenseDescribe', + 'expenseDestroy', 'milesDriven', 'volunteerAddress', 'reimbursementForm', @@ -16,8 +15,10 @@ export default class extends Controller { } clearExpenses = () => { - this.expenseDescribeTargets.forEach(el => (el.value = '')) - this.expenseAmountTargets.forEach(el => (el.value = '')) + // mark for destruction. autosave has already created the records. + // if autosaved, nested form controller will remove destroy: true items + // if form submitted, it will be destroyed. + this.expenseDestroyTargets.forEach(el => (el.value = '1')) } clearMileage = () => { @@ -28,6 +29,7 @@ export default class extends Controller { setReimbursementFormVisibility = () => { if (this.wantDrivingReimbursementTarget.checked) { this.reimbursementFormTarget.classList.remove('d-none') + this.expenseDestroyTargets.forEach(el => (el.value = '0')) } else { this.clearExpenses() this.clearMileage() diff --git a/app/models/additional_expense.rb b/app/models/additional_expense.rb index 12856b8ce2..6ed7f2567e 100644 --- a/app/models/additional_expense.rb +++ b/app/models/additional_expense.rb @@ -1,8 +1,16 @@ class AdditionalExpense < ApplicationRecord belongs_to :case_contact + has_one :casa_case, through: :case_contact + has_one :casa_org, through: :casa_case - # validates :other_expense_amount, presence: true - validates :other_expenses_describe, presence: {message: "Expense description cannot be blank."} + validates :other_expenses_describe, presence: true, if: :describe_required? + + alias_attribute :amount, :other_expense_amount + alias_attribute :describe, :other_expenses_describe + + def describe_required? + other_expense_amount&.positive? + end end # == Schema Information diff --git a/app/models/case_contact.rb b/app/models/case_contact.rb index 73c9c0c3fb..60b7b0d6a6 100644 --- a/app/models/case_contact.rb +++ b/app/models/case_contact.rb @@ -72,9 +72,7 @@ def active_or_notes? end accepts_nested_attributes_for :additional_expenses, reject_if: :all_blank, allow_destroy: true - validates_associated :additional_expenses - accepts_nested_attributes_for :casa_case accepts_nested_attributes_for :contact_topic_answers, allow_destroy: true, reject_if: proc { |attrs| attrs["contact_topic_id"].blank? && attrs["value"].blank? } # .notes sent without topic_id, but must have a value. diff --git a/app/policies/additional_expense_policy.rb b/app/policies/additional_expense_policy.rb new file mode 100644 index 0000000000..ef3d6b9c9b --- /dev/null +++ b/app/policies/additional_expense_policy.rb @@ -0,0 +1,36 @@ +class AdditionalExpensePolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + case user + when CasaAdmin, Supervisor + scope.joins([:case_contact, :casa_case]).where(casa_case: {casa_org_id: user.casa_org.id}) + when Volunteer + scope.where(case_contact: user.case_contacts) + else + scope.none + end + end + end + + def create? + case user + when Volunteer + user.case_contacts.include?(record.case_contact) + when CasaAdmin, Supervisor + same_org? + else + false + end + end + + def destroy? + case user + when Volunteer + user.case_contacts.include?(record.case_contact) + when CasaAdmin, Supervisor + same_org? + else + false + end + end +end diff --git a/app/views/case_contacts/form/_contact_topic_answer.html.erb b/app/views/case_contacts/form/_contact_topic_answer.html.erb index 7b123403a0..7c127ae7c2 100644 --- a/app/views/case_contacts/form/_contact_topic_answer.html.erb +++ b/app/views/case_contacts/form/_contact_topic_answer.html.erb @@ -1,8 +1,7 @@