From f0be2fcdcab9ef155a7ca5c6beb2820bbd2250df Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 21 Jul 2023 16:39:47 -0600 Subject: [PATCH] WIP on caching static option file and searching it client-side Still needs file caching & passing correct URL --- .../etemplate/Et2Select/Et2SelectReadonly.ts | 23 ++++++- api/js/etemplate/Et2Select/SearchMixin.ts | 68 ++++++++++++++++++- api/js/etemplate/Et2Select/StaticOptions.ts | 32 ++++++++- .../etemplate/et2_extension_customfields.ts | 8 ++- api/js/etemplate/et2_extension_nextmatch.ts | 15 ++-- api/src/Etemplate/Widget/Customfields.php | 67 ++++++++++++++---- api/src/Storage/Customfields.php | 25 ++++--- 7 files changed, 205 insertions(+), 33 deletions(-) diff --git a/api/js/etemplate/Et2Select/Et2SelectReadonly.ts b/api/js/etemplate/Et2Select/Et2SelectReadonly.ts index 84a9051be1..79323edd18 100644 --- a/api/js/etemplate/Et2Select/Et2SelectReadonly.ts +++ b/api/js/etemplate/Et2Select/Et2SelectReadonly.ts @@ -11,7 +11,7 @@ import {css, html, LitElement, repeat, TemplateResult} from "@lion/core"; import {et2_IDetachedDOM} from "../et2_core_interfaces"; import {Et2Widget} from "../Et2Widget/Et2Widget"; -import {StaticOptions as so} from "./StaticOptions"; +import {StaticOptions, StaticOptions as so} from "./StaticOptions"; import {find_select_options, SelectOption} from "./FindSelectOptions"; import {SelectAccountMixin} from "./SelectAccountMixin"; @@ -52,6 +52,7 @@ li { private __select_options : SelectOption[]; private __value : string[]; + private __fetchComplete : Promise = null; constructor() { @@ -61,6 +62,16 @@ li { this.__value = []; } + public async getUpdateComplete() + { + const result = await super.getUpdateComplete(); + if(this.__fetchComplete) + { + await this.__fetchComplete; + } + return result; + } + protected find_select_options(_attrs) { let sel_options = find_select_options(this, _attrs['select_options']); @@ -68,6 +79,16 @@ li { { this.select_options = sel_options; } + + // Cache options from file + if(_attrs.searchUrl && _attrs.searchUrl.includes(".json") && this.__fetchComplete == null) + { + this.__fetchComplete = StaticOptions.cached_from_file(this, _attrs.searchUrl).then(options => + { + this.select_options = options; + this.requestUpdate(); + }); + } } transformAttributes(_attrs) diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts index 70629d0583..7b359b4d78 100644 --- a/api/js/etemplate/Et2Select/SearchMixin.ts +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -14,6 +14,7 @@ import {Validator} from "@lion/form-core"; import {Et2Tag} from "./Tag/Et2Tag"; import {SlMenuItem} from "@shoelace-style/shoelace"; import {waitForEvent} from "@shoelace-style/shoelace/dist/internal/event"; +import {StaticOptions} from "./StaticOptions"; // Otherwise import gets stripped let keep_import : Et2Tag; @@ -581,6 +582,26 @@ export const Et2WithSearchMixin = >(superclass } }); } + if(this.searchEnabled) + { + for(const newValueElement of this.getValueAsArray()) + { + if(this.select_options.some(o => o.value == newValueElement)) + { + continue; + } + + // Given a value we need to search for - this will add in all matches, including the one needed + this.remoteSearch(newValueElement, this.searchOptions).then((result : SelectOption[]) => + { + const option = result.find(o => o.value == newValueElement); + if(option) + { + this._selected_remote.push(option); + } + }); + } + } } protected fix_bad_value() @@ -1124,12 +1145,54 @@ export const Et2WithSearchMixin = >(superclass return Promise.resolve(); } - // Fire off the query - let promise = this.remoteQuery(search, options); + // Check our URL: JSON file or URL? + if(this.searchUrl.includes(".json")) + { + // Get the file, search it + return this.jsonQuery(search, options); + } + else + { + // Fire off the query to the server + let promise = this.remoteQuery(search, options); + } return promise; } + /** + * Search through a JSON file in the browser + * + * @param {string} search + * @param {object} options + * @protected + */ + protected jsonQuery(search : string, options : object) + { + // Get the file + const controller = new AbortController(); + const signal = controller.signal; + let response_ok = false; + return StaticOptions.cached_from_file(this, this.searchUrl) + .then(options => + { + // Filter the options + const lower_search = search.toLowerCase(); + const filtered = options.filter(option => + { + return option.label.toLowerCase().includes(lower_search) || option.value.includes(search) + }); + // Add the matches + this.processRemoteResults(filtered); + return filtered; + }) + .catch((_err) => + { + this.egw().message(_err.statusText || this.searchUrl, "error"); + return []; + }); + } + /** * Actually query the server. * @@ -1157,6 +1220,7 @@ export const Et2WithSearchMixin = >(superclass {query: search, ...sendOptions}), [search, sendOptions]).then((result) => { this.processRemoteResults(result); + return result; }); } diff --git a/api/js/etemplate/Et2Select/StaticOptions.ts b/api/js/etemplate/Et2Select/StaticOptions.ts index 57e430950c..bf6fa218f2 100644 --- a/api/js/etemplate/Et2Select/StaticOptions.ts +++ b/api/js/etemplate/Et2Select/StaticOptions.ts @@ -9,7 +9,7 @@ */ import {sprintf} from "../../egw_action/egw_action_common"; import {Et2SelectReadonly} from "./Et2SelectReadonly"; -import {find_select_options, SelectOption} from "./FindSelectOptions"; +import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions"; import {Et2Select, Et2SelectNumber, Et2WidgetWithSelect} from "./Et2Select"; export type Et2SelectWidgets = Et2Select | Et2WidgetWithSelect | Et2SelectReadonly; @@ -198,6 +198,36 @@ export const StaticOptions = new class StaticOptionsType } } + cached_from_file(widget, file) : Promise + { + const cache_owner = widget.egw().getCache('Et2Select'); + let cache = cache_owner[file]; + if(typeof cache === 'undefined') + { + cache_owner[file] = cache = widget.egw().window.fetch(file) + .then((response) => + { + // Get the options + if(!response.ok) + { + throw response; + } + return response.json(); + }) + .then(options => + { + // Need to clean the options because file may be key=>value, may have option list, may be mixed + cache_owner[file] = cleanSelectOptions(options) ?? []; + return cache_owner[file]; + }); + } + else if(cache && typeof cache.then === "undefined") + { + return Promise.resolve(cache); + } + return cache; + } + priority(widget : Et2SelectWidgets) : SelectOption[] { return [ diff --git a/api/js/etemplate/et2_extension_customfields.ts b/api/js/etemplate/et2_extension_customfields.ts index 825c47e150..3aef1872af 100644 --- a/api/js/etemplate/et2_extension_customfields.ts +++ b/api/js/etemplate/et2_extension_customfields.ts @@ -614,9 +614,11 @@ export class et2_customfields_list extends et2_valueWidget implements et2_IDetac { attrs.multiple = true; } - // select_options are now send from server-side incl. ones defined via a file in EGroupware root - attrs.tags = field.tags; - + if(field.values && field.values["@"]) + { + // Options are in a list stored in a file + attrs.searchUrl = this.egw().webserverUrl + '/webdav.php' + field.values["@"]; + } return true; } _setup_select_account( field_name, field, attrs) diff --git a/api/js/etemplate/et2_extension_nextmatch.ts b/api/js/etemplate/et2_extension_nextmatch.ts index 5d0a7dc4e7..c9b658b4ac 100644 --- a/api/js/etemplate/et2_extension_nextmatch.ts +++ b/api/js/etemplate/et2_extension_nextmatch.ts @@ -4190,12 +4190,17 @@ export class et2_nextmatch_customfields extends et2_customfields_list implements { field.values.splice(field.values.findIndex((i) => i.value == ''), 1); } + let attrs = { + id: cf_id, + emptyLabel: field.label + }; + if(field.values["@"]) + { + attrs.searchUrl = this.egw().webserverUrl + '/webdav.php' + field.values["@"]; + } widget = loadWebComponent( field.type == 'select-account' ? 'et2-nextmatch-header-account' : "et2-nextmatch-header-filter", - { - id: cf_id, - empty_label: field.label - }, + attrs, this ); } @@ -4203,7 +4208,7 @@ export class et2_nextmatch_customfields extends et2_customfields_list implements { widget = loadWebComponent("et2-nextmatch-header-entry", { id: cf_id, - only_app: field.type, + onlyApp: field.type, placeholder: field.label }, this); } diff --git a/api/src/Etemplate/Widget/Customfields.php b/api/src/Etemplate/Widget/Customfields.php index 760f23a8b2..6a2e48b4a7 100644 --- a/api/src/Etemplate/Widget/Customfields.php +++ b/api/src/Etemplate/Widget/Customfields.php @@ -244,6 +244,11 @@ class Customfields extends Transformer { if (!empty($data['values'])) { + // Full URL for options from file + if(!empty($data['values']['@'])) + { + $fields[$data['name']]['values']['@'] = Api\Framework::link(Api\Vfs::download_url($data['values']['@'])); + } Select::fix_encoded_options($data['values']); } } @@ -283,11 +288,14 @@ class Customfields extends Transformer protected function _widget($fname, array $field) { static $link_types = null; - if (!isset($link_types)) $link_types = Api\Link::app_list (); + if(!isset($link_types)) + { + $link_types = Api\Link::app_list(); + } $type = $field['type']; // Link-tos needs to change from appname to link-to - if (!empty($link_types[$field['type']])) + if(!empty($link_types[$field['type']])) { if($type == 'filemanager') { @@ -355,21 +363,30 @@ class Customfields extends Transformer case 'radio': if (!empty($field['values']) && count($field['values']) == 1 && isset($field['values']['@'])) { - $field['values'] = Api\Storage\Customfields::get_options_from_file($field['values']['@']); + if(substr($type, 0, 7) !== 'select') + { + // Other widgets need the options, and select needs them for validation + $field['values'] = Api\Storage\Customfields::get_options_from_file($field['values']['@']);; + } + else + { + // Pass on no options, we do it directly through the file + $field['values'] = []; + } } // keep extra values set by app code, eg. addressbook advanced search - if (!empty(self::$request->sel_options[self::$prefix.$fname]) && is_array(self::$request->sel_options[self::$prefix.$fname])) + if(!empty(self::$request->sel_options[self::$prefix . $fname]) && is_array(self::$request->sel_options[self::$prefix . $fname])) { - self::$request->sel_options[self::$prefix.$fname] += (array)$field['values']; + self::$request->sel_options[self::$prefix . $fname] += (array)$field['values']; } else { - self::$request->sel_options[self::$prefix.$fname] = $field['values']; + self::$request->sel_options[self::$prefix . $fname] = $field['values']; } //error_log(__METHOD__."('$fname', ".array2string($field).") request->sel_options['".self::$prefix.$fname."']=".array2string(self::$request->sel_options[$this->id])); // to keep order of numeric values, we have to explicit run fix_encoded_options, as sel_options are already encoded - $options = self::$request->sel_options[self::$prefix.$fname]; - if (is_array($options)) + $options = self::$request->sel_options[self::$prefix . $fname]; + if(is_array($options)) { Select::fix_encoded_options($options); self::$request->sel_options[self::$prefix . $fname] = $options; @@ -471,18 +488,44 @@ class Customfields extends Transformer // run validation method of widget implementing this custom field $widget = $this->_widget($fname, $field_settings); // widget has no validate method, eg. is only displaying stuff --> nothing to validate - if (!method_exists($widget, 'validate')) continue; + if(!method_exists($widget, 'validate')) + { + continue; + } + + // Selects only - do a local search through the file in the browser instead of sending all the options + // Here we need the options to validate + if($widget instanceof Select && !empty($field_settings['values']['@'])) + { + $options = Api\Storage\Customfields::get_options_from_file($field_settings['values']['@']); + // keep extra values set by app code, eg. addressbook advanced search + if(!empty(self::$request->sel_options[self::$prefix . $fname]) && is_array(self::$request->sel_options[self::$prefix . $fname])) + { + self::$request->sel_options[$widget->id] += (array)$options; + } + else + { + self::$request->sel_options[$widget->id] = $options; + } + } + $widget->validate($form_name != self::GLOBAL_ID ? $form_name : $cname, $expand, $content, $validated); - $field_name = $this->id[0] == self::$prefix && $customfields[substr($this->id,strlen($this->attrs['prefix']))] ? $this->id : self::form_name($form_name != self::GLOBAL_ID ? $form_name : $cname, $field); + $field_name = $this->id[0] == self::$prefix && $customfields[substr($this->id, strlen($this->attrs['prefix']))] ? $this->id : self::form_name($form_name != self::GLOBAL_ID ? $form_name : $cname, $field); $valid =& self::get_array($validated, $field_name, true); // Arrays are not valid, but leave filemanager alone, we'll catch it // when saving. This allows files for new entries. - if (is_array($valid) && $field_settings['type'] !== 'filemanager') $valid = implode(',', $valid); + if(is_array($valid) && $field_settings['type'] !== 'filemanager') + { + $valid = implode(',', $valid); + } // NULL is valid for most fields, but not custom fields due to backend handling // See so_sql_cf->save() - if (is_null($valid)) $valid = false; + if(is_null($valid)) + { + $valid = false; + } //error_log(__METHOD__."() $field_name: ".array2string($value).' --> '.array2string($valid)); } } diff --git a/api/src/Storage/Customfields.php b/api/src/Storage/Customfields.php index ad56538a41..27eb1c2786 100644 --- a/api/src/Storage/Customfields.php +++ b/api/src/Storage/Customfields.php @@ -272,16 +272,23 @@ class Customfields implements \IteratorAggregate /** * Read the options of a 'select' or 'radio' custom field from a file * - * For security reasons it has to be a php file setting one variable called options, - * (to not display it to anonymously by the webserver). - * The $options var has to be an array with value => label pairs, eg: + * For security reasons it has to be a json file containing an array of options + * The contents of the file has to be either an object with value:label pairs, eg: * - * 'Option A', - * 'b' => 'Option B', - * 'c' => 'Option C', - * ); + * { + * 'a': 'Option A', + * 'b': 'Option B', + * 'c': 'Option C', + * } + * + * or an array of options with at least value and label, eg: + * [ + * {value: 'a', label: 'Option A'}, + * {value: 'b', label: 'Option B'}, + * {value: 'c', label: 'Option C'} + * ] + * See SelectOption type in api/js/etemplate/Et2Select/FindSelectOption.ts for full options. + * Of the two, the array is preferred over value:label pairs for performance reasons. * * @param string $file file name inside the eGW server root, either relative to it or absolute * @return array in case of an error we return a single option with the message