From 6e242d98a13e43eaeb05ff795a225a449589cc0f Mon Sep 17 00:00:00 2001 From: Josue Granados Date: Fri, 8 Mar 2024 12:51:05 -0600 Subject: [PATCH 1/5] feat: add change-detection logic --- .../detect_form_changes_controller.js | 72 +++++++++++++++++++ .../halt_navigation_on_change_controller.js | 35 +++++++++ app/presenters/organization_form_presenter.rb | 21 ++++++ 3 files changed, 128 insertions(+) create mode 100644 app/javascript/controllers/detect_form_changes_controller.js create mode 100644 app/javascript/controllers/halt_navigation_on_change_controller.js create mode 100644 app/presenters/organization_form_presenter.rb diff --git a/app/javascript/controllers/detect_form_changes_controller.js b/app/javascript/controllers/detect_form_changes_controller.js new file mode 100644 index 000000000..fc58c5a95 --- /dev/null +++ b/app/javascript/controllers/detect_form_changes_controller.js @@ -0,0 +1,72 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["form", "utilityInput"]; + static values = { + inputTypes: String, + } + + connect() { + const initialFormInputs = [...this.formTarget.querySelectorAll(this.inputTypesValue)]; + this.formTarget.initialNumberOfInputs = this.validInputsLength(initialFormInputs); + this.formTarget.changed = false; + } + + captureUserInput(event) { + const input = event.target; + + if (this.utilityInputTargets.includes(input) || this.eventAndInputIncompatible(event, input)) { + return; + } + + this.detectChanges(this.formTarget); + } + + detectChanges(form, newInputAdded = false) { + const didFormChange = newInputAdded || this.changesInForm(form); + form.changed = didFormChange; + } + + changesInForm(form) { + const currentFormInputs = [...form.querySelectorAll(this.inputTypesValue)]; + + return (this.validInputsLength(currentFormInputs) !== form.initialNumberOfInputs) || + currentFormInputs.some(input => this.inputValueChanged(input)); + } + + inputValueChanged(input) { + if (input.type === "checkbox" || input.type === "radio") { + return input.checked !== input.defaultChecked + } + + if (input.type === "select-one") { + const selectedOption = input.options[input.selectedIndex]; + return selectedOption.selected !== selectedOption.defaultSelected; + } + + return input.value.trim() !== input.defaultValue; + } + + // Some inputs don't send any data + validInputsLength(currentFormInputs) { + const validInputs = currentFormInputs.filter(input => { + return !this.utilityInputTargets.includes(input); + }); + + return validInputs.length; + } + + // `change` event is more suitable for input types that involve user selection or choice. + eventAndInputIncompatible(event, input) { + const inputIsSelectable = + ["radio", "checkbox", "select-one", "select-multiple"].includes(input.type); + + return (event.type === "input" && inputIsSelectable) || + (event.type === "change" && !inputIsSelectable); + } + + // users can add or remove inputs + captureDOMUpdate(event) { + this.detectChanges(event.currentTarget, event.detail.newInputAdded); + } +} diff --git a/app/javascript/controllers/halt_navigation_on_change_controller.js b/app/javascript/controllers/halt_navigation_on_change_controller.js new file mode 100644 index 000000000..358853bac --- /dev/null +++ b/app/javascript/controllers/halt_navigation_on_change_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["form", "modalTemplate"] + static values = { + modalContainerId: String, + discardOptionId: String, + } + + // click-based + displayModalOnChange(event) { + if (this.formTarget.changed) { + event.preventDefault(); + const modal = this.modal; + this.prepareDiscardOption(modal, event.detail.url); + this.addModalToDocument(modal); + } + } + + // Modal is added out of this controller scope + prepareDiscardOption(modal, targetLocation) { + const discardOption = modal.querySelector(`#${this.discardOptionIdValue}`); + discardOption.setAttribute("href", targetLocation); + } + + addModalToDocument(modal) { + const modalContainer = document.getElementById(this.modalContainerIdValue); + modalContainer.appendChild(modal); + } + + get modal() { + const modalFragment = this.modalTemplateTarget.content; + return document.importNode(modalFragment, true); + } +} diff --git a/app/presenters/organization_form_presenter.rb b/app/presenters/organization_form_presenter.rb new file mode 100644 index 000000000..9273f19d8 --- /dev/null +++ b/app/presenters/organization_form_presenter.rb @@ -0,0 +1,21 @@ +class OrganizationFormPresenter + # Make sure to assign to :data option + def change_detection_form_container_setup + { + controller: 'detect-form-changes halt-navigation-on-change', + detect_form_changes_input_types_value: 'input, textarea, select', + halt_navigation_on_change_modal_container_id_value: 'main-modal-container', + halt_navigation_on_change_discard_option_id_value: 'discard-changes-option', + action: 'turbo:before-visit@document->halt-navigation-on-change#displayModalOnChange' + } + end + + def change_detection_form_setup + { + detect_form_changes_target: 'form', + halt_navigation_on_change_target: 'form', + action: "input->detect-form-changes#captureUserInput change->detect-form-changes#captureUserInput \ + nested-form:domupdate->detect-form-changes#captureDOMUpdate" + } + end +end From 597467e46b835add993a12969d6f6fd7258c516b Mon Sep 17 00:00:00 2001 From: Josue Granados Date: Fri, 8 Mar 2024 12:53:49 -0600 Subject: [PATCH 2/5] feat: implement change-detection logic --- app/controllers/organizations_controller.rb | 2 ++ .../_unsaved_changes_modal_template.html.erb | 27 +++++++++++++++++++ app/views/organizations/edit.html.slim | 24 ++++++++--------- 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 app/views/organizations/_unsaved_changes_modal_template.html.erb diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 8de1b6ed8..e6fce542a 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -12,6 +12,8 @@ def new def edit @organization = Organization.find(params[:id]) + @form_presenter = OrganizationFormPresenter.new + authorize @organization set_form_data end diff --git a/app/views/organizations/_unsaved_changes_modal_template.html.erb b/app/views/organizations/_unsaved_changes_modal_template.html.erb new file mode 100644 index 000000000..43ad7c165 --- /dev/null +++ b/app/views/organizations/_unsaved_changes_modal_template.html.erb @@ -0,0 +1,27 @@ + diff --git a/app/views/organizations/edit.html.slim b/app/views/organizations/edit.html.slim index ada6c0760..1561a5e32 100644 --- a/app/views/organizations/edit.html.slim +++ b/app/views/organizations/edit.html.slim @@ -1,5 +1,5 @@ -div class="w-full h-full bg-grey-9" - div class="flex flex-col max-w-6xl px-5 py-12 mx-auto bg-white md:px-28 md:my-7 " += content_tag(:div, class: "w-full h-full bg-grey-9", data: @form_presenter.change_detection_form_container_setup) do + div class="flex flex-col max-w-6xl px-5 py-12 mx-auto bg-white md:px-28 md:my-7" h1 class="mb-5 text-2xl font-bold text-center sm:text-left text-gray-3" | Edit Page @@ -16,7 +16,7 @@ div class="w-full h-full bg-grey-9" - @organization.errors.full_messages.each do |message| li class="text-red-500 text-sm" = "- #{message}" - = form_for @organization, data: { controller: "form-validation" } do |f| + = form_for(@organization, data: @form_presenter.change_detection_form_setup.merge({controller: 'form-validation'})) do |f| section class="text-gray-2" h3 class="text-lg font-medium leading-7 text-blue-medium flex items-center" = inline_svg_tag 'star.svg', class: "w-4 h-4 mr-1" @@ -61,13 +61,13 @@ div class="w-full h-full bg-grey-9" div class="flex flex-col col-span-12 lg:col-span-6 md:col-span-7" = f.label :logo, class: "block text-sm text-gray-3 font-medium my-2" - div data-controller="logo" class="flex flex-row" - = image_tag url_for(@organization.logo), class: "h-24 w-24 border border-gray-8 rounded mb-2 border-2 border-gray-5 object-contain", data: { target: "logo.output" } - label class=" ml-4 h-full" - = f.file_field :logo, accept: 'image/png,image/jpeg', data: { target: "logo.input", action: "logo#readURL" }, class: "invisible" - div class="border border-gray-5 rounded px-2 py-1 text-md text-gray-3 w-fit mb-2" + div data-controller="logo" class="flex gap-x-4" + = image_tag url_for(@organization.logo), class: "h-24 w-24 border border-gray-8 rounded border-2 border-gray-5 object-contain", data: { target: "logo.output" } + div class="flex flex-col justify-end items-start gap-y-2" + label class="relative border border-gray-5 rounded px-2 py-1 text-md text-gray-3 w-fit" + = f.file_field :logo, accept: 'image/png,image/jpeg', data: { target: "logo.input", action: "logo#readURL" }, class: "sr-only" | Change - div class="text-body-2 text-gray-2" + span class="text-body-2 text-gray-2" | Maximum size of 5MB div class="hidden col-span-3 md:flex" @@ -391,7 +391,7 @@ div class="w-full h-full bg-grey-9" div class="hidden col-span-3 md:flex" div class="col-span-12 text-center lg:col-span-5 md:col-span-7" = link_to 'Remove Location', "#", data: { action: "click->nested-form#remove_association" }, class:"text-sm font-bold cursor-pointer text-blue-medium" - = location_form.hidden_field :_destroy + = location_form.text_field :_destroy, class: "hidden" section class="mt-12 text-gray-2" data-nested-form-target="links" button class="block uppercase border mt-9 border-salmon text-salmon px-5 py-2.5 mx-auto rounded-6px mb-8" type='button' data-action="click->nested-form#add_association" @@ -460,5 +460,5 @@ div class="w-full h-full bg-grey-9" = f.label :verified, "Check to verify", class: "text-sm" = f.submit 'Save', class: 'mx-auto mt-12 flex cursor-pointer uppercase flex-row justify-center items-center bg-seafoam py-4 px-10 rounded-6px text-blue-dark text-base font-bold focus:outline-none', data: { action: "click->form-validation#addEmptyRequiredStyles" } - -/div data-controller="causes" data-causes-options-value=@causes + // unsaved changes modal + = render "organizations/unsaved_changes_modal_template" From c3fa82afaea2e9f589bd2f8162b40623e345a304 Mon Sep 17 00:00:00 2001 From: Josue Granados Date: Fri, 8 Mar 2024 12:55:00 -0600 Subject: [PATCH 3/5] refactor: simplify existing modal implementation --- .../controllers/main_modal_controller.js | 15 +++++++ .../controllers/turbo_modal_controller.js | 43 ------------------- app/javascript/stylesheets/application.scss | 6 +++ app/views/shared/_main_modal.html.erb | 42 +++++++++--------- 4 files changed, 42 insertions(+), 64 deletions(-) create mode 100644 app/javascript/controllers/main_modal_controller.js delete mode 100644 app/javascript/controllers/turbo_modal_controller.js diff --git a/app/javascript/controllers/main_modal_controller.js b/app/javascript/controllers/main_modal_controller.js new file mode 100644 index 000000000..a508b618b --- /dev/null +++ b/app/javascript/controllers/main_modal_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["dialog", "backdrop"] + + close() { + this.element.remove(); + } + + closeAfterSubmit(event) { + if (event.detail.success) { + this.close(); + } + } +} diff --git a/app/javascript/controllers/turbo_modal_controller.js b/app/javascript/controllers/turbo_modal_controller.js deleted file mode 100644 index b5c5e39c5..000000000 --- a/app/javascript/controllers/turbo_modal_controller.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["modal", "form"] - - connect() { - console.log("turbo-modal connected") - } - - // hide modal - // action: "turbo-modal#hideModal" - hideModal() { - this.element.parentElement.removeAttribute("src") - // Remove src reference from parent frame element - // Without this, turbo won't re-open the modal on subsequent click - this.modalTarget.remove() - } - - // hide modal on successful form submission - // action: "turbo:submit-end->turbo-modal#submitEnd" - submitEnd(e) { - if (e.detail.success) { - this.hideModal() - } - } - - // hide modal when clicking ESC - // action: "keyup@window->turbo-modal#closeWithKeyboard" - closeWithKeyboard(e) { - if (e.code == "Escape") { - this.hideModal() - } - } - - // hide modal when clicking outside of modal - // action: "click@window->turbo-modal#closeBackground" - closeBackground(e) { - if (e && this.formTarget.contains(e.target)) { - return - } - this.hideModal() - } -} \ No newline at end of file diff --git a/app/javascript/stylesheets/application.scss b/app/javascript/stylesheets/application.scss index 27f22d0fd..d04858ae0 100644 --- a/app/javascript/stylesheets/application.scss +++ b/app/javascript/stylesheets/application.scss @@ -12,6 +12,12 @@ .centered-flex { @apply flex items-center justify-center; } + + .positioned-center { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } } .scroll-mt-25 { diff --git a/app/views/shared/_main_modal.html.erb b/app/views/shared/_main_modal.html.erb index 423d7649d..1dd29277d 100644 --- a/app/views/shared/_main_modal.html.erb +++ b/app/views/shared/_main_modal.html.erb @@ -1,22 +1,22 @@ - - - <%= turbo_frame_tag "modal" %> - <%= yield %> <%= render Footer::Component.new() %> +
diff --git a/app/views/my_accounts/show.html.slim b/app/views/my_accounts/show.html.slim index 4ac0068cb..471960010 100644 --- a/app/views/my_accounts/show.html.slim +++ b/app/views/my_accounts/show.html.slim @@ -96,7 +96,7 @@ div class="w-full h-full bg-white" div class="flex flex-row justify-between w-full" span class="text-lg font-bold text-black capitalize" | #{alert.decorate.title(index)} - = link_to "Edit", edit_alert_path(alert), class: "text-blue-medium", data: { turbo_frame: "modal" } + = link_to "Edit", edit_alert_path(alert), class: "text-blue-medium", data: { turbo_stream: true } div class="flex flex-row text-sm font-normal text-gray-2" | #{list_all_filters(alert).join(', ')} - else diff --git a/app/views/searches/show.html.slim b/app/views/searches/show.html.slim index a29d8010f..012406613 100644 --- a/app/views/searches/show.html.slim +++ b/app/views/searches/show.html.slim @@ -39,7 +39,7 @@ filters: parse_filters(@search), \ filters_list: list_of_filters(@search)), \ class: "inline-flex items-center px-3 py-2 text-xs text-gray-3", \ - data: { turbo_frame: "modal" } ) do + data: { turbo_stream: true } ) do = inline_svg_tag "bell.svg", class: 'h-3 w-3 mr-2 fill-current text-gray-2 -ml-0.5' | Create Search Alert - else @@ -86,7 +86,7 @@ filters_list: list_of_filters(@search) \ ), \ class: "mt-10 text-sm font-bold text-black", \ - data: { turbo_frame: "modal" } \ + data: { turbo_stream: true } \ ) do | Create an Alert for this Search // Mobile map From 4bc231de6282a4d052a70130cf1171467d615b0f Mon Sep 17 00:00:00 2001 From: Josue Granados Date: Fri, 8 Mar 2024 12:56:51 -0600 Subject: [PATCH 5/5] feat: address change detection edge cases --- app/components/select_multiple/component.html.erb | 10 +++++++++- .../controllers/nested_form_controller.js | 14 ++++++++++---- .../controllers/select_multiple_controller.js | 6 ++++++ app/javascript/controllers/tags_controller.js | 6 +++--- app/views/organizations/_location_fields.html.slim | 2 +- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/components/select_multiple/component.html.erb b/app/components/select_multiple/component.html.erb index 5e59fe0ed..018c3a8d5 100644 --- a/app/components/select_multiple/component.html.erb +++ b/app/components/select_multiple/component.html.erb @@ -1,7 +1,15 @@ <%= content_tag :div, options do %>
- + + +