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/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/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/nested_form_controller.js b/app/javascript/controllers/nested_form_controller.js
index a3153f58a..d256c02be 100644
--- a/app/javascript/controllers/nested_form_controller.js
+++ b/app/javascript/controllers/nested_form_controller.js
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = [ "links", "template" ]
+ static targets = ["links", "template"]
connect() {
this.wrapperClass = this.data.get("wrapperClass") || "nested-fields"
@@ -12,6 +12,7 @@ export default class extends Controller {
var content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
this.linksTarget.insertAdjacentHTML('beforebegin', content)
+ this.dispatch("domupdate", { detail: { newInputAdded: true } })
}
remove_association(event) {
@@ -21,10 +22,15 @@ export default class extends Controller {
// New records are simply removed from the page
if (wrapper.dataset.newRecord == "true") {
wrapper.remove()
-
+ this.dispatch("domupdate")
+ }
// Existing records are hidden and flagged for deletion
- } else {
- wrapper.querySelector("input[name*='_destroy']").value = 1
+ else {
+ const deletionFlag = wrapper.querySelector("input[name*='_destroy']")
+ const inputEvent = new Event("input", { bubbles: true })
+
+ deletionFlag.value = 1
+ deletionFlag.dispatchEvent(inputEvent)
wrapper.style.display = 'none'
}
}
diff --git a/app/javascript/controllers/select_multiple_controller.js b/app/javascript/controllers/select_multiple_controller.js
index 70d689071..994cd9c79 100644
--- a/app/javascript/controllers/select_multiple_controller.js
+++ b/app/javascript/controllers/select_multiple_controller.js
@@ -53,12 +53,18 @@ export default class extends Controller {
}
updateCheckboxes() {
+ const changeEvent = new Event("change", { bubbles: true })
+
this.checkboxTargets.forEach(checkbox => {
if (this.store.has(checkbox.dataset.value)) {
checkbox.checked = true
} else {
checkbox.checked = false
}
+
+ if (checkbox.checked !== checkbox.defaultChecked) {
+ checkbox.dispatchEvent(changeEvent)
+ }
})
this.search()
}
diff --git a/app/javascript/controllers/tags_controller.js b/app/javascript/controllers/tags_controller.js
index fe53c18dc..829131bbc 100644
--- a/app/javascript/controllers/tags_controller.js
+++ b/app/javascript/controllers/tags_controller.js
@@ -2,10 +2,10 @@ import { Controller } from "@hotwired/stimulus"
import Tagify from '@yaireo/tagify';
export default class extends Controller {
- static targets = [ "output" ]
+ static targets = ["output"]
connect() {
- new Tagify(
- this.outputTarget);
+ new Tagify(this.outputTarget); // internally changes `value`
+ this.outputTarget.defaultValue = this.outputTarget.value // keeps input consistent
}
}
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/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
diff --git a/app/views/alerts/edit.html.erb b/app/views/alerts/edit.html.erb
deleted file mode 100644
index 48a35ba08..000000000
--- a/app/views/alerts/edit.html.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-<%= render "shared/main_modal" do %>
- <%= render(
- "alerts/form",
- alert: @alert,
- url: alert_path(@alert),
- method: :patch,
- filters: @filters,
- filters_list: @filters_list
- ) %>
-<% end %>
diff --git a/app/views/alerts/edit.turbo_stream.erb b/app/views/alerts/edit.turbo_stream.erb
new file mode 100644
index 000000000..9b291e10a
--- /dev/null
+++ b/app/views/alerts/edit.turbo_stream.erb
@@ -0,0 +1,12 @@
+<%= turbo_stream.update "main-modal-container" do %>
+ <%= render "shared/main_modal" do %>
+ <%= render(
+ "alerts/form",
+ alert: @alert,
+ url: alert_path(@alert),
+ method: :patch,
+ filters: @filters,
+ filters_list: @filters_list
+ ) %>
+ <% end %>
+<% end %>
diff --git a/app/views/alerts/new.html.erb b/app/views/alerts/new.html.erb
deleted file mode 100644
index f87595eba..000000000
--- a/app/views/alerts/new.html.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-<%= render "shared/main_modal" do %>
- <%= render(
- "alerts/form",
- alert: @alert,
- url: alerts_path,
- method: :post,
- filters: @filters,
- filters_list: @filters_list
- ) %>
-<% end %>
diff --git a/app/views/alerts/new.turbo_stream.erb b/app/views/alerts/new.turbo_stream.erb
new file mode 100644
index 000000000..f7c99bd23
--- /dev/null
+++ b/app/views/alerts/new.turbo_stream.erb
@@ -0,0 +1,12 @@
+<%= turbo_stream.update "main-modal-container" do %>
+ <%= render "shared/main_modal" do %>
+ <%= render(
+ "alerts/form",
+ alert: @alert,
+ url: alerts_path,
+ method: :post,
+ filters: @filters,
+ filters_list: @filters_list
+ ) %>
+ <% end %>
+<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index c981a5da4..d157ebd91 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -47,11 +47,10 @@
<%= render "shared/flash_messages" %>
- <%= 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 550c42442..eddae25e2 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/organizations/_location_fields.html.slim b/app/views/organizations/_location_fields.html.slim
index 87b39f0c8..57571b786 100644
--- a/app/views/organizations/_location_fields.html.slim
+++ b/app/views/organizations/_location_fields.html.slim
@@ -1,4 +1,4 @@
-div class="grid grid-cols-12 gap-6 mt-8 nested-fields" data-action="google-maps-callback@window->places#initMap" data-controller="places toggle" data-places-imageurl-value=("\#{asset_path 'markergc.png'}")
+div class="grid grid-cols-12 gap-6 mt-8 nested-fields" data-new-record="true" data-action="google-maps-callback@window->places#initMap" data-controller="places toggle" data-places-imageurl-value=("\#{asset_path 'markergc.png'}")
div class="col-span-12 lg:col-span-6 md:col-span-7"
= location_form.label :name, "Location Name", class:"text-sm"
= location_form.text_field :name, class: "block mb-4 h-46px mt-1 h-full w-full py-0 px-4 rounded-6px border-gray-5 text-base text-gray-3 focus:ring-blue-medium focus:border-blue-medium"
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 @@
+
+ <%= render "shared/main_modal" do %>
+
+
+ You made changes without saving
+ Would you like to return to this page to save?
+
+
+
+ <%= link_to(
+ "Leave",
+ "javascript:void(0)",
+ id: "discard-changes-option",
+ class: "w-full py-2 px-5 rounded-lg border text-center font-medium border-blue-medium text-blue-medium transition-colors hover:bg-blue-pale",
+ data: {turbo: false} # scapes `turbo:before-visit` event
+ ) %>
+
+
+
+
+ <% end %>
+
diff --git a/app/views/organizations/edit.html.slim b/app/views/organizations/edit.html.slim
index 015a722e2..ba47c4572 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-sm text-red-500"
= "- #{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="flex items-center text-lg font-medium leading-7 text-blue-medium"
= 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="h-full ml-4 "
- = f.file_field :logo, accept: 'image/png,image/jpeg', data: { target: "logo.input", action: "logo#readURL" }, class: "invisible"
- div class="px-2 py-1 mb-2 border rounded border-gray-5 text-md text-gray-3 w-fit"
+ 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"
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
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 @@
-
-
-
-
+
+ <%# Backdrop %>
+
+
+ <%# Dialog %>
+
+ <%# Close %>
+
+
+ <%= yield %>
-
\ No newline at end of file
+