Merge pull request #9495 from tchak/feat-autocomplete-from-api

wip(autocomplete): autocomplete from url
This commit is contained in:
Paul Chavard 2023-10-13 09:32:53 +00:00 committed by GitHub
commit c995a06434
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 241 additions and 79 deletions

View file

@ -86,7 +86,7 @@ GEM
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.4) addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
administrate (0.18.0) administrate (0.18.0)
actionpack (>= 5.0) actionpack (>= 5.0)
@ -103,13 +103,10 @@ GEM
anchored (1.1.0) anchored (1.1.0)
ast (2.4.2) ast (2.4.2)
attr_required (1.0.1) attr_required (1.0.1)
axe-core-api (4.2.1) axe-core-api (4.8.0)
capybara
dumb_delegator dumb_delegator
selenium-webdriver
virtus virtus
watir axe-core-rspec (4.8.0)
axe-core-rspec (4.2.1)
axe-core-api axe-core-api
dumb_delegator dumb_delegator
virtus virtus
@ -487,7 +484,7 @@ GEM
pry (>= 0.13, < 0.15) pry (>= 0.13, < 0.15)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (5.0.1) public_suffix (5.0.3)
puma (6.3.1) puma (6.3.1)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
@ -576,7 +573,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0) http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0) mime-types (>= 1.16, < 4.0)
netrc (~> 0.8) netrc (~> 0.8)
rexml (3.2.5) rexml (3.2.6)
rodf (1.1.1) rodf (1.1.1)
builder (>= 3.0) builder (>= 3.0)
dry-inflector (~> 0.1) dry-inflector (~> 0.1)
@ -666,7 +663,7 @@ GEM
selectize-rails (0.12.6) selectize-rails (0.12.6)
selenium-devtools (0.114.0) selenium-devtools (0.114.0)
selenium-webdriver (~> 4.2) selenium-webdriver (~> 4.2)
selenium-webdriver (4.10.0) selenium-webdriver (4.13.1)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
@ -763,9 +760,6 @@ GEM
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
watir (6.19.1)
regexp_parser (>= 1.2, < 3)
selenium-webdriver (>= 3.142.7)
web-console (4.1.0) web-console (4.1.0)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
@ -778,7 +772,7 @@ GEM
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.9) websocket (1.2.10)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)

View file

@ -1,9 +1,9 @@
class Dsfr::ComboboxComponent < ApplicationComponent class Dsfr::ComboboxComponent < ApplicationComponent
def initialize(form: nil, options:, selected: nil, allows_custom_value: false, **html_options) def initialize(form: nil, options: nil, url: nil, selected: nil, allows_custom_value: false, **html_options)
@form, @options, @selected, @allows_custom_value, @html_options = form, options, selected, allows_custom_value, html_options @form, @options, @url, @selected, @allows_custom_value, @html_options = form, options, url, selected, allows_custom_value, html_options
end end
attr_reader :form, :options, :selected, :allows_custom_value attr_reader :form, :options, :url, :selected, :allows_custom_value
private private
@ -22,7 +22,7 @@ class Dsfr::ComboboxComponent < ApplicationComponent
spellcheck: 'false', spellcheck: 'false',
id: input_id, id: input_id,
class: input_class, class: input_class,
value: input_value, role: 'combobox',
'aria-expanded': 'false', 'aria-expanded': 'false',
'aria-describedby': @html_options[:describedby] 'aria-describedby': @html_options[:describedby]
}.compact }.compact
@ -36,8 +36,22 @@ class Dsfr::ComboboxComponent < ApplicationComponent
"#{@html_options[:class].presence || ''} fr-select" "#{@html_options[:class].presence || ''} fr-select"
end end
def input_value def selected_option_label_input_value
selected.present? ? options_with_values.find { _1.last == selected }&.first : nil 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 end
def list_id def list_id
@ -45,10 +59,12 @@ class Dsfr::ComboboxComponent < ApplicationComponent
end end
def options_with_values def options_with_values
return [] if url.present?
options.map { _1.is_a?(Array) ? _1 : [_1, _1] } options.map { _1.is_a?(Array) ? _1 : [_1, _1] }
end end
def options_json def options_json
return nil if url.present?
options_with_values.map { |(label, value)| { label:, value: } }.to_json options_with_values.map { |(label, value)| { label:, value: } }.to_json
end end

View file

@ -1,13 +1,14 @@
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } } .fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } }
.fr-ds-combobox-input .fr-ds-combobox-input
%input{ **html_input_options } %input{ value: selected_option_label_input_value, **html_input_options }
- if form - 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 - 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 .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 } } .sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
%template %template
%li.fr-menu__item{ role: 'option' } %li.fr-menu__item{ role: 'option' }
%slot{ name: 'label' } %slot{ name: 'label' }
= content

View file

@ -1,2 +1,2 @@
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
end end

View file

@ -1,11 +1,2 @@
- render_parent = 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' }
= @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,
)

View file

@ -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

View file

@ -9,14 +9,16 @@ export class ComboboxController extends ApplicationController {
#combobox?: ComboboxUI; #combobox?: ComboboxUI;
connect() { 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< const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
string, string,
string string
>; >;
this.#combobox = new ComboboxUI({ this.#combobox = new ComboboxUI({
input, input,
valueInput, selectedValueInput,
valueSlots,
list, list,
item, item,
hint, hint,
@ -33,9 +35,12 @@ export class ComboboxController extends ApplicationController {
private getElements() { private getElements() {
const input = const input =
this.element.querySelector<HTMLInputElement>('input[type="text"]'); this.element.querySelector<HTMLInputElement>('input[type="text"]');
const valueInput = this.element.querySelector<HTMLInputElement>( const selectedValueInput = this.element.querySelector<HTMLInputElement>(
'input[type="hidden"]' 'input[type="hidden"]'
); );
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][data-value-slot]'
);
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]'); const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
const item = this.element.querySelector<HTMLTemplateElement>('template'); const item = this.element.querySelector<HTMLTemplateElement>('template');
const hint = const hint =
@ -46,7 +51,7 @@ export class ComboboxController extends ApplicationController {
'ComboboxController requires a input element' 'ComboboxController requires a input element'
); );
invariant( invariant(
isInputElement(valueInput), isInputElement(selectedValueInput),
'ComboboxController requires a hidden input element' 'ComboboxController requires a hidden input element'
); );
invariant( invariant(
@ -58,7 +63,7 @@ export class ComboboxController extends ApplicationController {
'ComboboxController requires a template element' 'ComboboxController requires a template element'
); );
return { input, valueInput, list, item, hint }; return { input, selectedValueInput, valueSlots, list, item, hint };
} }
} }

View file

@ -3,15 +3,23 @@ import { isElement, dispatch, isInputElement } from '@coldwired/utils';
import { dispatchAction } from '@coldwired/actions'; import { dispatchAction } from '@coldwired/actions';
import { createPopper, Instance as Popper } from '@popperjs/core'; 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/); const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
export type ComboboxUIOptions = { export type ComboboxUIOptions = {
input: HTMLInputElement; input: HTMLInputElement;
valueInput: HTMLInputElement; selectedValueInput: HTMLInputElement;
list: HTMLUListElement; list: HTMLUListElement;
item: HTMLTemplateElement; item: HTMLTemplateElement;
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
allowsCustomValue?: boolean; allowsCustomValue?: boolean;
hint?: HTMLElement; hint?: HTMLElement;
getHintText?: (hint: Hint) => string; getHintText?: (hint: Hint) => string;
@ -25,7 +33,8 @@ export class ComboboxUI implements EventListenerObject {
#isComposing = false; #isComposing = false;
#input: HTMLInputElement; #input: HTMLInputElement;
#valueInput: HTMLInputElement; #selectedValueInput: HTMLInputElement;
#valueSlots: HTMLInputElement[];
#list: HTMLUListElement; #list: HTMLUListElement;
#item: HTMLTemplateElement; #item: HTMLTemplateElement;
#hint?: HTMLElement; #hint?: HTMLElement;
@ -35,7 +44,8 @@ export class ComboboxUI implements EventListenerObject {
constructor({ constructor({
input, input,
valueInput, selectedValueInput,
valueSlots,
list, list,
item, item,
hint, hint,
@ -43,7 +53,8 @@ export class ComboboxUI implements EventListenerObject {
allowsCustomValue allowsCustomValue
}: ComboboxUIOptions) { }: ComboboxUIOptions) {
this.#input = input; this.#input = input;
this.#valueInput = valueInput; this.#selectedValueInput = selectedValueInput;
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
this.#list = list; this.#list = list;
this.#item = item; this.#item = item;
this.#hint = hint; this.#hint = hint;
@ -52,20 +63,39 @@ export class ComboboxUI implements EventListenerObject {
} }
init() { init() {
const selectedValue = this.#list.dataset.selected; if (this.#list.dataset.url) {
const options = JSON.parse(this.#list.dataset.options ?? '[]') as Option[]; const fetcher = createFetcher(this.#list.dataset.url);
this.#list.removeAttribute('data-options');
this.#list.removeAttribute('data-selected'); this.#list.removeAttribute('data-url');
const selected =
options.find(({ value }) => value == selectedValue) ?? null; 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.#combobox.init();
this.#input.addEventListener('blur', this); this.#input.addEventListener('blur', this);
@ -192,18 +222,20 @@ export class ComboboxUI implements EventListenerObject {
this.#input.value = state.inputValue; this.#input.value = state.inputValue;
} }
this.dispatchChange(() => { this.dispatchChange(() => {
if (this.#valueInput.value != state.inputValue) { if (this.#selectedValueInput.value != state.inputValue) {
if (state.allowsCustomValue || !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 { private renderSelect(state: State): void {
this.dispatchChange(() => { this.dispatchChange(() => {
this.#valueInput.value = state.selection?.value ?? ''; this.#selectedValueInput.value = state.selection?.value ?? '';
this.#input.value = state.selection?.label ?? ''; this.#input.value = state.selection?.label ?? '';
return state.selection?.data;
}); });
} }
@ -252,12 +284,28 @@ export class ComboboxUI implements EventListenerObject {
} }
} }
private dispatchChange(cb: () => void): void { private dispatchChange(cb: () => Option['data']): void {
const value = this.#valueInput.value; const value = this.#selectedValueInput.value;
cb(); const data = cb();
if (value != this.#valueInput.value) { if (value != this.#selectedValueInput.value) {
console.debug('combobox change', this.#valueInput.value); for (const input of this.#valueSlots) {
dispatch('change', { target: this.#valueInput }); 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) { 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 { function defaultGetHintText(hint: Hint): string {
@ -369,3 +422,37 @@ function defaultGetHintText(hint: Hint): string {
return `${hint.label} selected.`; 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<Option[]>((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);
}
});
}

View file

@ -1,4 +1,5 @@
import { suite, test, beforeEach, expect } from 'vitest'; import { suite, test, beforeEach, expect } from 'vitest';
import { matchSorter } from 'match-sorter';
import { Combobox, Option, State } from './combobox'; import { Combobox, Option, State } from './combobox';
@ -26,6 +27,7 @@ suite('Combobox', () => {
test('open select box and select option with click', () => { test('open select box and select option with click', () => {
expect(currentState.open).toBeFalsy(); expect(currentState.open).toBeFalsy();
expect(currentState.loading).toBe(null);
expect(currentState.selection?.label).toBe('Fraises'); expect(currentState.selection?.label).toBe('Fraises');
combobox.open(); combobox.open();
@ -262,4 +264,32 @@ suite('Combobox', () => {
expect(currentState.inputValue).toEqual('toto'); 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);
});
});
}); });

View file

@ -9,7 +9,7 @@ export enum Action {
Clear = 'clear', Clear = 'clear',
Update = 'update' Update = 'update'
} }
export type Option = { value: string; label: string }; export type Option = { value: string; label: string; data?: unknown };
export type Hint = export type Hint =
| { | {
type: 'results'; type: 'results';
@ -27,8 +27,14 @@ export type State = {
options: Option[]; options: Option[];
allowsCustomValue: boolean; allowsCustomValue: boolean;
hint: Hint | null; hint: Hint | null;
loading: boolean | null;
}; };
export type Fetcher = (
term: string,
options?: { signal: AbortSignal }
) => Promise<Option[]>;
export class Combobox { export class Combobox {
#allowsCustomValue = false; #allowsCustomValue = false;
#open = false; #open = false;
@ -38,27 +44,26 @@ export class Combobox {
#options: Option[] = []; #options: Option[] = [];
#visibleOptions: Option[] = []; #visibleOptions: Option[] = [];
#render: (state: State) => void; #render: (state: State) => void;
#fetcher: Fetcher | null;
#abortController?: AbortController | null;
constructor({ constructor({
options, options,
selected, selected,
value,
allowsCustomValue, allowsCustomValue,
render render
}: { }: {
options: Option[]; options: Option[] | Fetcher;
selected: Option | null; selected: Option | null;
value?: string;
allowsCustomValue?: boolean; allowsCustomValue?: boolean;
render: (state: State) => void; render: (state: State) => void;
}) { }) {
this.#allowsCustomValue = allowsCustomValue ?? false; this.#allowsCustomValue = allowsCustomValue ?? false;
this.#options = options; this.#options = Array.isArray(options) ? options : [];
this.#fetcher = Array.isArray(options) ? null : options;
this.#selectedOption = selected; this.#selectedOption = selected;
if (this.#selectedOption) { if (this.#selectedOption) {
this.#inputValue = this.#selectedOption.label; this.#inputValue = this.#selectedOption.label;
} else if (value) {
this.#inputValue = value;
} }
this.#render = render; this.#render = render;
} }
@ -114,11 +119,28 @@ export class Combobox {
return true; return true;
} }
input(value: string) { async input(value: string) {
if (this.#inputValue == value) return; if (this.#inputValue == value) return;
this.#inputValue = value; 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.#visibleOptions.length > 0) {
if (!this.#open) { if (!this.#open) {
this.open(); this.open();
@ -239,7 +261,8 @@ export class Combobox {
focused: this.#focusedOption, focused: this.#focusedOption,
selection: this.#selectedOption, selection: this.#selectedOption,
allowsCustomValue: this.#allowsCustomValue, allowsCustomValue: this.#allowsCustomValue,
hint: null hint: null,
loading: this.#abortController ? true : this.#fetcher ? false : null
}; };
return { ...state, hint: this._getFeedback(state) }; return { ...state, hint: this._getFeedback(state) };

View file

@ -221,6 +221,10 @@ Rails.application.routes.draw do
end end
end end
namespace :data_sources do
get :adresse, to: 'adresse#search', as: :data_source_adresse
end
# #
# Deprecated UI # Deprecated UI
# #