diff --git a/api/js/etemplate/Et2Email/Et2Email.ts b/api/js/etemplate/Et2Email/Et2Email.ts index 6349102572..9863404908 100644 --- a/api/js/etemplate/Et2Email/Et2Email.ts +++ b/api/js/etemplate/Et2Email/Et2Email.ts @@ -30,7 +30,6 @@ import Sortable from "sortablejs/modular/sortable.complete.esm.js"; /** * @summary Enter email addresses, offering suggestions from contacts - * @documentation https://shoelace.style/components/select * @since 23.1 * * @dependency sl-icon @@ -68,6 +67,7 @@ import Sortable from "sortablejs/modular/sortable.complete.esm.js"; */ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinInterface { + // Solves some issues with focus static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true}; static get styles() @@ -495,6 +495,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI this._search.blur(); clearTimeout(this._searchTimeout); + l } diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts index 89d0e3b05f..5b4e5cdbbe 100644 --- a/api/js/etemplate/Et2Select/SearchMixin.ts +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -56,13 +56,13 @@ export declare class SearchMixinInterface /** * Search local options */ - localSearch(search : string, options : object) : Promise + localSearch(search : string, options : object) : Promise /** * Search remote options. * If searchUrl is not set, it will return very quickly with no results */ - remoteSearch(search : string, options : object) : Promise + remoteSearch(search : string, options : object) : Promise /** * Check a [local] item to see if it matches diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts new file mode 100644 index 0000000000..bff37ad8d4 --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts @@ -0,0 +1,32 @@ +import {css} from 'lit'; + +export default css` + :host { + flex: 0 0; + } + + et2-dialog::part(body) { + display: flex; + flex-direction: column; + } + + .vfs_select__listbox { + flex: 1 1 auto; + min-height: 15em; + overflow-y: auto; + } + + .vfs_select__listbox .vfs_select__empty { + height: 100%; + min-height: 5em; + display: flex; + flex-direction: column; + align-items: center; + filter: contrast(0.1); + user-select: none; + } + + .vfs_select__listbox .vfs_select__empty et2-image { + margin-top: auto; + } +`; \ No newline at end of file diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts new file mode 100644 index 0000000000..a8de435edc --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts @@ -0,0 +1,576 @@ +/** + * EGroupware eTemplate2 - File selection WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import {html, LitElement, nothing, TemplateResult} from "lit"; +import shoelace from "../Styles/shoelace"; +import styles from "./Et2VfsSelect.styles"; +import {property} from "lit/decorators/property.js"; +import {state} from "lit/decorators/state.js"; +import {ifDefined} from "lit/directives/if-defined.js"; +import {repeat} from "lit/directives/repeat.js"; +import {until} from "lit/directives/until.js"; +import {SearchMixinInterface} from "../Et2Select/SearchMixin"; +import {SelectOption} from "../Et2Select/FindSelectOptions"; +import {DialogButton, Et2Dialog} from "../Et2Dialog/Et2Dialog"; +import {HasSlotController} from "../Et2Widget/slot"; +import {IegwAppLocal} from "../../jsapi/egw_global"; +import {Et2Select} from "../Et2Select/Et2Select"; + +/** + * @summary Select files (including directories) from the VFS + * @since 23.1 + * + * @dependency et2-dialog + * @dependency et2-select + * + * @slot title - Optional additions to title. Works best with `et2-button-icon`. + * @slot toolbar - Toolbar containing controls for search & navigation + * @slot prefix - Before the toolbar + * @slot suffix - Like prefix, but after + * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. + * @slot footer - Customise the dialog footer + * + * @event change - Emitted when the control's value changes. + * + * @csspart form-control-input - The textbox's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + * @csspart prefix - The container that wraps the prefix slot. + * @csspart suffix - The container that wraps the suffix slot. + * + */ + +export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMixinInterface +{ + static get styles() + { + return [ + shoelace, + ...super.styles, + styles + ]; + } + + /** Currently selected files */ + @property() value : string[] = []; + + /** + * The dialog’s label as displayed in the header. + * You should always include a relevant label, as it is required for proper accessibility. + */ + @property() title : string = "Select"; + + /** + * Dialog mode + * Quickly sets button label, multiple, selection and for "select-dir", mime-type + **/ + @property() mode : "open" | "open-multiple" | "saveas" | "select-dir" = "open"; + + /** Button label */ + @property() buttonLabel : string = "Select"; + + /** Provide a suggested filename for saving */ + @property() filename : string = ""; + + /** Allow selecting multiple files */ + @property({type: Boolean}) multiple = false; + + /** Start path in VFS. Leave unset to use the last used path. */ + @property() path : string = ""; + + /** Limit display to the given mime-type */ + @property() mime : string | string[] | RegExp = ""; + + /** List of mimetypes to allow user to filter. */ + @property() mimeList : SelectOption[] = []; + + /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({attribute: 'help-text'}) helpText = ''; + + @state() searching = false; + @state() open : boolean = false; + @state() currentFile; + + // SearchMixinInterface // + @property() searchUrl : string = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectFiles"; + + /** Additional options passed to server search */ + @property({type: Object}) searchOptions : object = {}; + + search : boolean = true; + allowFreeEntries : boolean = false; + + // End SearchMixinInterface // + protected _searchTimeout : number; + protected _searchPromise : Promise = Promise.resolve([]); + private static SEARCH_TIMEOUT : number = 500; + + // Still need some server-side info + protected _serverContent : Promise = Promise.resolve({}); + private static SERVER_URL = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectContent"; + + protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'toolbar', 'footer'); + + protected _fileList = []; + + // Internal accessors + get _dialog() : Et2Dialog { return this.shadowRoot.querySelector("et2-dialog");} + + get _filenameNode() : HTMLInputElement { return this.shadowRoot.querySelector("#filename");} + + get _fileNodes() : HTMLElement[] { return Array.from(this.shadowRoot.querySelectorAll(".vfs_select__file"));} + + get _searchNode() : HTMLInputElement { return this.shadowRoot.querySelector("#search");} + + get _pathNode() : HTMLElement { return this.shadowRoot.querySelector("#path");} + + get _listNode() : HTMLElement { return this.shadowRoot.querySelector("#listbox");} + + get _mimeNode() : Et2Select { return this.shadowRoot.querySelector("#mimeFilter");} + + /* + * List of properties that get translated + * Done separately to not interfere with properties - if we re-define label property, + * labels go missing. + */ + static get translate() + { + return { + ...super.translate, + title: true, + buttonLabel: true + } + } + + constructor(parent_egw? : string | IegwAppLocal) + { + super(); + + if(parent_egw) + { + this._setApiInstance(parent_egw); + } + // Use filemanager translations + this.egw().langRequireApp(this.egw().window, "filemanager", () => {this.requestUpdate()}); + + this.handleButtonClick = this.handleButtonClick.bind(this); + this.handleCreateDirectory = this.handleCreateDirectory.bind(this); + this.handleSearchKeyDown = this.handleSearchKeyDown.bind(this); + } + + transformAttributes(attr) + { + super.transformAttributes(attr); + + // Start request to get server-side info + let content = {}; + let attrs = { + mode: this.mode, + label: this.buttonLabel, + path: this.path || null, + mime: this.mime || null, + name: this.title + }; + return this.egw().request(this.egw().link(this.egw().ajaxUrl(this.egw().decodePath(Et2VfsSelect.SERVER_URL))), + [content, attrs]).then((results) => + { + debugger; + + }); + } + + connectedCallback() + { + super.connectedCallback(); + + if(this.path == "") + { + this.path = "~"; + } + // Get file list + this.startSearch(); + } + + async getUpdateComplete() + { + const result = await super.getUpdateComplete(); + + // Need to wait for server content + await this._serverContent; + + return result; + } + + public setPath(path) + { + if(path == '..') + { + path = this.dirname(this.path); + } + const oldValue = this.path; + this._pathNode.value = this.path = path; + this.requestUpdate("path", oldValue); + + return this._searchPromise; + } + + /** + * Get directory of a path + * + * @param {string} _path + * @returns string + */ + public dirname(_path) + { + let parts = _path.split('/'); + parts.pop(); + return parts.join('/') || '/'; + } + + /** + * Shows the dialog. + */ + public show() + { + this.open = true; + if(this.path && this._fileList.length == 0) + { + this.startSearch(); + } + return Promise.all([ + this._searchPromise, + this._dialog.show() + ]); + } + + /** + * Hides the dialog. + */ + public hide() + { + this.open = false; + return this._dialog.hide(); + } + + startSearch() : Promise + { + // Stop timeout timer + clearTimeout(this._searchTimeout); + + this.searching = true; + this.requestUpdate("searching"); + + // Start the searches + this._searchPromise = this.remoteSearch(this._searchNode?.value ?? "", this.searchOptions); + return this._searchPromise.then(async() => + { + this.searching = false; + this.requestUpdate("searching", true); + }); + } + + localSearch(search : string, options : object) : Promise + { + // No local search + return Promise.resolve([]); + } + + remoteSearch(search : string, options : object) : Promise + { + // Include a limit, even if options don't, to avoid massive lists breaking the UI + let sendOptions = { + path: this._pathNode?.value ?? this.path, + mime: this._mimeNode?.value ?? this.mime, + num_rows: 100, + ...options + } + return this.egw().request(this.egw().link(this.egw().ajaxUrl(this.egw().decodePath(this.searchUrl))), [search, sendOptions]).then((results) => + { + return this.processRemoteResults(results); + }); + } + + processRemoteResults(results) : FileInfo[] + { + if(results.message) + { + this.helpText = results.message; + } + this._fileList = results.files ?? []; + + return this._fileList; + } + + searchMatch(search : string, options : object, item : LitElement) : boolean + { + // No local matching + return false; + } + + /** + * Inject application specific egw object with loaded translations into the dialog + * + * @param {string|egw} _egw_or_appname egw object with already loaded translations or application name to load translations for + */ + _setApiInstance(_egw_or_appname ? : string | IegwAppLocal) + { + if(typeof _egw_or_appname == 'undefined') + { + // @ts-ignore + _egw_or_appname = egw_appName; + } + // if egw object is passed in because called from et2, just use it + if(typeof _egw_or_appname != 'string') + { + this.__egw = _egw_or_appname; + } + // otherwise use given appname to create app-specific egw instance and load default translations + else + { + this.__egw = egw(_egw_or_appname); + this.egw().langRequireApp(this.egw().window, _egw_or_appname); + } + } + + protected handleButtonClick(event : MouseEvent) + { + if(event.target.id !== "cancel") + { + + throw new Error("Method not implemented."); + } + this.open = false; + this.requestUpdate("open", true); + } + + protected handleCreateDirectory(event : MouseEvent | KeyboardEvent) + { + throw new Error("Method not implemented."); + } + + handleSearchKeyDown(event) + { + clearTimeout(this._searchTimeout); + + // Up / Down navigates options + if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._fileList.length) + { + event.stopPropagation(); + this.setCurrentOption(this._fileNodes[0]); + return; + } + // Start search immediately + else if(event.key == "Enter") + { + event.preventDefault(); + this.startSearch(); + return; + } + else if(event.key == "Escape") + { + this.value = []; + this.close(); + 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._searchNode.value.length - 1 > 0) + { + this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2VfsSelect.SEARCH_TIMEOUT); + } + } + + + protected toolbarTemplate() : TemplateResult + { + return html` + + this.setPath("~")} + > + this.setPath("..")} + > + + this.setPath("/apps/favorites")} + > + + + + + + `; + } + + protected filesTemplate() + { + const empty = this._fileList.length == 0; + const noFilesTemplate = html` +
+ + ${this.egw().lang("no files in this directory.")} +
`; + const promise = this._searchPromise.then(() => + { + return html` + ${empty ? noFilesTemplate : html` + ${repeat(this._fileList, (file) => file.path, (file, index) => + { + return html` + + ${file.name}`; + } + )}` + }`; + }); + return html` + ${until(promise, html``)}`; + } + + protected mimeOptionsTemplate() + { + return html``; + } + + protected footerTemplate() + { + let image = "check"; + switch(this.mode) + { + case "saveas": + image = "save_new"; + break; + } + + const buttons = [ + {id: "ok", label: this.buttonLabel, image: image}, + {id: "cancel", label: "cancel", image: "cancel"} + ]; + + return html` + ${repeat(buttons, (button : DialogButton) => button.id, (button, index) => + { + return html` + ${button.label} + + ` + })}`; + } + + render() + { + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + const hasFooterSlot = this.hasSlotController.test('footer'); + const hasToolbarSlot = this.hasSlotController.test('toolbar'); + const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const hasToolbar = !!hasToolbarSlot; + + const hasFilename = this.mode == "saveas"; + + return html` + + ${hasFilename ? html`` : nothing} +
+ + ${hasToolbar ? nothing : this.toolbarTemplate()} + +
+
+ +
+
+ ${this.filesTemplate()} +
+ + ${this.egw().lang("mime filter")} + + + ${this.mimeOptionsTemplate()} + + +
+ ${this.helpText} +
+ ${hasFooterSlot ? nothing : this.footerTemplate()} +
+ `; + } + +} + +customElements.define("et2-vfs-select", Et2VfsSelect); + +export interface FileInfo +{ + name : string, + mime : string, + isDir : boolean, + path? : string, +} \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 178f746842..e6531fed27 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -97,6 +97,7 @@ import './Et2Url/Et2UrlFaxReadonly'; import "./Layout/Et2Split/Et2Split"; import "./Layout/RowLimitedMixin"; import "./Et2Vfs/Et2VfsMime"; +import "./Et2Vfs/Et2VfsSelect"; import "./Et2Vfs/Et2VfsUid"; import "./Et2Textbox/Et2Password"; import './Et2Textbox/Et2Searchbox'; diff --git a/api/src/Etemplate/Widget/Vfs.php b/api/src/Etemplate/Widget/Vfs.php index beec32827f..0c636c1874 100644 --- a/api/src/Etemplate/Widget/Vfs.php +++ b/api/src/Etemplate/Widget/Vfs.php @@ -602,6 +602,61 @@ class Vfs extends File )); } + /** + * Get a list of files that match the given parameters + * + * @param $search + * @param $content + * @return void + * @throws Json\Exception + */ + public static function ajax_vfsSelectFiles($search, $content) + { + $pathIn = $options['path'] ?? '~'; + if($pathIn == '~') + { + $pathIn = Api\Vfs::get_home_dir(); + } + $content = []; + if(!($files = Api\Vfs::find($pathIn, array( + 'dirsontop' => true, + 'order' => 'name', + 'sort' => 'ASC', + 'maxdepth' => 1, + )))) + { + $content['message'] = lang("Can't open directory %1!", $pathIn); + } + else + { + $n = 0; + foreach($files as $path) + { + if($path == $pathIn) + { + continue; + } // remove directory itself + + $name = Api\Vfs::basename($path); + $is_dir = Api\Vfs::is_dir($path); + $mime = Api\Vfs::mime_content_type($path); + if($content['mime'] && !$is_dir && $mime != $content['mime']) + { + continue; // does not match mime-filter --> ignore + } + $content['files'][$n] = array( + 'name' => $name, + 'path' => $path, + 'mime' => $mime, + 'is_dir' => $is_dir + ); + ++$n; + } + } + $response = Json\Response::get(); + $response->data($content); + } + /** * function to create directory in the given path *