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

View file

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

View file

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

View file

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

View file

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

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;
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<HTMLInputElement>('input[type="text"]');
const valueInput = this.element.querySelector<HTMLInputElement>(
const selectedValueInput = this.element.querySelector<HTMLInputElement>(
'input[type="hidden"]'
);
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][data-value-slot]'
);
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
const item = this.element.querySelector<HTMLTemplateElement>('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 };
}
}

View file

@ -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<HTMLInputElement>;
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<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 { 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);
});
});
});

View file

@ -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<Option[]>;
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) };

View file

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