Skip to content

Commit b5c36bd

Browse files
committed
feature #330 [Autocompleter] New Ajax-powered, autocomplete component (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Autocompleter] New Ajax-powered, autocomplete component | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | none | License | MIT Hi! A new component! One I've wanted for a LONG time. In short, it creates things that look like this, where the autocomplete search is backed by Ajax. ![ux-autocomplete-animation](https://user-images.githubusercontent.com/121003/172226699-9ec8f016-6d46-4da0-a5fa-d626f37612f3.gif) Big thanks to EasyAdmin, whose AssociationField heavily inspired this. * [ ] We could possibly avoid the requirement of creating a separate class for Ajax autocomplete by using signed URLs. However, this would only work for "simpler" fields - e.g. if you had a custom `query_builder` (or perhaps other options that we might not want to include in the URL, like `security`), then you would still be required to have the extra class. It's something to explore. Cheers! Recipe: symfony/recipes#1094 Commits ------- 1228e99 [Autocompleter] New Ajax-powered, autocomplete component
2 parents f1711ec + 1228e99 commit b5c36bd

File tree

93 files changed

+4093
-17
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

93 files changed

+4093
-17
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"@babel/preset-env": "^7.15.8",
1818
"@babel/preset-react": "^7.15.8",
1919
"@babel/preset-typescript": "^7.15.8",
20-
"@rollup/plugin-node-resolve": "^13.0.0",
20+
"@rollup/plugin-commonjs": "^22.0.0",
21+
"@rollup/plugin-node-resolve": "^13.0.6",
2122
"@rollup/plugin-typescript": "^8.3.0",
2223
"@symfony/stimulus-testing": "^2.0.1",
2324
"@typescript-eslint/eslint-plugin": "^5.2.0",
@@ -29,7 +30,7 @@
2930
"jest": "^27.3.1",
3031
"pkg-up": "^3.1.0",
3132
"prettier": "^2.2.1",
32-
"rollup": "^2.52.2",
33+
"rollup": "^2.68.0",
3334
"tslib": "^2.3.1",
3435
"typescript": "^4.4.4"
3536
},

rollup.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import resolve from '@rollup/plugin-node-resolve';
2+
import commonjs from '@rollup/plugin-commonjs';
23
import typescript from '@rollup/plugin-typescript';
34
import glob from 'glob';
45
import path from 'path';
@@ -56,6 +57,11 @@ const packages = files.map((file) => {
5657
plugins: [
5758
resolve(),
5859
typescript(),
60+
commonjs({
61+
namedExports: {
62+
'react-dom/client': ['createRoot'],
63+
},
64+
}),
5965
wildcardExternalsPlugin(peerDependencies)
6066
],
6167
};

src/Autocomplete/.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/tests export-ignore

src/Autocomplete/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/composer.lock
2+
/phpunit.xml
3+
/vendor/
4+
/var/
5+
/.phpunit.result.cache

src/Autocomplete/.symfony.bundle.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
branches: ["2.x"]
2+
maintained_branches: ["2.x"]
3+
doc_dir: "src/Resources/doc"

src/Autocomplete/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2021 Fabien Potencier
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is furnished
10+
to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

src/Autocomplete/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# UX Autocomplete
2+
3+
Javascript-powered auto-completion functionality for your Symfony forms!
4+
5+
**EXPERIMENTAL** This component is currently experimental and is
6+
likely to change, or even change drastically.
7+
8+
**This repository is a READ-ONLY sub-tree split**. See
9+
https://github.com/symfony/ux to create issues or submit pull requests.
10+
11+
## Resources
12+
13+
- [Documentation](https://symfony.com/bundles/ux-autocomplete/current/index.html)
14+
- [Report issues](https://github.com/symfony/ux/issues) and
15+
[send Pull Requests](https://github.com/symfony/ux/pulls)
16+
in the [main Symfony UX repository](https://github.com/symfony/ux)

src/Autocomplete/assets/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/node_modules/
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import TomSelect from 'tom-select';
3+
4+
/*! *****************************************************************************
5+
Copyright (c) Microsoft Corporation.
6+
7+
Permission to use, copy, modify, and/or distribute this software for any
8+
purpose with or without fee is hereby granted.
9+
10+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
11+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
12+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
13+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
14+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
15+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
16+
PERFORMANCE OF THIS SOFTWARE.
17+
***************************************************************************** */
18+
19+
function __classPrivateFieldGet(receiver, state, kind, f) {
20+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
21+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
22+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
23+
}
24+
25+
var _instances, _getCommonConfig, _createAutocomplete, _createAutocompleteWithHtmlContents, _createAutocompleteWithRemoteData, _stripTags, _mergeObjects, _createTomSelect, _dispatchEvent;
26+
class default_1 extends Controller {
27+
constructor() {
28+
super(...arguments);
29+
_instances.add(this);
30+
}
31+
connect() {
32+
if (this.tomSelect) {
33+
return;
34+
}
35+
if (this.urlValue) {
36+
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue);
37+
return;
38+
}
39+
if (this.optionsAsHtmlValue) {
40+
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithHtmlContents).call(this);
41+
return;
42+
}
43+
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this);
44+
}
45+
get selectElement() {
46+
if (!(this.element instanceof HTMLSelectElement)) {
47+
return null;
48+
}
49+
return this.element;
50+
}
51+
get formElement() {
52+
if (!(this.element instanceof HTMLInputElement) && !(this.element instanceof HTMLSelectElement)) {
53+
throw new Error('Autocomplete Stimulus controller can only be used no an <input> or <select>.');
54+
}
55+
return this.element;
56+
}
57+
}
58+
_instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
59+
const plugins = {};
60+
const isMultiple = !this.selectElement || this.selectElement.multiple;
61+
if (!this.formElement.disabled && !isMultiple) {
62+
plugins.clear_button = { title: '' };
63+
}
64+
if (isMultiple) {
65+
plugins.remove_button = { title: '' };
66+
}
67+
if (this.urlValue) {
68+
plugins.virtual_scroll = {};
69+
}
70+
const config = {
71+
render: {
72+
no_results: () => {
73+
return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
74+
},
75+
},
76+
plugins: plugins,
77+
onItemAdd: () => {
78+
this.tomSelect.setTextboxValue('');
79+
},
80+
closeAfterSelect: true,
81+
};
82+
if (!this.selectElement && !this.urlValue) {
83+
config.shouldLoad = () => false;
84+
}
85+
return __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, config, this.tomSelectOptionsValue);
86+
}, _createAutocomplete = function _createAutocomplete() {
87+
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
88+
maxOptions: this.selectElement ? this.selectElement.options.length : 50,
89+
});
90+
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
91+
}, _createAutocompleteWithHtmlContents = function _createAutocompleteWithHtmlContents() {
92+
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
93+
maxOptions: this.selectElement ? this.selectElement.options.length : 50,
94+
score: (search) => {
95+
const scoringFunction = this.tomSelect.getScoreFunction(search);
96+
return (item) => {
97+
return scoringFunction(Object.assign(Object.assign({}, item), { text: __classPrivateFieldGet(this, _instances, "m", _stripTags).call(this, item.text) }));
98+
};
99+
},
100+
render: {
101+
item: function (item) {
102+
return `<div>${item.text}</div>`;
103+
},
104+
option: function (item) {
105+
return `<div>${item.text}</div>`;
106+
}
107+
},
108+
});
109+
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
110+
}, _createAutocompleteWithRemoteData = function _createAutocompleteWithRemoteData(autocompleteEndpointUrl) {
111+
const config = __classPrivateFieldGet(this, _instances, "m", _mergeObjects).call(this, __classPrivateFieldGet(this, _instances, "m", _getCommonConfig).call(this), {
112+
firstUrl: (query) => {
113+
const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
114+
return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
115+
},
116+
load: function (query, callback) {
117+
const url = this.getUrl(query);
118+
fetch(url)
119+
.then(response => response.json())
120+
.then(json => { this.setNextUrl(query, json.next_page); callback(json.results); })
121+
.catch(() => callback());
122+
},
123+
score: function (search) {
124+
return function (item) {
125+
return 1;
126+
};
127+
},
128+
render: {
129+
option: function (item) {
130+
return `<div>${item.text}</div>`;
131+
},
132+
item: function (item) {
133+
return `<div>${item.text}</div>`;
134+
},
135+
no_more_results: () => {
136+
return `<div class="no-more-results">${this.noMoreResultsTextValue}</div>`;
137+
},
138+
no_results: () => {
139+
return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
140+
},
141+
},
142+
preload: 'focus',
143+
});
144+
return __classPrivateFieldGet(this, _instances, "m", _createTomSelect).call(this, config);
145+
}, _stripTags = function _stripTags(string) {
146+
return string.replace(/(<([^>]+)>)/gi, '');
147+
}, _mergeObjects = function _mergeObjects(object1, object2) {
148+
return Object.assign(Object.assign({}, object1), object2);
149+
}, _createTomSelect = function _createTomSelect(options) {
150+
__classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:pre-connect', { options });
151+
const tomSelect = new TomSelect(this.formElement, options);
152+
__classPrivateFieldGet(this, _instances, "m", _dispatchEvent).call(this, 'autocomplete:connect', { tomSelect, options });
153+
return tomSelect;
154+
}, _dispatchEvent = function _dispatchEvent(name, payload) {
155+
this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
156+
};
157+
default_1.values = {
158+
url: String,
159+
optionsAsHtml: Boolean,
160+
noResultsFoundText: String,
161+
noMoreResultsText: String,
162+
tomSelectOptions: Object,
163+
};
164+
165+
export { default_1 as default };
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const config = require('../../../jest.config.js');
2+
3+
config.setupFilesAfterEnv.push('./test/setup.js');
4+
5+
module.exports = config;

src/Autocomplete/assets/package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@symfony/ux-autocomplete",
3+
"description": "JavaScript-powered autocompletion functionality for forms.",
4+
"main": "dist/controller.js",
5+
"module": "dist/controller.js",
6+
"version": "1.0.0",
7+
"license": "MIT",
8+
"symfony": {
9+
"controllers": {
10+
"autocomplete": {
11+
"main": "dist/controller.js",
12+
"webpackMode": "eager",
13+
"fetch": "eager",
14+
"enabled": true,
15+
"autoimport": {
16+
"tom-select/dist/css/tom-select.default.css": true
17+
}
18+
}
19+
}
20+
},
21+
"peerDependencies": {
22+
"@hotwired/stimulus": "^3.0.0",
23+
"tom-select": "^2.0.1"
24+
},
25+
"devDependencies": {
26+
"@hotwired/stimulus": "^3.0.0",
27+
"fetch-mock-jest": "^1.5.1",
28+
"tom-select": "^2.0.1"
29+
}
30+
}

0 commit comments

Comments
 (0)