Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP][Autocomplete] Adding morph support for Turbo & overhauling process #1512

Open
wants to merge 2 commits into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Autocomplete/assets/dist/controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default class extends Controller {
private isObserving;
private hasLoadedChoicesPreviously;
private originalOptions;
private parentElement;
initialize(): void;
connect(): void;
initializeTomSelect(): void;
Expand All @@ -50,4 +51,6 @@ export default class extends Controller {
private onMutations;
private createOptionsDataStructure;
private areOptionsEquivalent;
private beforeMorphElement;
private beforeMorphAttribute;
}
65 changes: 51 additions & 14 deletions src/Autocomplete/assets/dist/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class default_1 extends Controller {
this.isObserving = false;
this.hasLoadedChoicesPreviously = false;
this.originalOptions = [];
this.parentElement = null;
}
initialize() {
if (!this.mutationObserver) {
Expand All @@ -42,6 +43,11 @@ class default_1 extends Controller {
if (this.selectElement) {
this.originalOptions = this.createOptionsDataStructure(this.selectElement);
}
this.parentElement = this.element.parentElement;
if (this.parentElement) {
this.parentElement.addEventListener('turbo:before-morph-element', this.beforeMorphElement.bind(this));
this.parentElement.addEventListener('turbo:before-morph-attribute', this.beforeMorphAttribute.bind(this));
}
this.initializeTomSelect();
}
initializeTomSelect() {
Expand All @@ -61,6 +67,11 @@ class default_1 extends Controller {
}
disconnect() {
this.stopMutationObserver();
if (this.parentElement) {
this.parentElement.removeEventListener('turbo:before-morph-element', this.beforeMorphElement.bind(this));
this.parentElement.removeEventListener('turbo:before-morph-attribute', this.beforeMorphAttribute.bind(this));
this.parentElement = null;
}
let currentSelectedValues = [];
if (this.selectElement) {
if (this.selectElement.multiple) {
Expand Down Expand Up @@ -156,15 +167,13 @@ class default_1 extends Controller {
}
}
onMutations(mutations) {
let changeDisabledState = false;
let requireReset = false;
if (this.tomSelect.isDisabled !== this.formElement.disabled) {
this.changeTomSelectDisabledState(this.formElement.disabled);
}
mutations.forEach((mutation) => {
switch (mutation.type) {
case 'attributes':
if (mutation.target === this.element && mutation.attributeName === 'disabled') {
changeDisabledState = true;
break;
}
if (mutation.target === this.element && mutation.attributeName === 'multiple') {
const isNowMultiple = this.element.hasAttribute('multiple');
const wasMultiple = mutation.oldValue === 'multiple';
Expand All @@ -177,14 +186,13 @@ class default_1 extends Controller {
}
});
const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
if (!areOptionsEquivalent || requireReset) {
const areOptionsEquivalent = this.areOptionsEquivalent(this.originalOptions, newOptions);
const value = this.selectElement ? Array.from(this.selectElement.options || []).map((option) => option.value) : this.formElement.value;
const didValueChange = value !== this.tomSelect.getValue();
if (!areOptionsEquivalent || requireReset || didValueChange) {
this.originalOptions = newOptions;
this.resetTomSelect();
}
if (changeDisabledState) {
this.changeTomSelectDisabledState(this.formElement.disabled);
}
}
createOptionsDataStructure(selectElement) {
return Array.from(selectElement.options).map((option) => {
Expand All @@ -196,25 +204,54 @@ class default_1 extends Controller {
};
});
}
areOptionsEquivalent(newOptions) {
const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
areOptionsEquivalent(currentOptions, newOptions) {
const filteredCurrentOptions = currentOptions.filter((option) => option.value !== '');
const filteredNewOptions = newOptions.filter((option) => option.value !== '');
console.log(filteredCurrentOptions, filteredNewOptions);
const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
const newPlaceholderOption = newOptions.find((option) => option.value === '');
if (originalPlaceholderOption &&
newPlaceholderOption &&
originalPlaceholderOption.text !== newPlaceholderOption.text) {
return false;
}
if (filteredOriginalOptions.length !== filteredNewOptions.length) {
if (filteredCurrentOptions.length !== filteredNewOptions.length) {
return false;
}
const normalizeOption = (option) => `${option.value}-${option.text}-${option.group}`;
const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption));
const originalOptionsSet = new Set(filteredCurrentOptions.map(normalizeOption));
const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption));
return (originalOptionsSet.size === newOptionsSet.size &&
[...originalOptionsSet].every((option) => newOptionsSet.has(option)));
}
beforeMorphElement(event) {
if (event.target.classList.contains('ts-wrapper')) {
event.preventDefault();
return;
}
if (event.target === this.element && event.target.tagName === 'SELECT') {
const newOptions = this.createOptionsDataStructure(event.detail.newElement);
const currentOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
console.log(this.areOptionsEquivalent(currentOptions, newOptions));
if (this.areOptionsEquivalent(currentOptions, newOptions)) {
event.preventDefault();
return;
}
}
}
beforeMorphAttribute(event) {
if (event.target.tagName === 'LABEL') {
console.log('before morph attribute', event.detail);
}
if (event.target.tagName === 'LABEL' && ['for', 'id'].includes(event.detail.attributeName)) {
event.preventDefault();
return;
}
if (event.target === this.element && event.detail.attributeName === 'class') {
event.preventDefault();
return;
}
}
}
_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
const plugins = {};
Expand Down
94 changes: 77 additions & 17 deletions src/Autocomplete/assets/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default class extends Controller {
private isObserving = false;
private hasLoadedChoicesPreviously = false;
private originalOptions: Array<{ value: string; text: string; group: string | null }> = [];
private parentElement: HTMLElement| null = null;

initialize() {
if (!this.mutationObserver) {
Expand All @@ -52,6 +53,12 @@ export default class extends Controller {
if (this.selectElement) {
this.originalOptions = this.createOptionsDataStructure(this.selectElement);
}
// TODO - also listen on `live:before-morph-element`
this.parentElement = this.element.parentElement;
if (this.parentElement) {
this.parentElement.addEventListener('turbo:before-morph-element', this.beforeMorphElement.bind(this));
this.parentElement.addEventListener('turbo:before-morph-attribute', this.beforeMorphAttribute.bind(this));
}

this.initializeTomSelect();
}
Expand Down Expand Up @@ -85,6 +92,12 @@ export default class extends Controller {
disconnect() {
this.stopMutationObserver();

if (this.parentElement) {
this.parentElement.removeEventListener('turbo:before-morph-element', this.beforeMorphElement.bind(this));
this.parentElement.removeEventListener('turbo:before-morph-attribute', this.beforeMorphAttribute.bind(this));
this.parentElement = null;
}

// TomSelect.destroy() resets the element to its original HTML. This
// causes the selected value to be lost. We store it.
let currentSelectedValues: string[] = [];
Expand Down Expand Up @@ -373,18 +386,15 @@ export default class extends Controller {
}

private onMutations(mutations: MutationRecord[]): void {
let changeDisabledState = false;
let requireReset = false;

if (this.tomSelect.isDisabled !== this.formElement.disabled) {
this.changeTomSelectDisabledState(this.formElement.disabled);
}

mutations.forEach((mutation) => {
switch (mutation.type) {
case 'attributes':
if (mutation.target === this.element && mutation.attributeName === 'disabled') {
changeDisabledState = true;

break;
}

if (mutation.target === this.element && mutation.attributeName === 'multiple') {
const isNowMultiple = this.element.hasAttribute('multiple');
const wasMultiple = mutation.oldValue === 'multiple';
Expand All @@ -400,15 +410,16 @@ export default class extends Controller {
});

const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
if (!areOptionsEquivalent || requireReset) {
const areOptionsEquivalent = this.areOptionsEquivalent(this.originalOptions, newOptions);

// TODO: test this: look for chsrc/Autocomplete/assets/dist/coanges in the "value" of the select element
const value = this.selectElement ? Array.from(this.selectElement.options || []).map((option) => option.value) : this.formElement.value;
const didValueChange = value !== this.tomSelect.getValue();

if (!areOptionsEquivalent || requireReset || didValueChange) {
this.originalOptions = newOptions;
this.resetTomSelect();
}

if (changeDisabledState) {
this.changeTomSelectDisabledState(this.formElement.disabled);
}
}

private createOptionsDataStructure(
Expand All @@ -424,10 +435,13 @@ export default class extends Controller {
});
}

private areOptionsEquivalent(newOptions: Array<{ value: string; text: string; group: string | null }>): boolean {
private areOptionsEquivalent(currentOptions: Array<{ value: string; text: string; group: string | null }>, newOptions: Array<{ value: string; text: string; group: string | null }>): boolean {
const filteredCurrentOptions = currentOptions.filter((option) => option.value !== '');

// remove the empty option, which is added by TomSelect so may be missing from new options
const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
//const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
const filteredNewOptions = newOptions.filter((option) => option.value !== '');
console.log(filteredCurrentOptions, filteredNewOptions);

const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
const newPlaceholderOption = newOptions.find((option) => option.value === '');
Expand All @@ -440,18 +454,64 @@ export default class extends Controller {
return false;
}

if (filteredOriginalOptions.length !== filteredNewOptions.length) {
if (filteredCurrentOptions.length !== filteredNewOptions.length) {
return false;
}

const normalizeOption = (option: { value: string; text: string; group: string | null }) =>
`${option.value}-${option.text}-${option.group}`;
const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption));
const originalOptionsSet = new Set(filteredCurrentOptions.map(normalizeOption));
const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption));

return (
originalOptionsSet.size === newOptionsSet.size &&
[...originalOptionsSet].every((option) => newOptionsSet.has(option))
);
}

private beforeMorphElement(event: any) {
// TomSelect adds this element to the DOM. Keep it.
if (event.target.classList.contains('ts-wrapper')) {
event.preventDefault();

return;
}

if (event.target === this.element && event.target.tagName === 'SELECT') {
const newOptions = this.createOptionsDataStructure(event.detail.newElement);
const currentOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
// if the options are the same OR this is an Ajax select (where the options
// don't represent the actual options), then prevent the morph to avoid
// the problem described in the comment below
if (this.areOptionsEquivalent(currentOptions, newOptions) || this.urlValue) {
// prevent the <option> elements from being mutated, as this will
// change their order, which befuddles TomSelect (who changes the
// order of the <option> elements when you select them)
// this will, unfortunately, prevent any changes attributed on
// the select element from being updated.
event.preventDefault();

return;
}
}
}

private beforeMorphAttribute(event: any) {
if (event.target.tagName === 'LABEL') {
console.log('before morph attribute', event.detail);
}
// TomSelect changes the label "for". Keep that change.
if (event.target.tagName === 'LABEL' && ['for', 'id'].includes(event.detail.attributeName)) {
event.preventDefault();

return;
}

// TomSelect changes the root element's class. Keep that change.
if (event.target === this.element && event.detail.attributeName === 'class') {
event.preventDefault();

return;
}
}
}
4 changes: 4 additions & 0 deletions ux.symfony.com/src/Form/TimeForAMealForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;

class TimeForAMealForm extends AbstractType
{
Expand All @@ -23,6 +24,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
->add('foods', FoodAutocompleteField::class)
->add('name', TextType::class, [
'label' => 'What should we call this meal?',
'constraints' => [
new NotBlank(),
],
])
;
}
Expand Down
2 changes: 2 additions & 0 deletions ux.symfony.com/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<link rel="canonical" href="{{ meta.canonical }}">
{% endif %}
<meta name="view-transition" content="same-origin" />
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
Expand Down
6 changes: 3 additions & 3 deletions ux.symfony.com/templates/ux_packages/autocomplete.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

{% block demo_content %}
{# The frame is used just to keep the form submit all in one place: it feels nice #}
<turbo-frame id="autocomplete-demo-form">
<div id="autocomplete-demo-form">
{% for message in app.flashes('autocomplete_success') %}
<div class="alert alert-success" data-turbo-cache="false">{{ message }}</div>
{% endfor %}
Expand All @@ -41,7 +41,7 @@

{{ form_row(form.name) }}

<button type="submit" class="btn btn-primary">Let's Nom!</button>
<button type="submit" class="btn btn-primary" formnovalidate>Let's Nom!</button>
{{ form_end(form) }}
</turbo-frame>
</div>
{% endblock %}