From b0e8666ecbeecbd693cea9e3ce8d29379ae8070a Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 22 Feb 2024 14:33:22 -0700 Subject: [PATCH] Implement searching in Et2TreeDropdown --- .../Et2Tree/Et2TreeDropdown.styles.ts | 12 ++ api/js/etemplate/Et2Tree/Et2TreeDropdown.ts | 135 +++++++++--------- .../Et2Tree/Et2TreeDropdownCategory.ts | 50 +++++++ api/src/Etemplate/Widget/Taglist.php | 24 ++++ api/src/Etemplate/Widget/Tree.php | 51 +++---- 5 files changed, 181 insertions(+), 91 deletions(-) diff --git a/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts b/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts index 63fbe01926..2f39b40619 100644 --- a/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts +++ b/api/js/etemplate/Et2Tree/Et2TreeDropdown.styles.ts @@ -146,6 +146,14 @@ export default css` order: 20; } + .search__results { + display: none; + } + + .tree-dropdown--searching .search__results { + display: initial;; + } + /* Tree */ sl-popup::part(popup) { @@ -170,4 +178,8 @@ export default css` et2-tree::part(checkbox) { display: none; } + + .tree-dropdown--searching et2-tree { + display: none; + } `; \ No newline at end of file diff --git a/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts b/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts index e296b83cc0..15966f101f 100644 --- a/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts +++ b/api/js/etemplate/Et2Tree/Et2TreeDropdown.ts @@ -11,6 +11,15 @@ import {SlPopup, SlRemoveEvent} from "@shoelace-style/shoelace"; import shoelace from "../Styles/shoelace"; import styles from "./Et2TreeDropdown.styles"; import {Et2Tag} from "../Et2Select/Tag/Et2Tag"; +import {SearchMixin, SearchResult, SearchResultsInterface} from "../Et2Widget/SearchMixin"; +import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget"; + + +interface TreeSearchResult extends SearchResultsInterface +{ +} + +type Constructor = new (...args : any[]) => T; /** * @summary A tree that is hidden in a dropdown @@ -34,14 +43,14 @@ import {Et2Tag} from "../Et2Select/Tag/Et2Tag"; * @csspart form-control - The form control that wraps the label, input, and help text. */ -export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) +export class Et2TreeDropdown extends SearchMixin & Et2InputWidgetInterface & typeof LitElement, SearchResult, TreeSearchResult>(Et2WidgetWithSelectMixin(LitElement)) { static get styles() { return [ shoelace, - ...super.styles, + super.styles, styles ]; } @@ -60,23 +69,19 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) */ @property({type: Boolean, reflect: true}) open = false; - @state() searching = false; - @state() hasFocus = false; @state() currentTag : Et2Tag; + // We show search results in the same dropdown + @state() treeOrSearch : "tree" | "search" = "tree"; + private get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup")} private get _tree() : Et2Tree { return this.shadowRoot.querySelector("et2-tree")} - private get _search() : HTMLInputElement { return this.shadowRoot.querySelector("#search")} - private get _tags() : Et2Tag[] { return Array.from(this.shadowRoot.querySelectorAll("et2-tag"));} - protected readonly hasSlotController = new HasSlotController(this, "help-text", "label"); + protected readonly hasSlotController = new HasSlotController(this, "help-text", "label"); private __value : string[]; - protected _searchTimeout : number; - protected _searchPromise : Promise = Promise.resolve([]); - constructor() { super(); @@ -93,8 +98,8 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) new_value = new_value.split(",") } const oldValue = this.__value; - // Filter to make sure there are no trailing commas - this.__value = new_value.filter(v => v); + // Filter to make sure there are no trailing commas or duplicates + this.__value = Array.from(new Set(new_value.filter(v => v))); this.requestUpdate("value", oldValue); } @@ -112,9 +117,9 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) // Should not be needed, but not firing the update this.requestUpdate("hasFocus"); - if(this._search) + if(this._searchNode) { - this._search.focus(options); + this._searchNode.focus(options); } } @@ -122,12 +127,13 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) blur() { this.open = false; + this.treeOrSearch = "tree"; this.hasFocus = false; this._popup.active = false; // Should not be needed, but not firing the update this.requestUpdate("open"); this.requestUpdate("hasFocus"); - this._search.blur(); + this._searchNode.blur(); clearTimeout(this._searchTimeout); } @@ -158,6 +164,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) this.open = false; this._popup.active = false; + this._searchNode.value = ""; this.requestUpdate("open"); return this.updateComplete } @@ -183,6 +190,37 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) } } + + startSearch() + { + super.startSearch(); + + // Show the dropdown, that's where the results will go + this.show(); + + // Hide the tree + this.treeOrSearch = "search"; + } + + protected searchResultSelected() + { + super.searchResultSelected(); + + if(this.multiple && typeof this.value !== "undefined") + { + // Add in the new result(s) + (this.value).splice(this.value.length, 0, ...this.selectedResults.map(el => el.value)); + } + else if(typeof this.value !== "undefined") + { + // Just replace our value with whatever they chose + this.value = this.selectedResults[0]?.value ?? ""; + } + + // Done with search, show the tree + this.treeOrSearch = "tree"; + } + /** * Keyboard events that the search input did not grab * (tags, otion navigation) @@ -219,7 +257,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) else { // Arrow back to search, or got lost - this._search.focus(); + this._searchNode.focus(); } event.stopPropagation(); return false; @@ -237,7 +275,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) } else { - this._search.focus(); + this._searchNode.focus(); } } } @@ -247,20 +285,20 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) this.hasFocus = true; // Should not be needed, but not firing the update this.requestUpdate("hasFocus"); - this.show(); + this.hide(); // Reset tags to not take focus this.setCurrentTag(null); - this._search.setSelectionRange(this._search.value.length, this._search.value.length); + this._searchNode.setSelectionRange(this._searchNode.value.length, this._searchNode.value.length); } handleSearchKeyDown(event) { - clearTimeout(this._searchTimeout); + super.handleSearchKeyDown(event); // Left at beginning goes to tags - if(this._search.selectionStart == 0 && event.key == "ArrowLeft") + if(this._searchNode.selectionStart == 0 && event.key == "ArrowLeft") { this.hide(); this._tags.forEach(t => t.tabIndex = 0); @@ -271,49 +309,12 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) event.stopPropagation(); return; } - // Tab on empty leaves - if(this._search.value == "" && event.key == "Tab") - { - // Propagate, browser will do its thing - return; - } - // Up / Down navigates options - if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._tree) - { - if(!this.open) - { - this.show(); - } - event.stopPropagation(); - this._tree.focus(); - return; - } - // Start search immediately - else if(event.key == "Enter") - { - event.preventDefault(); - this.startSearch(); - return; - } - else if(event.key == "Escape") - { - this.hide(); - event.stopPropagation(); - return; - } - - // Start the search automatically if they have enough letters - // -1 because we're in keyDown handler, and value is from _before_ this key was pressed - if(this._search.value.length - 1 > 0) - { - this._searchTimeout = window.setTimeout(() => {this.startSearch()}, 500); - } } protected handleLabelClick() { - this._search.focus(); + this._searchNode.focus(); } handleTagRemove(event : SlRemoveEvent, value : string) @@ -380,12 +381,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) if(this.open) { this._popup.active = false; + this._searchNode.value = ""; } else { this._popup.active = true; } this.open = this._popup.active; + this.treeOrSearch = "tree"; this.requestUpdate("open"); this.updateComplete.then(() => { @@ -407,16 +410,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) } return html` - ` + ` } inputTemplate() { return html` - ${this.searching ? html` - ` : nothing - } + ${this.searchResultsTemplate()} []]; + $start = 0; + $categories = new Api\Categories('', $options['application'] ?: ''); + foreach($categories->return_sorted_array($start, (int)$options['num_rows'], $search, 'ASC', 'cat_name', (boolean)$options['globalCategories'], false) as $cat) + { + $results['results'][] = Tree::formatCategory($cat, $categories); + } + $results['total'] = $categories->total_records; + + // switch regular JSON response handling off + Api\Json\Request::isJSONRequest(false); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($results); + exit; + } + /** * Validate input * diff --git a/api/src/Etemplate/Widget/Tree.php b/api/src/Etemplate/Widget/Tree.php index f0943c66e7..1df5766eb2 100644 --- a/api/src/Etemplate/Widget/Tree.php +++ b/api/src/Etemplate/Widget/Tree.php @@ -519,28 +519,7 @@ class Tree extends Etemplate\Widget { foreach((array)$categories->return_array($cat_id ? 'subs' : 'mains', 0, false, '', 'ASC', 'name', $globals, $cat_id) as $cat) { - $s = stripslashes($cat['name']); - - if($cat['app_name'] == 'phpgw' || $cat['owner'] == '-1') - { - $s .= ' ♦'; - } - - // 1D array - $category = $cat + array( - 'text' => $s, - 'path' => $categories->id2name($cat['id'], 'path'), - - /* - These ones to play nice when a user puts a tree & a selectbox with the same - ID on the form (addressbook edit): - if tree overwrites selectbox options, selectbox will still work - */ - 'value' => $cat['id'], - 'label' => $s, - 'icon' => $cat['data']['icon'] ?? '', - 'title' => $cat['description'] - ); + $category = static::formatCategory($cat, $categories); $cat_id_list[] = $cat['id']; if(!empty($cat['children'])) { @@ -552,8 +531,32 @@ class Tree extends Etemplate\Widget } } - public static function ajaxSearch($search, $options) - { + public static function formatCategory($cat, &$categories_object) + { + $s = stripslashes($cat['name']); + + if($cat['app_name'] == 'phpgw' || $cat['owner'] == '-1') + { + $s .= ' ♦'; + } + + // 1D array + $category = $cat + array( + // Legacy + 'text' => $s, + 'path' => $categories_object->id2name($cat['id'], 'path'), + + //Client side search interface + 'value' => $cat['id'], + 'label' => $s, + 'icon' => $cat['data']['icon'] ?? '', + 'title' => $cat['description'] + ); + if(!empty($cat['children'])) + { + $category['hasChildren'] = true; + } + return $category; } } \ No newline at end of file