- EGroupware
- Getting started
- 1. Extend
- 2. Say what selecting a result means
- 3. Put the two templates in your render()
- Getting results from the server
- Sending extra parameters
- Getting results from a JS method instead
- Searching things you already have on the client
- Changing what counts as a match
- Changing how a result looks
- When the host renders the results itself
- Reference
- Overrides
- State you can read
- Two things that are easy to get wrong
- Legacy responses
- Who uses it
- Used by
- Properties
- Methods
Search Mixin
SearchMixin
Strongly typed mixin for asking the server for values that match a string the user types in and displaying those matches for the user to choose from.
SearchMixin gives a widget “ask for values matching what the user typed, then let them choose
from the results” behaviour. It handles the debounce, running local and remote searches together,
de-duplicating, counting, and keyboard navigation. It renders a search input and a result list for you, but
a host that already has its own can suppress either.
Getting started
1. Extend
export class MySearchingWidget extends SearchMixin(Et2InputWidget(LitElement))
{
// ...
}
2. Say what selecting a result means
The mixin knows a result was picked; only you know what that does to your value. Call
super first, then read this.selectedResults:
protected searchResultSelected()
{
super.searchResultSelected();
this.value = this.selectedResults[0]?.value ?? "";
}
3. Put the two templates in your render()
render()
{
return html`
${this.searchInputTemplate()}
${this.searchResultsTemplate()}
`;
}
That is a working searching widget. Everything below is for when the defaults are not what you want.
Getting results from the server
Point searchUrl at a menuaction. The mixin sends the search string and its options, and expects
{results: [...], total: n} back:
@property() searchUrl : string = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectFiles";
The server side wants to answer with that shape. total is how many exist altogether, not how
many you are returning - the mixin uses the difference to say “42 more…”:
public static function ajax_mySearch($search, $options)
{
$rows = my_search_function($search, $options['num_rows'] ?? 100);
Json\Response::get()->data([
'results' => array_map(static function($row) {
return ['value' => $row['id'], 'label' => $row['name']];
}, $rows),
'total' => my_count_function($search),
// optional - shown to the user, eg. "Access denied"
'message' => null,
]);
}
Api\Etemplate\Widget\Vfs::ajax_vfsSelectFiles() is a real one to copy from.
A result needs value and label. It may also carry title (hover text),
icon, color, class, disabled, and
children for a group - see the SearchResult type.
Sending extra parameters
Whatever is in searchOptions goes along with the request, and can be set from a template:
<et2-select id="folder" searchUrl="app.mail.searchFolder"
searchOptions="{"noPrefixId": "true"}"></et2-select>
If your widget always needs a parameter, put it in _classSearchOptions instead - that way
searchOptions can still override it:
// a suggestion list is not a result list, so ask for fewer than the default 100
protected _classSearchOptions = {num_rows: 10};
Getting results from a JS method instead
searchUrl does not have to be a server endpoint.
An app method, from a template. Give it
"app.<appname>.<method>" and it is resolved through egw().applyFunc(),
which loads that app’s JS object if it is not loaded yet - so this needs no extra code in the app’s
et2_ready():
<et2-select id="folder" searchUrl="app.mail.searchFolder"></et2-select>
// myapp/js/app.ts
async mySearch(search : string, options : any) : Promise<SearchResultsInterface<SearchResult>>
{
const matches = (await this.getThings())
.filter(t => t.name.toLowerCase().includes(search.toLowerCase()));
return {results: matches.map(t => ({value: t.id, label: t.name})), total: matches.length};
}
Mail’s real searchFolder() returns a bare {value, label}[] instead, and works
because it is used on an et2-select, which normalises legacy shapes (see below).
A bare array is tolerated on the plain mixin too - it warns once naming the source, then uses the array as
the results. Do not rely on that. There is no total in a bare array, so “n more…” cannot be
shown, and the tolerance is marked @deprecated to be removed once nothing warns. Return
{results, total}.
A function, for one widget instance. Useful when the results come from something you already have in hand:
const widget = this.et2.getWidgetById("picker");
widget.searchUrl = (search, options) =>
{
const matches = this.alreadyLoadedRows.filter(r => r.name.includes(search));
return Promise.resolve({
results: matches.map(r => ({value: r.id, label: r.name})),
total: matches.length
});
};
Prefer this over overriding remoteSearch() for a single instance - overriding needs a subclass
with its own registered tag.
searchUrl is {attribute: false}, because a function cannot come from an HTML
attribute. Setting it from a template still works: etemplate assigns attributes as properties.
Searching things you already have on the client
Local and remote searches run together, and the results are merged. The mixin cannot guess where your options live, so hand them over:
protected localSearch<DataType extends SearchResult>(search : string, searchOptions : object, localOptions : DataType[] = []) : Promise<DataType[]>
{
return super.localSearch(search, searchOptions, this.select_options);
}
It recurses into children, so option groups work without extra effort.
Changing what counts as a match
searchMatch() decides whether one local option matches. By default it is case-insensitively
contained in label, value or title, in the original or the
translation.
To add a condition, call super and narrow the result:
public searchMatch<FileInfo>(search : string, searchOptions : Object, option : FileInfo) : boolean
{
let result = super.searchMatch(search, searchOptions, option);
// also has to be the right mime type
if(result && searchOptions.mime)
{
result = result && option.mime.match(searchOptions.mime);
}
return result;
}
To replace the rule entirely, do not call super:
// only match from the start, not anywhere in the label
public searchMatch(search : string, searchOptions : Object, option) : boolean
{
return option.label?.toLowerCase().startsWith(search.toLowerCase()) ?? false;
}
Note this only affects local options. The server decides what matches remotely - if you need different remote matching, that belongs in the endpoint.
Changing how a result looks
Override resultTemplate() for the whole row, or iconTemplate() for just the icon:
protected resultTemplate(result : FileInfo, index : number) : TemplateResult
{
return html`
<et2-vfs-select-row .value=${result} ?disabled=${result.disabled}></et2-vfs-select-row>`;
}
Whatever you render should behave like a SearchResultElement - a value, and
selected / current / disabled the mixin can set - so its keyboard
navigation and selection still work.
When the host renders the results itself
If your widget already has somewhere to show results, suppress the mixin’s list and use your own.
Et2Select does this because sl-select owns an option list already, so a second one
would be a duplicate:
protected searchResultsTemplate()
{
return nothing;
}
Et2Email goes further and suppresses both templates, because its input is part of its combobox.
Its results are in this._searchResults for its own render to use.
Reference
Overrides
| Override | When |
|---|---|
searchResultSelected() |
always - only you know what a selection does to your value |
localSearch(search, options, localOptions) |
you have options on the client to search |
searchMatch(search, options, option) |
a local option matches on different terms |
remoteSearch(search, options) |
results come from somewhere none of the searchUrl forms covers |
processRemoteResults(results) |
the response needs converting first |
resultTemplate(result, index) / iconTemplate(option) |
a result should look different |
searchInputTemplate() / searchResultsTemplate() |
the host renders these itself |
_classSearchOptions |
request defaults searchOptions should still override |
static SEARCH_TIMEOUT |
a different debounce (default 500ms) |
State you can read
searching |
a search is in flight |
resultsOpen |
the result list is showing |
hasFocus |
the widget has focus |
_searchResults |
the results, after merging and de-duplication |
_totalResults |
how many the server says exist |
selectedResults |
the result elements the user has chosen |
currentResult |
the one the keyboard is on |
Listen for the et2-select event on the widget to react to selection from outside, rather than
only inside searchResultSelected().
Two things that are easy to get wrong
-
getValueAsArray()defers to the host. If the superclass has its own, that one wins - the mixin only supplies a fallback.Et2WidgetWithSelectMixin’s version keeps""when there is anemptyLabel, and shadowing it silently drops the empty option. -
_totalResultsis the server’s count, not yours. “n more” subtracts_searchResults.length, not however many rows are on screen - counting rendered rows includes your own local matches, which were never part of the server’s total, and under-reports.
Legacy responses
The mixin expects {results: [...], total: n}. Several older EGroupware endpoints send a bare
array, or an object with total mixed in among the results.
Et2Select/legacySearchResults.ts converts those and warns once per searchUrl when
it has to, so the console shows which endpoints still need updating. Reuse it if you point a widget at an
older endpoint; a new one should just send the right shape.
Who uses it
-
Et2TreeDropdown- the straightforward case, renders both templates as they come. -
Et2VfsSelectDialog- its own result rows viaresultTemplate(), and extra mime matching insearchMatch(). -
Et2Select, throughSelectSearchMixin- suppressessearchResultsTemplate()and feedsselect_optionsinstead, and adds a.jsonsearchUrlform for static option files. -
Et2Email- no local options at all, suppresses both templates, and opens its ownsl-popuponce the search lands.
Used by
This mixin is applied by the following widgets.
Properties
| Name | Description | Type | Default |
|---|---|---|---|
resultsOpen |
Indicates whether the search results are open. You can toggle this attribute to show and hide the results list. |
boolean
|
false
|
search |
Enable or disable searching |
boolean
|
true
|
SEARCH_TIMEOUT |
How long to wait after the user stops typing before searching, in ms. Override in a subclass to change the debounce. |
number
|
500
|
searchOptions |
Additional search parameters that are passed to the server when we query searchUrl |
object
|
{}
|
searchUrl |
Where search results come from, instead of just searching in the browser. Either: - a menuaction string, requested from the server - an “app.appname.method” string, resolved via egw().applyFunc() - the same convention onExecute=“javaScript:app.X.Y” uses, minus the prefix. applyFunc() lazy-loads the app’s JS object, so this can be wired straight into a template with no extra app code. - a JS callback returning a Promise of results, for per-instance sourcing without a subclass (overriding remoteSearch() would need one, or patching the instance) {attribute: false} because a function cannot come from an HTML attribute. Template attributes still work: etemplate assigns them as properties (Et2Widget.transformAttributes). |
string | ((search : string, options : object) => Promise
|
""
|
Methods
| Name | Description | Arguments |
|---|---|---|
getValueAsArray()
|
The value as an array, whatever shape it is stored in. A host that has its own idea of this wins: Et2WidgetWithSelectMixin’s version keeps an empty string when there is an emptyLabel, and silently shadowing it drops the empty option from the rendered list. Only fall back to our own when the host has none. | - |
searchMatch()
|
Check if one of our [local] items matches the search |
search: string, searchOptions: Object, option: DataType
|
startSearch()
|
Start searching for results matching what has been typed | - |