diff --git a/Gemfile.lock b/Gemfile.lock index 88b3f2c..9456364 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - advanced_select (0.1.6) + advanced_select (0.1.7) actionview (>= 7.1) railties (>= 7.1) stimulus-rails (>= 1.3) diff --git a/README.md b/README.md index 21d2a33..7b25b16 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ AdvancedSelect does not provide query objects, model concerns, authorization log Add the gem to the host Rails app: ```ruby -gem "advanced_select", "~> 0.1.5" +gem "advanced_select", "~> 0.1.7" ``` Run the installer: @@ -488,19 +488,20 @@ The partial is rendered on the server for the initial selection, and the control - `data-advanced-select-tooltip-list` on the element that holds the rows (the same hook the built-in list uses). - ` diff --git a/app/javascript/advanced_select/advanced_select_controller.js b/app/javascript/advanced_select/advanced_select_controller.js index 7f7ba76..fe8eddc 100644 --- a/app/javascript/advanced_select/advanced_select_controller.js +++ b/app/javascript/advanced_select/advanced_select_controller.js @@ -39,11 +39,7 @@ export default class extends Controller { this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active") this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "") this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "") - this.selectedValue = this.selectedValue.map((option) => ({ - ...option, - id: option.id.toString(), - displayLabel: option.displayLabel || option.label - })) + this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option)) this.close = this.close.bind(this) this.renderOptionsState() this.setupEagerDependentFields() @@ -109,7 +105,7 @@ export default class extends Controller { choose(event) { event.preventDefault() - this.selectOption(event.params.value, event.params.label, event.params.submitValue, { displayLabel: event.params.displayLabel }) + this.selectOptionFromElement(event.currentTarget) } add(event) { @@ -281,17 +277,28 @@ export default class extends Controller { } selectOption(value, label, submitValue = value, { displayLabel = label, refreshOptions = this.multipleValue } = {}) { - value = value.toString() - submitValue = submitValue.toString() + this.selectOptionData( + this.normalizeSelectedOption({ id: value, value: submitValue, label, displayLabel }), + { refreshOptions } + ) + } + + selectOptionFromElement(element, { refreshOptions = this.multipleValue } = {}) { + this.selectOptionData(this.optionData(element), { refreshOptions }) + } + + selectOptionData(option, { refreshOptions = this.multipleValue } = {}) { + const selectedOption = this.normalizeSelectedOption(option) + const value = selectedOption.id if (this.multipleValue) { if (this.selectedValue.some((option) => option.id === value)) { this.selectedValue = this.selectedValue.filter((option) => option.id !== value) } else { - this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }, ...this.selectedValue] + this.selectedValue = [selectedOption, ...this.selectedValue] } } else { - this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }] + this.selectedValue = [selectedOption] } this.renderSelection() @@ -304,6 +311,51 @@ export default class extends Controller { } } + optionData(element) { + const data = this.parseOptionData(element.dataset.advancedSelectOptionParam) + const value = element.dataset.advancedSelectValueParam + const submitValue = element.dataset.advancedSelectSubmitValueParam || value + const label = element.dataset.advancedSelectLabelParam + const displayLabel = element.dataset.advancedSelectDisplayLabelParam || label + + return { + ...data, + id: value, + value: submitValue, + label, + displayLabel + } + } + + parseOptionData(json) { + if (!json) { + return {} + } + + try { + const data = JSON.parse(json) + return data && typeof data === "object" && !Array.isArray(data) ? data : {} + } catch (_error) { + return {} + } + } + + normalizeSelectedOption(option) { + const id = option.id.toString() + const value = (option.value || id).toString() + const label = (option.label || option.displayLabel || option.display_label || value).toString() + const displayLabel = (option.displayLabel || option.display_label || label).toString() + + return { + ...option, + id, + value, + label, + displayLabel, + display_label: displayLabel + } + } + renderSelection() { this.hiddenFieldsTarget.replaceChildren(...this.hiddenFieldElements) this.summaryTarget.replaceChildren(...this.selectionElements) @@ -383,13 +435,7 @@ export default class extends Controller { return } - const option = options[0] - this.selectOption( - option.dataset.advancedSelectValueParam, - option.dataset.advancedSelectLabelParam, - option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam, - { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false } - ) + this.selectOptionFromElement(options[0], { refreshOptions: false }) } chooseActiveOption() { @@ -401,12 +447,7 @@ export default class extends Controller { this.activeOption.dataset.advancedSelectDisplayLabelParam ) } else { - this.selectOption( - this.activeOption.dataset.advancedSelectValueParam, - this.activeOption.dataset.advancedSelectLabelParam, - this.activeOption.dataset.advancedSelectSubmitValueParam || this.activeOption.dataset.advancedSelectValueParam, - { displayLabel: this.activeOption.dataset.advancedSelectDisplayLabelParam } - ) + this.selectOptionFromElement(this.activeOption) } } @@ -602,11 +643,29 @@ export default class extends Controller { return this.displayLabel(option) } - const camelizedField = field.replace(/_([a-z])/g, (_match, character) => character.toUpperCase()) - const value = option[field] ?? option[camelizedField] + const value = this.optionFieldValue(option, field) return value === undefined || value === null ? "" : value.toString() } + optionFieldValue(option, field) { + const directValue = option[field] ?? option[this.camelize(field)] + if (directValue !== undefined) { + return directValue + } + + return field.split(".").reduce((value, segment) => { + if (value === undefined || value === null) { + return undefined + } + + return value[segment] ?? value[this.camelize(segment)] + }, option) + } + + camelize(value) { + return value.replace(/_([a-z])/g, (_match, character) => character.toUpperCase()) + } + textElement(tagName, className, text) { const element = document.createElement(tagName) element.className = className @@ -642,4 +701,4 @@ export default class extends Controller { get expanded() { return this.triggerTarget.getAttribute("aria-expanded") === "true" } -} \ No newline at end of file +} diff --git a/lib/advanced_select/helper.rb b/lib/advanced_select/helper.rb index f32b697..13630e0 100644 --- a/lib/advanced_select/helper.rb +++ b/lib/advanced_select/helper.rb @@ -50,12 +50,12 @@ def advanced_select_state_class(class_map, key) def advanced_select_selected_options(selected) advanced_select_array(selected).map do |option| - { + option.to_h.merge( id: option.fetch(:id).to_s, value: advanced_select_option_value(option), label: advanced_select_option_label(option), display_label: advanced_select_option_display_label(option) - } + ) end end @@ -79,7 +79,8 @@ def advanced_select_option_tag(option, selected_options, option_content_partial, advanced_select_value_param: option.fetch(:id), advanced_select_submit_value_param: advanced_select_option_value(option), advanced_select_label_param: advanced_select_option_label(option), - advanced_select_display_label_param: advanced_select_option_display_label(option) + advanced_select_display_label_param: advanced_select_option_display_label(option), + advanced_select_option_param: advanced_select_option_payload(option) } ) do safe_join([ @@ -170,6 +171,15 @@ def advanced_select_option_description(option) option[:description].to_s end + def advanced_select_option_payload(option) + option.to_h.merge( + id: option.fetch(:id).to_s, + value: advanced_select_option_value(option), + label: advanced_select_option_label(option), + display_label: advanced_select_option_display_label(option) + ).to_json + end + def advanced_select_display_label(label) label.to_s.split(" > ").last end diff --git a/lib/advanced_select/version.rb b/lib/advanced_select/version.rb index a3c1350..e1ea74b 100644 --- a/lib/advanced_select/version.rb +++ b/lib/advanced_select/version.rb @@ -1,3 +1,3 @@ module AdvancedSelect - VERSION = "0.1.6" + VERSION = "0.1.7" end diff --git a/lib/generators/advanced_select/install/templates/advanced_select_controller.js b/lib/generators/advanced_select/install/templates/advanced_select_controller.js index 23335bd..fe8eddc 100644 --- a/lib/generators/advanced_select/install/templates/advanced_select_controller.js +++ b/lib/generators/advanced_select/install/templates/advanced_select_controller.js @@ -39,11 +39,7 @@ export default class extends Controller { this.optionActiveClasses = this.classList(this.element.dataset.advancedSelectOptionActiveClass || "ui-advanced-select-option-active") this.addOptionActiveClasses = this.classList(this.element.dataset.advancedSelectAddOptionActiveClass || "") this.optionSelectedClasses = this.classList(this.element.dataset.advancedSelectOptionSelectedClass || "") - this.selectedValue = this.selectedValue.map((option) => ({ - ...option, - id: option.id.toString(), - displayLabel: option.displayLabel || option.label - })) + this.selectedValue = this.selectedValue.map((option) => this.normalizeSelectedOption(option)) this.close = this.close.bind(this) this.renderOptionsState() this.setupEagerDependentFields() @@ -109,7 +105,7 @@ export default class extends Controller { choose(event) { event.preventDefault() - this.selectOption(event.params.value, event.params.label, event.params.submitValue, { displayLabel: event.params.displayLabel }) + this.selectOptionFromElement(event.currentTarget) } add(event) { @@ -281,17 +277,28 @@ export default class extends Controller { } selectOption(value, label, submitValue = value, { displayLabel = label, refreshOptions = this.multipleValue } = {}) { - value = value.toString() - submitValue = submitValue.toString() + this.selectOptionData( + this.normalizeSelectedOption({ id: value, value: submitValue, label, displayLabel }), + { refreshOptions } + ) + } + + selectOptionFromElement(element, { refreshOptions = this.multipleValue } = {}) { + this.selectOptionData(this.optionData(element), { refreshOptions }) + } + + selectOptionData(option, { refreshOptions = this.multipleValue } = {}) { + const selectedOption = this.normalizeSelectedOption(option) + const value = selectedOption.id if (this.multipleValue) { if (this.selectedValue.some((option) => option.id === value)) { this.selectedValue = this.selectedValue.filter((option) => option.id !== value) } else { - this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }, ...this.selectedValue] + this.selectedValue = [selectedOption, ...this.selectedValue] } } else { - this.selectedValue = [{ id: value, value: submitValue, label, displayLabel }] + this.selectedValue = [selectedOption] } this.renderSelection() @@ -304,6 +311,51 @@ export default class extends Controller { } } + optionData(element) { + const data = this.parseOptionData(element.dataset.advancedSelectOptionParam) + const value = element.dataset.advancedSelectValueParam + const submitValue = element.dataset.advancedSelectSubmitValueParam || value + const label = element.dataset.advancedSelectLabelParam + const displayLabel = element.dataset.advancedSelectDisplayLabelParam || label + + return { + ...data, + id: value, + value: submitValue, + label, + displayLabel + } + } + + parseOptionData(json) { + if (!json) { + return {} + } + + try { + const data = JSON.parse(json) + return data && typeof data === "object" && !Array.isArray(data) ? data : {} + } catch (_error) { + return {} + } + } + + normalizeSelectedOption(option) { + const id = option.id.toString() + const value = (option.value || id).toString() + const label = (option.label || option.displayLabel || option.display_label || value).toString() + const displayLabel = (option.displayLabel || option.display_label || label).toString() + + return { + ...option, + id, + value, + label, + displayLabel, + display_label: displayLabel + } + } + renderSelection() { this.hiddenFieldsTarget.replaceChildren(...this.hiddenFieldElements) this.summaryTarget.replaceChildren(...this.selectionElements) @@ -354,8 +406,6 @@ export default class extends Controller { return } - // Delegated on document so it keeps working after a parent advanced select - // rewrites its hidden input on each selection. this.dependentChangeListener = (event) => { if (event.target instanceof Element && this.dependentSelectors.some((selector) => event.target.matches(selector))) { this.reloadDependentOptions() @@ -385,13 +435,7 @@ export default class extends Controller { return } - const option = options[0] - this.selectOption( - option.dataset.advancedSelectValueParam, - option.dataset.advancedSelectLabelParam, - option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam, - { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false } - ) + this.selectOptionFromElement(options[0], { refreshOptions: false }) } chooseActiveOption() { @@ -403,12 +447,7 @@ export default class extends Controller { this.activeOption.dataset.advancedSelectDisplayLabelParam ) } else { - this.selectOption( - this.activeOption.dataset.advancedSelectValueParam, - this.activeOption.dataset.advancedSelectLabelParam, - this.activeOption.dataset.advancedSelectSubmitValueParam || this.activeOption.dataset.advancedSelectValueParam, - { displayLabel: this.activeOption.dataset.advancedSelectDisplayLabelParam } - ) + this.selectOptionFromElement(this.activeOption) } } @@ -528,10 +567,19 @@ export default class extends Controller { } renderTooltip() { - if (!this.hasTooltipTarget || this.tooltipTarget.hasAttribute("data-advanced-select-tooltip-custom")) { + if (!this.hasTooltipTarget) { return } + if (this.tooltipTarget.hasAttribute("data-advanced-select-tooltip-custom")) { + this.renderCustomTooltip() + return + } + + this.renderBuiltInTooltip() + } + + renderBuiltInTooltip() { const list = this.tooltipTarget.querySelector("[data-advanced-select-tooltip-list]") if (!list) { return @@ -546,6 +594,78 @@ export default class extends Controller { } } + renderCustomTooltip() { + const list = this.tooltipTarget.querySelector("[data-advanced-select-tooltip-list]") + const template = this.customTooltipTemplate() + + if (!list || !template) { + if (this.selectedValue.length === 0) { + this.hideTooltip(true) + } + return + } + + list.replaceChildren( + ...this.selectedValue.map((option) => this.customTooltipElement(template, option)) + ) + + if (this.selectedValue.length === 0) { + this.hideTooltip(true) + } + } + + customTooltipTemplate() { + if (this.customTooltipTemplateContent) { + return this.customTooltipTemplateContent + } + + const template = this.tooltipTarget.querySelector("template[data-advanced-select-tooltip-template]") + if (!template) { + return null + } + + this.customTooltipTemplateContent = template.content.cloneNode(true) + template.remove() + return this.customTooltipTemplateContent + } + + customTooltipElement(template, option) { + const fragment = template.cloneNode(true) + fragment.querySelectorAll("[data-advanced-select-tooltip-field]").forEach((element) => { + element.textContent = this.tooltipFieldValue(option, element.dataset.advancedSelectTooltipField) + }) + + return fragment + } + + tooltipFieldValue(option, field) { + if (field === "display_label" || field === "displayLabel") { + return this.displayLabel(option) + } + + const value = this.optionFieldValue(option, field) + return value === undefined || value === null ? "" : value.toString() + } + + optionFieldValue(option, field) { + const directValue = option[field] ?? option[this.camelize(field)] + if (directValue !== undefined) { + return directValue + } + + return field.split(".").reduce((value, segment) => { + if (value === undefined || value === null) { + return undefined + } + + return value[segment] ?? value[this.camelize(segment)] + }, option) + } + + camelize(value) { + return value.replace(/_([a-z])/g, (_match, character) => character.toUpperCase()) + } + textElement(tagName, className, text) { const element = document.createElement(tagName) element.className = className @@ -581,4 +701,4 @@ export default class extends Controller { get expanded() { return this.triggerTarget.getAttribute("aria-expanded") === "true" } -} \ No newline at end of file +} diff --git a/test/dummy/app/views/advanced_select/tooltips/_alternatives.html.erb b/test/dummy/app/views/advanced_select/tooltips/_alternatives.html.erb index d48adad..a8b8bba 100644 --- a/test/dummy/app/views/advanced_select/tooltips/_alternatives.html.erb +++ b/test/dummy/app/views/advanced_select/tooltips/_alternatives.html.erb @@ -3,6 +3,7 @@ Code & Name Type + Test @@ -10,6 +11,7 @@ <%= option.fetch(:display_label) %> <%= option.fetch(:value) %> + <%= option.dig(:test, :name) %> <% end %> @@ -17,6 +19,7 @@ + diff --git a/test/dummy/app/views/advanced_select_examples/show.html.erb b/test/dummy/app/views/advanced_select_examples/show.html.erb index e6370b8..3206b1e 100644 --- a/test/dummy/app/views/advanced_select_examples/show.html.erb +++ b/test/dummy/app/views/advanced_select_examples/show.html.erb @@ -125,13 +125,13 @@ "example[tooltip_partial_ids][]", id: "example_tooltip_partial_ids", selected: [ - { id: "alt-1", value: "Muadil", label: "ALT-001 – Antikor A" }, - { id: "alt-2", value: "Benzer", label: "ALT-002 – Antikor B" } + { id: "alt-1", value: "Muadil", label: "ALT-001 – Antikor A", test: { name: "CD19" } }, + { id: "alt-2", value: "Benzer", label: "ALT-002 – Antikor B", test: { name: "CD20" } } ], options: [ - { id: "alt-1", value: "Muadil", label: "ALT-001 – Antikor A" }, - { id: "alt-2", value: "Benzer", label: "ALT-002 – Antikor B" }, - { id: "alt-3", value: "Eşdeğer", label: "ALT-003 – Antikor C" } + { id: "alt-1", value: "Muadil", label: "ALT-001 – Antikor A", test: { name: "CD19" } }, + { id: "alt-2", value: "Benzer", label: "ALT-002 – Antikor B", test: { name: "CD20" } }, + { id: "alt-3", value: "Eşdeğer", label: "ALT-003 – Antikor C", test: { name: "HER2" } } ], placeholder: "Choose alternatives", multiple: true, diff --git a/test/helpers/advanced_select/helper_test.rb b/test/helpers/advanced_select/helper_test.rb index 1062e4d..6a7964a 100644 --- a/test/helpers/advanced_select/helper_test.rb +++ b/test/helpers/advanced_select/helper_test.rb @@ -476,7 +476,7 @@ class AdvancedSelectHelperTest < ActionView::TestCase "report[item]", id: "report_custom_item", selected: nil, - options: [{ id: "product-1", code: "P-001", label: "Product one" }], + options: [{ id: "product-1", code: "P-001", label: "Product one", test: { name: "CD19" } }], placeholder: "Select", searchable: false, option_content_partial: "advanced_select/option_contents/product" @@ -485,6 +485,10 @@ class AdvancedSelectHelperTest < ActionView::TestCase assert_selector fragment, "#report_custom_item_options button[role='option'][data-advanced-select-value-param='product-1']" assert_selector fragment, "#report_custom_item_options .custom-product-code", text: "P-001" + + payload = JSON.parse(fragment.at_css("#report_custom_item_options button")["data-advanced-select-option-param"]) + assert_equal "P-001", payload.fetch("code") + assert_equal "CD19", payload.fetch("test").fetch("name") end test "renders options only content for turbo stream replacement" do diff --git a/test/system/advanced_select_interaction_test.rb b/test/system/advanced_select_interaction_test.rb index 86fb956..bffbee0 100644 --- a/test/system/advanced_select_interaction_test.rb +++ b/test/system/advanced_select_interaction_test.rb @@ -304,6 +304,7 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase assert_selector "table.advanced-select-tooltip-table" assert_text "ALT-001 – Antikor A" assert_text "Muadil" + assert_text "CD19" end end @@ -322,6 +323,7 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase assert_text "ALT-001 – Antikor A" assert_text "ALT-003 – Antikor C" assert_text "Eşdeğer" + assert_text "HER2" end find("#example_tooltip_partial_ids_trigger").click @@ -334,6 +336,7 @@ class AdvancedSelectInteractionTest < ApplicationSystemTestCase within "#example_tooltip_partial_ids_tooltip" do assert_no_text "ALT-001 – Antikor A" assert_text "ALT-003 – Antikor C" + assert_text "HER2" end end