From 4b893b6ebfb00dc6dd6628674b11b104eb0bd69b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 3 Oct 2023 18:13:29 +0200 Subject: [PATCH 1/3] chore(ruby): update axe-core-rspec --- Gemfile.lock | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f4ef02a81..8ad9fa508 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,7 +86,7 @@ GEM i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) administrate (0.18.0) actionpack (>= 5.0) @@ -103,13 +103,10 @@ GEM anchored (1.1.0) ast (2.4.2) attr_required (1.0.1) - axe-core-api (4.2.1) - capybara + axe-core-api (4.8.0) dumb_delegator - selenium-webdriver virtus - watir - axe-core-rspec (4.2.1) + axe-core-rspec (4.8.0) axe-core-api dumb_delegator virtus @@ -487,7 +484,7 @@ GEM pry (>= 0.13, < 0.15) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (5.0.1) + public_suffix (5.0.3) puma (6.3.1) nio4r (~> 2.0) pundit (2.2.0) @@ -576,7 +573,7 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.2.5) + rexml (3.2.6) rodf (1.1.1) builder (>= 3.0) dry-inflector (~> 0.1) @@ -666,7 +663,7 @@ GEM selectize-rails (0.12.6) selenium-devtools (0.114.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.10.0) + selenium-webdriver (4.13.1) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -763,9 +760,6 @@ GEM zeitwerk (~> 2.2) warden (1.2.9) rack (>= 2.0.9) - watir (6.19.1) - regexp_parser (>= 1.2, < 3) - selenium-webdriver (>= 3.142.7) web-console (4.1.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -778,7 +772,7 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.9) + websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) From ae450a2d2bb0e8bf1c7919d7f3e2b803007bd1e9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 3 Oct 2023 18:14:20 +0200 Subject: [PATCH 2/3] feat(combobox): add a setting to load options from an API --- app/components/dsfr/combobox_component.rb | 28 +++- .../combobox_component.html.haml | 9 +- .../controllers/combobox_controller.ts | 15 +- app/javascript/shared/combobox-ui.ts | 143 ++++++++++++++---- app/javascript/shared/combobox.test.ts | 30 ++++ app/javascript/shared/combobox.ts | 45 ++++-- config/routes.rb | 4 + 7 files changed, 220 insertions(+), 54 deletions(-) diff --git a/app/components/dsfr/combobox_component.rb b/app/components/dsfr/combobox_component.rb index d80dd70ce..208b76fd2 100644 --- a/app/components/dsfr/combobox_component.rb +++ b/app/components/dsfr/combobox_component.rb @@ -1,9 +1,9 @@ class Dsfr::ComboboxComponent < ApplicationComponent - def initialize(form: nil, options:, selected: nil, allows_custom_value: false, **html_options) - @form, @options, @selected, @allows_custom_value, @html_options = form, options, selected, allows_custom_value, html_options + def initialize(form: nil, options: nil, url: nil, selected: nil, allows_custom_value: false, **html_options) + @form, @options, @url, @selected, @allows_custom_value, @html_options = form, options, url, selected, allows_custom_value, html_options end - attr_reader :form, :options, :selected, :allows_custom_value + attr_reader :form, :options, :url, :selected, :allows_custom_value private @@ -22,7 +22,7 @@ class Dsfr::ComboboxComponent < ApplicationComponent spellcheck: 'false', id: input_id, class: input_class, - value: input_value, + role: 'combobox', 'aria-expanded': 'false', 'aria-describedby': @html_options[:describedby] }.compact @@ -36,8 +36,22 @@ class Dsfr::ComboboxComponent < ApplicationComponent "#{@html_options[:class].presence || ''} fr-select" end - def input_value - selected.present? ? options_with_values.find { _1.last == selected }&.first : nil + def selected_option_label_input_value + if selected.is_a?(Array) && selected.size == 2 + selected.first + elsif options.present? + selected.present? ? options_with_values.find { _1.last == selected }&.first : nil + else + selected + end + end + + def selected_option_value_input_value + if selected.is_a?(Array) && selected.size == 2 + selected.last + else + selected + end end def list_id @@ -45,10 +59,12 @@ class Dsfr::ComboboxComponent < ApplicationComponent end def options_with_values + return [] if url.present? options.map { _1.is_a?(Array) ? _1 : [_1, _1] } end def options_json + return nil if url.present? options_with_values.map { |(label, value)| { label:, value: } }.to_json end diff --git a/app/components/dsfr/combobox_component/combobox_component.html.haml b/app/components/dsfr/combobox_component/combobox_component.html.haml index d29bda22a..a8d1e0a55 100644 --- a/app/components/dsfr/combobox_component/combobox_component.html.haml +++ b/app/components/dsfr/combobox_component/combobox_component.html.haml @@ -1,13 +1,14 @@ .fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } } .fr-ds-combobox-input - %input{ **html_input_options } + %input{ value: selected_option_label_input_value, **html_input_options } - if form - = form.hidden_field name, value: selected, form: form_id + = form.hidden_field name, value: selected_option_value_input_value, form: form_id - else - %input{ type: 'hidden', name:, value: selected, form: form_id } + %input{ type: 'hidden', name:, value: selected_option_value_input_value, form: form_id } .fr-menu - %ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, selected:, hints: hints_json } } + %ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, url:, hints: hints_json }.compact } .sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } } %template %li.fr-menu__item{ role: 'option' } %slot{ name: 'label' } + = content diff --git a/app/javascript/controllers/combobox_controller.ts b/app/javascript/controllers/combobox_controller.ts index 06b605ed9..5813f8d98 100644 --- a/app/javascript/controllers/combobox_controller.ts +++ b/app/javascript/controllers/combobox_controller.ts @@ -9,14 +9,16 @@ export class ComboboxController extends ApplicationController { #combobox?: ComboboxUI; connect() { - const { input, valueInput, list, item, hint } = this.getElements(); + const { input, selectedValueInput, valueSlots, list, item, hint } = + this.getElements(); const hints = JSON.parse(list.dataset.hints ?? '{}') as Record< string, string >; this.#combobox = new ComboboxUI({ input, - valueInput, + selectedValueInput, + valueSlots, list, item, hint, @@ -33,9 +35,12 @@ export class ComboboxController extends ApplicationController { private getElements() { const input = this.element.querySelector('input[type="text"]'); - const valueInput = this.element.querySelector( + const selectedValueInput = this.element.querySelector( 'input[type="hidden"]' ); + const valueSlots = this.element.querySelectorAll( + 'input[type="hidden"][data-value-slot]' + ); const list = this.element.querySelector('[role=listbox]'); const item = this.element.querySelector('template'); const hint = @@ -46,7 +51,7 @@ export class ComboboxController extends ApplicationController { 'ComboboxController requires a input element' ); invariant( - isInputElement(valueInput), + isInputElement(selectedValueInput), 'ComboboxController requires a hidden input element' ); invariant( @@ -58,7 +63,7 @@ export class ComboboxController extends ApplicationController { 'ComboboxController requires a template element' ); - return { input, valueInput, list, item, hint }; + return { input, selectedValueInput, valueSlots, list, item, hint }; } } diff --git a/app/javascript/shared/combobox-ui.ts b/app/javascript/shared/combobox-ui.ts index e01c55450..3d4326564 100644 --- a/app/javascript/shared/combobox-ui.ts +++ b/app/javascript/shared/combobox-ui.ts @@ -3,15 +3,23 @@ import { isElement, dispatch, isInputElement } from '@coldwired/utils'; import { dispatchAction } from '@coldwired/actions'; import { createPopper, Instance as Popper } from '@popperjs/core'; -import { Combobox, State, Action, Option, Hint } from './combobox'; +import { + Combobox, + Action, + type State, + type Option, + type Hint, + type Fetcher +} from './combobox'; const ctrlBindings = !!navigator.userAgent.match(/Macintosh/); export type ComboboxUIOptions = { input: HTMLInputElement; - valueInput: HTMLInputElement; + selectedValueInput: HTMLInputElement; list: HTMLUListElement; item: HTMLTemplateElement; + valueSlots?: HTMLInputElement[] | NodeListOf; allowsCustomValue?: boolean; hint?: HTMLElement; getHintText?: (hint: Hint) => string; @@ -25,7 +33,8 @@ export class ComboboxUI implements EventListenerObject { #isComposing = false; #input: HTMLInputElement; - #valueInput: HTMLInputElement; + #selectedValueInput: HTMLInputElement; + #valueSlots: HTMLInputElement[]; #list: HTMLUListElement; #item: HTMLTemplateElement; #hint?: HTMLElement; @@ -35,7 +44,8 @@ export class ComboboxUI implements EventListenerObject { constructor({ input, - valueInput, + selectedValueInput, + valueSlots, list, item, hint, @@ -43,7 +53,8 @@ export class ComboboxUI implements EventListenerObject { allowsCustomValue }: ComboboxUIOptions) { this.#input = input; - this.#valueInput = valueInput; + this.#selectedValueInput = selectedValueInput; + this.#valueSlots = valueSlots ? Array.from(valueSlots) : []; this.#list = list; this.#item = item; this.#hint = hint; @@ -52,20 +63,39 @@ export class ComboboxUI implements EventListenerObject { } init() { - const selectedValue = this.#list.dataset.selected; - const options = JSON.parse(this.#list.dataset.options ?? '[]') as Option[]; - this.#list.removeAttribute('data-options'); - this.#list.removeAttribute('data-selected'); - const selected = - options.find(({ value }) => value == selectedValue) ?? null; + if (this.#list.dataset.url) { + const fetcher = createFetcher(this.#list.dataset.url); + + this.#list.removeAttribute('data-url'); + + const selected: Option | null = this.#input.value + ? { label: this.#input.value, value: this.#selectedValueInput.value } + : null; + this.#combobox = new Combobox({ + options: fetcher, + selected, + allowsCustomValue: this.#allowsCustomValue, + render: (state) => this.render(state) + }); + } else { + const selectedValue = this.#selectedValueInput.value; + const options = JSON.parse( + this.#list.dataset.options ?? '[]' + ) as Option[]; + const selected = + options.find(({ value }) => value == selectedValue) ?? null; + + this.#list.removeAttribute('data-options'); + this.#list.removeAttribute('data-selected'); + + this.#combobox = new Combobox({ + options, + selected, + allowsCustomValue: this.#allowsCustomValue, + render: (state) => this.render(state) + }); + } - this.#combobox = new Combobox({ - options, - selected, - allowsCustomValue: this.#allowsCustomValue, - value: this.#input.value, - render: (state) => this.render(state) - }); this.#combobox.init(); this.#input.addEventListener('blur', this); @@ -192,18 +222,20 @@ export class ComboboxUI implements EventListenerObject { this.#input.value = state.inputValue; } this.dispatchChange(() => { - if (this.#valueInput.value != state.inputValue) { + if (this.#selectedValueInput.value != state.inputValue) { if (state.allowsCustomValue || !state.inputValue) { - this.#valueInput.value = state.inputValue; + this.#selectedValueInput.value = state.inputValue; } } + return state.selection?.data; }); } private renderSelect(state: State): void { this.dispatchChange(() => { - this.#valueInput.value = state.selection?.value ?? ''; + this.#selectedValueInput.value = state.selection?.value ?? ''; this.#input.value = state.selection?.label ?? ''; + return state.selection?.data; }); } @@ -252,12 +284,28 @@ export class ComboboxUI implements EventListenerObject { } } - private dispatchChange(cb: () => void): void { - const value = this.#valueInput.value; - cb(); - if (value != this.#valueInput.value) { - console.debug('combobox change', this.#valueInput.value); - dispatch('change', { target: this.#valueInput }); + private dispatchChange(cb: () => Option['data']): void { + const value = this.#selectedValueInput.value; + const data = cb(); + if (value != this.#selectedValueInput.value) { + for (const input of this.#valueSlots) { + switch (input.dataset.valueSlot) { + case 'value': + input.value = this.#selectedValueInput.value; + break; + case 'label': + input.value = this.#input.value; + break; + case 'data': + input.value = data ? JSON.stringify(data) : ''; + break; + } + } + console.debug('combobox change', this.#selectedValueInput.value); + dispatch('change', { + target: this.#selectedValueInput, + detail: data ? { data } : undefined + }); } } @@ -353,7 +401,12 @@ function inViewport(container: HTMLElement, element: HTMLElement): boolean { } function optionId(value: string) { - return `option-${value.replace(/\s/g, '-')}`; + return `option-${value + .toLowerCase() + // Replace spaces and special characters with underscores + .replace(/[^a-z0-9]/g, '_') + // Remove non-alphanumeric characters at start and end + .replace(/^[^a-z]+|[^\w]$/g, '')}`; } function defaultGetHintText(hint: Hint): string { @@ -369,3 +422,37 @@ function defaultGetHintText(hint: Hint): string { return `${hint.label} selected.`; } } + +function createFetcher(source: string, param = 'q'): Fetcher { + const url = new URL(source, location.href); + + const fetcher: Fetcher = (term: string, options) => { + url.searchParams.set(param, term); + return fetch(url.toString(), { + headers: { accept: 'application/json' }, + signal: options?.signal + }).then((response) => { + if (response.ok) { + return response.json(); + } + return []; + }); + }; + + return async (term: string, options) => { + await wait(500, options?.signal); + return fetcher(term, options); + }; +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const abort = () => reject(new DOMException('Aborted', 'AbortError')); + if (signal?.aborted) { + abort(); + } else { + signal?.addEventListener('abort', abort); + setTimeout(resolve, ms); + } + }); +} diff --git a/app/javascript/shared/combobox.test.ts b/app/javascript/shared/combobox.test.ts index f3f429b3c..45633b997 100644 --- a/app/javascript/shared/combobox.test.ts +++ b/app/javascript/shared/combobox.test.ts @@ -1,4 +1,5 @@ import { suite, test, beforeEach, expect } from 'vitest'; +import { matchSorter } from 'match-sorter'; import { Combobox, Option, State } from './combobox'; @@ -26,6 +27,7 @@ suite('Combobox', () => { test('open select box and select option with click', () => { expect(currentState.open).toBeFalsy(); + expect(currentState.loading).toBe(null); expect(currentState.selection?.label).toBe('Fraises'); combobox.open(); @@ -262,4 +264,32 @@ suite('Combobox', () => { expect(currentState.inputValue).toEqual('toto'); }); }); + + suite('single select with fetcher', () => { + beforeEach(() => { + combobox = new Combobox({ + options: (term: string) => + Promise.resolve(matchSorter(options, term, { keys: ['value'] })), + selected: null, + render: (state) => { + currentState = state; + } + }); + combobox.init(); + }); + + test('type and get options from fetcher', async () => { + expect(currentState.open).toBeFalsy(); + expect(currentState.loading).toBe(false); + + const result = combobox.input('Baies'); + + expect(currentState.loading).toBe(true); + await result; + expect(currentState.loading).toBe(false); + expect(currentState.open).toBeTruthy(); + expect(currentState.selection).toBeNull(); + expect(currentState.options.length).toEqual(3); + }); + }); }); diff --git a/app/javascript/shared/combobox.ts b/app/javascript/shared/combobox.ts index 15b9f38b6..ddae8c4c2 100644 --- a/app/javascript/shared/combobox.ts +++ b/app/javascript/shared/combobox.ts @@ -9,7 +9,7 @@ export enum Action { Clear = 'clear', Update = 'update' } -export type Option = { value: string; label: string }; +export type Option = { value: string; label: string; data?: unknown }; export type Hint = | { type: 'results'; @@ -27,8 +27,14 @@ export type State = { options: Option[]; allowsCustomValue: boolean; hint: Hint | null; + loading: boolean | null; }; +export type Fetcher = ( + term: string, + options?: { signal: AbortSignal } +) => Promise; + export class Combobox { #allowsCustomValue = false; #open = false; @@ -38,27 +44,26 @@ export class Combobox { #options: Option[] = []; #visibleOptions: Option[] = []; #render: (state: State) => void; + #fetcher: Fetcher | null; + #abortController?: AbortController | null; constructor({ options, selected, - value, allowsCustomValue, render }: { - options: Option[]; + options: Option[] | Fetcher; selected: Option | null; - value?: string; allowsCustomValue?: boolean; render: (state: State) => void; }) { this.#allowsCustomValue = allowsCustomValue ?? false; - this.#options = options; + this.#options = Array.isArray(options) ? options : []; + this.#fetcher = Array.isArray(options) ? null : options; this.#selectedOption = selected; if (this.#selectedOption) { this.#inputValue = this.#selectedOption.label; - } else if (value) { - this.#inputValue = value; } this.#render = render; } @@ -114,11 +119,28 @@ export class Combobox { return true; } - input(value: string) { + async input(value: string) { if (this.#inputValue == value) return; + this.#inputValue = value; - this.#selectedOption = null; - this.#visibleOptions = this._filterOptions(); + + if (this.#fetcher) { + this.#abortController?.abort(); + this.#abortController = new AbortController(); + this._render(Action.Update); + this.#options = await this.#fetcher(value, { + signal: this.#abortController.signal + }).catch(() => []); + this.#abortController = null; + this._render(Action.Update); + + this.#selectedOption = null; + this.#visibleOptions = this.#options; + } else { + this.#selectedOption = null; + this.#visibleOptions = this._filterOptions(); + } + if (this.#visibleOptions.length > 0) { if (!this.#open) { this.open(); @@ -239,7 +261,8 @@ export class Combobox { focused: this.#focusedOption, selection: this.#selectedOption, allowsCustomValue: this.#allowsCustomValue, - hint: null + hint: null, + loading: this.#abortController ? true : this.#fetcher ? false : null }; return { ...state, hint: this._getFeedback(state) }; diff --git a/config/routes.rb b/config/routes.rb index 2625b779f..1e0d2d225 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -221,6 +221,10 @@ Rails.application.routes.draw do end end + namespace :data_sources do + get :adresse, to: 'adresse#search', as: :data_source_adresse + end + # # Deprecated UI # From 89582d2e09e809ae94668dc8a722a7a41927dce0 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 3 Oct 2023 18:14:42 +0200 Subject: [PATCH 3/3] feat(dossier): use new combobox on champ adresse --- app/components/editable_champ/address_component.rb | 2 +- .../address_component/address_component.html.haml | 13 ++----------- app/controllers/data_sources/adresse_controller.rb | 11 +++++++++++ 3 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 app/controllers/data_sources/adresse_controller.rb diff --git a/app/components/editable_champ/address_component.rb b/app/components/editable_champ/address_component.rb index 35ff5aeeb..b3cc203cc 100644 --- a/app/components/editable_champ/address_component.rb +++ b/app/components/editable_champ/address_component.rb @@ -1,2 +1,2 @@ -class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent +class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent end diff --git a/app/components/editable_champ/address_component/address_component.html.haml b/app/components/editable_champ/address_component/address_component.html.haml index ef480948d..c8a590d7c 100644 --- a/app/components/editable_champ/address_component/address_component.html.haml +++ b/app/components/editable_champ/address_component/address_component.html.haml @@ -1,11 +1,2 @@ -- render_parent - -= @form.hidden_field :value -= @form.hidden_field :external_id - -= react_component("ComboAdresseSearch", - required: @champ.required?, - id: @champ.input_id, - describedby: @champ.describedby_id, - **react_combo_props, -) += render Dsfr::ComboboxComponent.new form: @form, name: :value, url: data_sources_data_source_adresse_path, selected: @champ.value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id do + = @form.hidden_field :external_id, data: { value_slot: 'value' } diff --git a/app/controllers/data_sources/adresse_controller.rb b/app/controllers/data_sources/adresse_controller.rb new file mode 100644 index 000000000..c9cac63ac --- /dev/null +++ b/app/controllers/data_sources/adresse_controller.rb @@ -0,0 +1,11 @@ +class DataSources::AdresseController < ApplicationController + def search + if params[:q].present? && params[:q].length > 3 + response = Typhoeus.get("#{API_ADRESSE_URL}/search", params: { q: params[:q], limit: 10 }) + result = JSON.parse(response.body, symbolize_names: true) + render json: result[:features].map { { label: _1[:properties][:label], value: _1[:properties][:label] } } + else + render json: [] + end + end +end