From 2e97770277f126dedb9dc453db52d85a88982d91 Mon Sep 17 00:00:00 2001 From: Han Date: Wed, 18 Sep 2024 16:44:42 +0100 Subject: [PATCH] TOP-249 allow admins to add location to regular schedules without saving first --- .../outpost-design-library/_forms.scss | 5 + app/controllers/admin/services_controller.rb | 2 + app/helpers/application_helper.rb | 2 +- app/javascript/packs/regular-schedule.js | 240 +++++++++++++++++- app/models/location.rb | 2 + app/models/regular_schedule.rb | 2 + app/models/service.rb | 20 ++ app/models/service_at_location.rb | 2 +- .../editors/_location-fields.html.erb | 98 ++++--- .../services/editors/_locations.html.erb | 2 +- .../editors/_regular-schedule-fields.html.erb | 6 +- ...add_cascade_delete_to_regular_schedules.rb | 9 + db/schema.rb | 4 +- spec/models/service_spec.rb | 62 +++++ 14 files changed, 404 insertions(+), 52 deletions(-) create mode 100644 db/migrate/20240918091413_add_cascade_delete_to_regular_schedules.rb diff --git a/app/assets/stylesheets/outpost-design-library/_forms.scss b/app/assets/stylesheets/outpost-design-library/_forms.scss index eb8679ce..18a3ef08 100644 --- a/app/assets/stylesheets/outpost-design-library/_forms.scss +++ b/app/assets/stylesheets/outpost-design-library/_forms.scss @@ -95,6 +95,11 @@ .field-group { margin-top: 45px; + // if the first item is hidden=true, remove the top margin + &:is([hidden="true"]) + * { + margin-top: 0px; + } + &:first-child { margin-top: 0px; } diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index c3b8d55a..c220a16e 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -149,6 +149,7 @@ def service_params regular_schedules_attributes: [ :id, :service_at_location_id, + :location_object_id, :weekday, :opens_at, :closes_at, @@ -185,6 +186,7 @@ def service_params :visible, :mask_exact_address, :preferred_for_post, + :location_object_id, :_destroy, accessibility_ids: [] ], diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5e2c0f36..949084d3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -66,7 +66,7 @@ def link_to_add_fields(name, f, association, view) fields = f.fields_for(association, new_object, child_index: id) do |builder| render(view, l: builder, c: builder, sched: builder) end - link_to name, '#', class: "button button--secondary button--add", data: {id: id, fields: fields.gsub("\n", ""), add: true} + link_to name, '#', class: "button button--secondary button--add", data: {id: id, fields: fields.gsub("\n", ""), add: true, association: association} end def local_offer_checkbox(builder, view) diff --git a/app/javascript/packs/regular-schedule.js b/app/javascript/packs/regular-schedule.js index 330f4486..08c89639 100644 --- a/app/javascript/packs/regular-schedule.js +++ b/app/javascript/packs/regular-schedule.js @@ -1,4 +1,5 @@ document.addEventListener("turbolinks:load", () => { + // regular schedules setupTimeTypePanelListeners(); const timeTypePanels = document.querySelectorAll( "[data-regular-schedule-panel]" @@ -6,8 +7,24 @@ document.addEventListener("turbolinks:load", () => { timeTypePanels.forEach((panel) => { setTimeTypePanelState(panel); }); + + // regular schedules & locations (admin only) + let locations = document.querySelector("#location_panels.repeater"); + if (locations) { + setupRegularScheduleLocationListeners(); + + runOnEachRegularSchedulePanel((panel) => { + setRegularScheduleLocationState(panel); + }); + } }); +/************* + * + * REGULAR SCHEDULES + * + *************/ + /** * Sets up event listeners inside the regular schedule panels */ @@ -16,7 +33,7 @@ const setupTimeTypePanelListeners = () => { "#regular_schedule_panels.repeater" ); - // listens for every change event inside the repeater + // listens for every change event inside the regular schedules repeater if (regularSchedules) { regularSchedules.addEventListener("change", (e) => { // the panel object @@ -54,7 +71,6 @@ const setupTimeTypePanelListeners = () => { */ const setTimeTypePanelState = (panel) => { const timeType = getPanelTimeType(panel); - console.log(`setTimeTypePanelState to: ${timeType}`); const opening_time = panel.querySelector(".regular_schedule__opening_time"); const event_time = panel.querySelector(".regular_schedule__event_time"); @@ -106,6 +122,158 @@ const setFreqVisibility = (panel, freq) => { } }; +/************* + * + * REGULAR SCHEDULES & LOCATIONS + * + *************/ + +/** + * Sets up event listeners inside the regular schedule panels + */ +const setupRegularScheduleLocationListeners = () => { + let locations = document.querySelector("#location_panels.repeater"); + let regularSchedules = document.querySelector( + "#regular_schedule_panels.repeater" + ); + if (locations) { + locations.addEventListener("click", (e) => { + // add a location + if ( + e.target.getAttribute("data-add") && + e.target.getAttribute("data-association") === "locations" + ) { + runOnEachRegularSchedulePanel((panel) => { + setRegularScheduleLocationState(panel); + }); + } + + // remove a location + if (e.target.getAttribute("data-close")) { + runOnEachRegularSchedulePanel((panel) => { + setRegularScheduleLocationState(panel); + }); + } + }); + + // update a location + locations.addEventListener("change", (e) => { + runOnEachRegularSchedulePanel((panel) => { + setRegularScheduleLocationState(panel); + }); + }); + } + + if (regularSchedules) { + regularSchedules.addEventListener("click", (e) => { + // add a regularSchedules + if ( + e.target.getAttribute("data-add") && + e.target.getAttribute("data-association") === "regular_schedules" + ) { + runOnEachRegularSchedulePanel((panel) => { + setRegularScheduleLocationState(panel); + }); + } + }); + + // when regular schedule field changes + regularSchedules.addEventListener("change", (e) => { + // the panel object + const panel = e.target.closest("[data-regular-schedule-panel]"); + const serviceAtLocationIdMatcher = e.target.name.match( + /^service\[regular_schedules_attributes\]\[(\d+)\]\[(service_at_location_id)\]$/ + ); + + if (serviceAtLocationIdMatcher) { + updateScheduleLocationObjectId(e.target, panel); + } + }); + } +}; + +/** + * Set location select visibility + * @param {*} panel + */ +const setRegularScheduleLocationState = (panel) => { + const locationNames = getAllLocationNames(); + + // toggle visibility of location select + if (locationNames.length <= 1) { + toggleHidden(panel, true, "data-location"); + } else { + toggleHidden(panel, false, "data-location"); + } + + // set dropdown state + + const dropdown = panel.querySelector('[name*="service_at_location_id"]'); + // get the 'title option' cant rely on it being the first option or it being the only blank option + var titleOption = Array.from(dropdown.options).find( + (o) => o.innerHTML === "All locations" && o.value === "" + ); + // is there an option currently selected? + const selectedOption = Array.from(dropdown.options).find( + (option) => option.selected && option.innerHTML !== "All locations" + ); + const selectedOptionValues = { + service_at_location_id: selectedOption?.value, + location_object_id: selectedOption?.dataset.locationObjectId, + }; + // create new options based on the location names + const newOptions = locationNames.map((location) => { + return createOption( + location.service_at_location_id ?? "", + location.value, + location.location_object_id + ); + }); + // clear the dropdown options + dropdown.innerHTML = ""; + // add them back in + [titleOption, ...newOptions].forEach((option) => { + dropdown.appendChild(option); + }); + // and select the one that was already selected if it still exists + const existingLocationOption = Array.from( + dropdown.querySelectorAll( + `option[value='${selectedOptionValues.service_at_location_id}']:not([data-location-object-id])` + ) + ).find((option) => option.textContent.trim() !== "All locations"); + const newLocationOption = dropdown.querySelector( + `option[value=''][data-location-object-id='${selectedOptionValues.location_object_id}']` + ); + + if (existingLocationOption) { + existingLocationOption.selected = true; + } else if (newLocationOption) { + newLocationOption.selected = true; + } +}; + +/** + * Updates the schedule location object id when location is selected in dropdown + * @param {*} panel + */ +const updateScheduleLocationObjectId = (locationSelector, panel) => { + const value = locationSelector.value; + const selectedIndex = locationSelector.selectedIndex; + const selectedLocationObjectId = + locationSelector[selectedIndex].dataset.locationObjectId; + + const location_object_id_field = panel.querySelector( + '[name$="[location_object_id]"]' + ); + + // if we have selected a new location update the location_object_id_field + if (!value && selectedLocationObjectId) { + location_object_id_field.value = selectedLocationObjectId; + } else { + location_object_id_field.value = ""; + } +}; + /************* * * UTILITIES @@ -196,3 +364,71 @@ const toggleRequired = (panel, required, dataField = "data-required") => { } }); }; + +/** + * Gets all the location names, uses the same formatting logic as elsewhere in the code base + * @returns {Array} An array of objects containing the location service_at_location_id and name + */ +const getAllLocationNames = () => { + const location_data = []; + runOnEachLocationPanel((panel) => { + const destroy = panel.querySelector('[name*="_destroy"]').value; + if (destroy !== "true") { + const name = panel.querySelector('[name*="name"]').value; + const address_1 = panel.querySelector('[name*="address_1"]').value; + const city = panel.querySelector('[name*="city"]').value; + const postal_code = panel.querySelector('[name*="postal_code"]').value; + + const panel_location_fields = { + value: name || address_1 || postal_code || "No location name provided", + location_object_id: panel.dataset.locationObjectId ?? null, + service_at_location_id: + panel.dataset.locationServiceAtLocationId ?? null, + }; + location_data.push(panel_location_fields); + } + }); + + return location_data; +}; + +/** + * Callback method to run on each regular schedule panel + * @param {*} callback + */ +const runOnEachRegularSchedulePanel = function (callback) { + const regularSchedulePanels = document.querySelectorAll( + "[data-regular-schedule-panel]" + ); + regularSchedulePanels.forEach((panel) => { + callback(panel); + }); +}; + +/** + * Callback method to run on each location panel + * @param {*} callback + */ +const runOnEachLocationPanel = function (callback) { + const locationPanels = document.querySelectorAll("[data-location-panel]"); + locationPanels.forEach((panel) => { + callback(panel); + }); +}; + +/** + * Create an