Skip to content

Commit 4a086f8

Browse files
author
antoine
committed
wip POC autocomplete endpoint / closure
1 parent 28567cb commit 4a086f8

File tree

8 files changed

+472
-346
lines changed

8 files changed

+472
-346
lines changed

demo/app/Sharp/Posts/PostForm.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ public function buildFormFields(FieldsContainer $formFields): void
138138
->setReadOnly(! auth()->user()->isAdmin())
139139
->setLabel('Author')
140140
->setRemoteEndpoint('/api/admin/users')
141+
// ->setTemplate('<div>{{$name}}</div><div><small>{{$email}}</small></div>')
141142
->setListItemInlineTemplate('<div>{{name}}</div><div><small>{{email}}</small></div>')
142143
->setResultItemInlineTemplate('<div>{{name}}</div><div><small>{{email}}</small></div>')
143144
->setHelpMessage('This field is only editable by admins.'),
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
<script setup lang="ts">
2+
import { __ } from "../../../../js/utils/i18n";
3+
</script>
4+
5+
<template>
6+
<div class="SharpAutocomplete" :class="classes">
7+
<template v-if="ready">
8+
<template v-if="overlayVisible">
9+
<div class="form-control clearable SharpAutocomplete__result">
10+
<TemplateRenderer
11+
name="ResultItem"
12+
:template="resultItemTemplate"
13+
:template-data="resolveTemplateData(value)"
14+
:template-props="searchKeys"
15+
/>
16+
<ClearButton @click="handleClearButtonClicked" />
17+
</div>
18+
</template>
19+
<template v-else>
20+
<Multiselect
21+
:id="id"
22+
class="SharpAutocomplete__multiselect form-control"
23+
:class="{
24+
'form-select': !this.isRemote,
25+
'SharpAutocomplete__multiselect--hide-dropdown': hideDropdown,
26+
}"
27+
:value="value"
28+
:options="suggestions"
29+
:track-by="itemIdAttribute"
30+
:internal-search="false"
31+
:placeholder="placeholder"
32+
:loading="isLoading"
33+
:multiple="multiple"
34+
:disabled="readOnly"
35+
:hide-selected="hideSelected"
36+
:allow-empty="allowEmpty"
37+
:preserve-search="preserveSearch"
38+
:show-pointer="showPointer"
39+
:searchable="searchable"
40+
:readonly="readOnly"
41+
:tabindex="readOnly ? -1 : 0"
42+
@search-change="updateSuggestions($event)"
43+
@select="handleSelect"
44+
@input="$emit('multiselect-input',$event)"
45+
@close="handleDropdownClose"
46+
@open="handleDropdownOpen"
47+
ref="multiselect"
48+
>
49+
<template v-slot:clear>
50+
<template v-if="clearButtonVisible">
51+
<ClearButton @click="handleClearButtonClicked" />
52+
</template>
53+
</template>
54+
<template v-slot:singleLabel="{ option }">
55+
<TemplateRenderer
56+
name="ResultItem"
57+
:template="resultItemTemplate"
58+
:template-data="resolveTemplateData(option)"
59+
:template-props="searchKeys"
60+
/>
61+
</template>
62+
<template v-slot:option="{ option }">
63+
<TemplateRenderer
64+
name="ListItem"
65+
:template="listItemTemplate"
66+
:template-data="resolveTemplateData(option)"
67+
:template-props="searchKeys"
68+
/>
69+
</template>
70+
<template v-slot:loading>
71+
<Loading :visible="isLoading" small />
72+
</template>
73+
<template v-slot:noResult>
74+
{{ __('sharp::form.autocomplete.no_results_text') }}
75+
</template>
76+
</multiselect>
77+
</template>
78+
</template>
79+
</div>
80+
</template>
81+
82+
<script lang="ts">
83+
import debounce from 'lodash/debounce';
84+
// import Multiselect from 'vue-multiselect';
85+
import { CancelToken } from 'axios';
86+
import { warn, logError } from '../../../../js/utils/log';
87+
import { search } from '../../../../js/utils/search';
88+
import { __ } from "../../../../js/utils/i18n";
89+
import { TemplateRenderer } from '../../../../js/components';
90+
import { Loading, ClearButton } from '../../../../js/components/ui';
91+
92+
import { getAutocompleteSuggestions } from "../../../../js/form/api";
93+
import localize from '../../../../js/form/mixins/localize/Autocomplete';
94+
import { setDefaultValue } from "../../../../js/form/util";
95+
import Fuse from "fuse.js";
96+
97+
98+
export default {
99+
name: 'SharpAutocomplete',
100+
components: {
101+
Multiselect: { template: '<div></div>' }, // todo replace this
102+
TemplateRenderer,
103+
Loading,
104+
ClearButton,
105+
},
106+
107+
mixins: [localize],
108+
109+
props: {
110+
id: String,
111+
fieldKey: String,
112+
113+
value: [String, Number, Object, Array],
114+
115+
mode: String,
116+
localValues: {
117+
type: Array,
118+
default:()=>[]
119+
},
120+
placeholder: {
121+
type: String,
122+
default: () => __('form.multiselect.placeholder')
123+
},
124+
remoteEndpoint: String,
125+
remoteMethod: String,
126+
remoteSearchAttribute: {
127+
type: String,
128+
default: 'query'
129+
},
130+
itemIdAttribute: {
131+
type:String,
132+
default: 'id'
133+
},
134+
searchMinChars: {
135+
type: Number,
136+
default: 1
137+
},
138+
searchKeys: {
139+
type: Array,
140+
default:()=>['value']
141+
},
142+
dataWrapper: String,
143+
readOnly: Boolean,
144+
listItemTemplate: String,
145+
resultItemTemplate: String,
146+
templateData: Object,
147+
noResultItem: Boolean,
148+
multiple: Boolean,
149+
hideSelected: Boolean,
150+
searchable: {
151+
type: Boolean,
152+
default: true,
153+
},
154+
allowEmpty: {
155+
type: Boolean,
156+
default: true
157+
},
158+
clearOnSelect: Boolean,
159+
preserveSearch: {
160+
type: Boolean,
161+
default: true
162+
},
163+
showPointer: {
164+
type:Boolean,
165+
default:true
166+
},
167+
dynamicAttributes: Array,
168+
debounceDelay: {
169+
type: Number,
170+
default: 400,
171+
},
172+
nowrap: Boolean,
173+
},
174+
data() {
175+
return {
176+
ready: false,
177+
query: '',
178+
suggestions: this.localValues,
179+
opened: false,
180+
isLoading: false,
181+
}
182+
},
183+
watch: {
184+
localValues() {
185+
if(!this.isRemote) {
186+
this.updateLocalSuggestions(this.query);
187+
}
188+
},
189+
},
190+
computed: {
191+
isRemote() {
192+
return this.mode === 'remote';
193+
},
194+
hideDropdown() {
195+
return this.isQueryTooShort;
196+
},
197+
isQueryTooShort() {
198+
return this.isRemote && this.query.length < this.searchMinChars;
199+
},
200+
clearButtonVisible() {
201+
return !!this.value && !this.opened;
202+
},
203+
classes() {
204+
return {
205+
'SharpAutocomplete--remote': this.isRemote,
206+
'SharpAutocomplete--disabled': this.readOnly,
207+
'SharpAutocomplete--wrap': !this.nowrap,
208+
};
209+
},
210+
overlayVisible() {
211+
const isFormField = !!this.fieldKey;
212+
return this.value && isFormField;
213+
}
214+
},
215+
methods: {
216+
updateSuggestions(query) {
217+
this.query = query;
218+
if(this.isQueryTooShort) {
219+
return;
220+
}
221+
if(this.isRemote) {
222+
this.isLoading = true;
223+
this.updateRemoteSuggestions(query);
224+
}
225+
else {
226+
this.updateLocalSuggestions(query);
227+
}
228+
},
229+
230+
updateLocalSuggestions(query) {
231+
if(query.length >= this.searchMinChars) {
232+
this.suggestions = new Fuse(this.localValues, {
233+
caseSensitive: false,
234+
include: [],
235+
minMatchCharLength: 1,
236+
shouldSort: true,
237+
tokenize: true,
238+
matchAllTokens: false,
239+
findAllMatches: false,
240+
id: null,
241+
keys: ['value'],
242+
location: 0,
243+
threshold: 0.0,
244+
distance: 0,
245+
maxPatternLength: 64,
246+
keys: searchKeys,
247+
})
248+
} else {
249+
this.suggestions = this.localValues;
250+
}
251+
this.suggestions = query.length >= this.searchMinChars
252+
? search(this.localValues, query, { searchKeys: this.searchKeys })
253+
: this.localValues;
254+
},
255+
updateRemoteSuggestions(query) {
256+
this.cancelSource?.cancel();
257+
this.cancelSource = CancelToken.source();
258+
return getAutocompleteSuggestions({
259+
url: this.remoteEndpoint,
260+
method: this.remoteMethod,
261+
locale: this.locale,
262+
searchAttribute: this.remoteSearchAttribute,
263+
dataWrapper: this.dataWrapper,
264+
fieldKey: this.fieldKey,
265+
query,
266+
cancelToken: this.cancelSource.token,
267+
})
268+
.then(suggestions => {
269+
this.suggestions = suggestions;
270+
this.scroll();
271+
})
272+
.finally(() => {
273+
this.isLoading = false;
274+
});
275+
},
276+
scroll() {
277+
// multiselectUpdateScroll(this);
278+
},
279+
handleSelect(value) {
280+
this.$emit('input', value);
281+
},
282+
handleDropdownClose() {
283+
this.opened = false;
284+
this.$emit('close');
285+
},
286+
handleDropdownOpen() {
287+
this.opened = true;
288+
this.$emit('open');
289+
this.scroll();
290+
},
291+
handleClearButtonClicked() {
292+
this.$emit('input', null);
293+
this.$nextTick(() => {
294+
this.$refs.multiselect.activate();
295+
});
296+
},
297+
resolveTemplateData(option) {
298+
return {
299+
...this.templateData,
300+
...this.localizedTemplateData(option),
301+
}
302+
},
303+
itemMatchValue(localValue) {
304+
// noinspection EqualityComparisonWithCoercionJS
305+
return localValue[this.itemIdAttribute] == this.value[this.itemIdAttribute];
306+
},
307+
findLocalValue() {
308+
if(!this.value || this.value[this.itemIdAttribute] == null) return null;
309+
if(!this.localValues.some(this.itemMatchValue)) {
310+
logError(`Autocomplete (key: ${this.fieldKey}) can't find local value matching : ${JSON.stringify(this.value)}`);
311+
return null;
312+
}
313+
return this.localValues.find(this.itemMatchValue);
314+
},
315+
async setDefault() {
316+
this.$emit('input', this.findLocalValue(), { force: true });
317+
await this.$nextTick();
318+
this.ready = true;
319+
}
320+
},
321+
created() {
322+
this.updateRemoteSuggestions = debounce(this.updateRemoteSuggestions, this.debounceDelay);
323+
324+
if(this.mode === 'local' && !this.searchKeys) {
325+
warn(`Autocomplete (key: ${this.fieldKey}) has local mode but no searchKeys, default set to ['value']`);
326+
}
327+
if(this.isRemote) {
328+
this.ready = true;
329+
} else {
330+
setDefaultValue(this, this.setDefault, {
331+
dependantAttributes: ['localValues'],
332+
});
333+
}
334+
}
335+
}
336+
</script>

resources/js/form/components/Field.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
const form = useParentForm();
2525
2626
const components: Record<FormFieldData['type'], Component> = {
27-
// 'autocomplete': Autocomplete,
27+
'autocomplete': Autocomplete,
2828
'check': Check,
2929
// 'date': DateInput,
3030
'editor': Editor,

0 commit comments

Comments
 (0)