/** * 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, PropertyValues, render, 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 {classMap} from "lit/directives/class-map.js"; import {repeat} from "lit/directives/repeat.js"; import {SelectOption} from "../Et2Select/FindSelectOptions"; import {DialogButton, Et2Dialog} from "../Et2Dialog/Et2Dialog"; import {HasSlotController} from "../Et2Widget/slot"; import {egw, IegwAppLocal} from "../../jsapi/egw_global"; import {Et2Select} from "../Et2Select/Et2Select"; import {Et2VfsSelectRow} from "./Et2VfsSelectRow"; import {Et2VfsPath} from "./Et2VfsPath"; import {SearchMixin, SearchResult, SearchResultElement, SearchResultsInterface} from "../Et2Widget/SearchMixin"; /** * @summary Select files (including directories) from the VFS. * * The dialog does not do anything with the files, just handles the UI to select them. * * @dependency et2-box * @dependency et2-button * @dependency et2-dialog * @dependency et2-image * @dependency et2-searchbox * @dependency et2-select * @dependency et2-vfs-select-row * * @slot title - Optional additions to title. * @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 toolbar - controls at the top * @csspart path - Et2VfsPath control * @csspart listbox - The list of files * @csspart mimefilter - Mime filter select * @csspart form-control-help-text - The help text's wrapper. * */ type Constructor = new (...args : any[]) => T; export class Et2VfsSelectDialog extends SearchMixin & typeof LitElement, FileInfo, FileResultsInterface>(Et2InputWidget(LitElement)) { 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({type: String}) mode : "open" | "open-multiple" | "saveas" | "select-dir"; /** Button label */ @property({type: String}) 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({type: Array}) mimeList : SelectOption[] = [ { value: "/(application\\/vnd.oasis.opendocument.text|application\\/vnd.openxmlformats-officedocument.wordprocessingml.document)/i", label: "Documents" }, { value: "/(application\\/vnd.oasis.opendocument.spreadsheet|application\\/vnd.openxmlformats-officedocument.spreadsheetml.sheet)/i", label: "Spreadsheets" }, {value: "image/", label: "Images"}, {value: "video/", label: "Videos"}, {value: "message/rfc822", label: "Email"} ]; /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({attribute: 'help-text'}) helpText = ''; @state() open : boolean = false; @state() currentResult : Et2VfsSelectRow; @state() selectedFiles : Et2VfsSelectRow[] = []; @state() _pathWritable : boolean = false; // SearchMixinInterface // @property() searchUrl : string = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectFiles"; // End SearchMixinInterface // // Still need some server-side info protected _serverContent : Promise = Promise.resolve({}); private static SERVER_URL = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelect_content"; protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'toolbar', 'footer'); // @ts-ignore different types protected _appList : SelectOption[] = this.egw().link_app_list("query") ?? []; // Internal accessors get _dialog() : Et2Dialog { return this.shadowRoot.querySelector("et2-dialog");} get _filenameNode() : HTMLInputElement { return this.shadowRoot.querySelector("#filename");} get _fileNodes() : Et2VfsSelectRow[] { return Array.from(this.shadowRoot.querySelectorAll("et2-vfs-select-row"));} get _resultNodes() : (HTMLElement & SearchResultElement)[] { return <(HTMLElement & SearchResultElement)[]>this._fileNodes;} get _searchNode() : HTMLInputElement { return this.shadowRoot.querySelector("#search");} get _pathNode() : Et2VfsPath { return this.shadowRoot.querySelector("#path");} 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.handleClose = this.handleClose.bind(this); this.handleCreateDirectory = this.handleCreateDirectory.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(Et2VfsSelectDialog.SERVER_URL))), [content, attrs]).then((results) => { debugger; }); } connectedCallback() { super.connectedCallback(); if(this.path == "") { this.path = this.egw().getLocalStorageItem(this.egw().appName, this.constructor.name + "Path") || this.egw()?.preference("startfolder", "filemanager") || "~"; } } async getUpdateComplete() { const result = await super.getUpdateComplete(); // Need to wait for server content await this._serverContent; return result; } firstUpdated() { this._dialog.updateComplete.then(() => { this._dialog.panel.style.width = "60em"; this._dialog.panel.style.height = "40em"; }); // Get file list if(this.open) { debugger; this.startSearch(); } } protected willUpdate(changedProperties : PropertyValues) { super.willUpdate(changedProperties); if(changedProperties.has("mode")) { this.multiple = this.mode == "open-multiple"; } if(changedProperties.has("path")) { this.startSearch(); } } public setPath(path) { const oldValue = this.path; // Selection doesn't stay across sub-dirs. Notify user we dropped them. if(this.value.length && path != oldValue) { const length = this.value.length; this.value.length = 0; this.updateComplete.then(() => { render(html` ${this.egw().lang("Selection of files can only be done in one folder. %1 files unselected.", length)} `, this); }); } if(path == '..') { path = this.dirname(this.path); } this._pathNode.value = this.path = path; this.requestUpdate("path", oldValue); this.currentResult = null; 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('/') || '/'; } /** * Get file information of currently displayed paths * * Returns null if the path is not currently displayed * @param _path */ public fileInfo(_path) { return this._searchResults.find(f => f.path == _path); } /** * Shows the dialog. */ public show() { this.open = true; if(this.path && this._searchResults.length == 0) { this.startSearch(); } return Promise.all([ this.updateComplete, this._searchPromise ]).then(() => { return this._dialog.show(); }).then(() => { // Set current file to first value if(this.value && this.value[0]) { this.setCurrentResult(this._fileNodes.find(node => node.value.path == this.value[0])); } }); } /** * Hides the dialog. */ public hide() { this.open = false; return this._dialog.hide(); } async getComplete() : Promise<[number, Object]> { await this.updateComplete; const value = await this._dialog.getComplete(); await this.handleClose(); value[1] = this.value; return value; } protected localSearch(search : string, searchOptions : object, localOptions : FileInfo[] = []) : Promise { return super.localSearch(search, {...searchOptions, mime: this.mime}, localOptions); } public searchMatch(search : string, searchOptions : Object, option : FileInfo) : boolean { let result = super.searchMatch(search, searchOptions, option); // Add in local mime check if(result && searchOptions.mime) { result = result && option.mime.match(searchOptions.mime); } return result; } 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.path, mime: this.mime, num_rows: 100, ...options } return super.remoteSearch(search, sendOptions); } processRemoteResults(results) : FileInfo[] { const result = super.processRemoteResults(results); if(typeof results.path === "string") { // Something like a redirect or link followed - server is sending us a "corrected" path this.path = results.path; } if(typeof results.writable !== "undefined") { this._pathWritable = results.writable; this.requestUpdate("_pathWritable"); } this.helpText = results?.message ?? ""; return result; } /** * 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); } } private async handleClose() { // Should already be complete, we want the button let dialogValue = await this._dialog.getComplete(); switch(this.mode) { case "select-dir": // If they didn't pick a specific directory and didn't cancel, use the current directory if(this.value.length == 0) { this.value.splice(0, 0, this.path) } break; case "saveas": // Saveas wants a full path, including filename this.value.splice(0, this.value.length, this.path + "/" + this._filenameNode.value ?? this.filename); // Check for existing file, ask what to do if(this.fileInfo(this.value[0])) { let result = await this.overwritePrompt(this._filenameNode ?? this.filename); if(result == null) { return; } this.value.splice(0, this.value.length, this.path + "/" + result); } break; } // Save path for next time this.egw().setLocalStorageItem(this.egw().appName, this.constructor.name + "Path", this.path); this.dispatchEvent(new Event("change", {bubbles: true})); } /** * User tried to saveas when we can see that file already exists. Prompt to overwrite or rename. * * We offer a suggested new name by appending "(#)", and give back either the original filename, their * modified filename, or null if they cancel. * * @param filename * @returns {Promise<[number|string, Object]|null>} [Button,filename] or null if they cancel * @private */ private overwritePrompt(filename) : Promise<[number | string, object] | null> { // Make a filename suggestion const parts = filename.split("."); const extension = parts.pop(); const newName = parts.join("."); let counter = 0; let suggestion; do { counter++; suggestion = `${newName} (${counter}).${extension}`; } while(this.fileInfo(suggestion)) // Ask about it const saveModeDialogButtons = [ { label: self.egw().lang("Yes"), id: "overwrite", class: "ui-priority-primary", "default": true, image: 'check' }, {label: self.egw().lang("Rename"), id: "rename", image: 'edit'}, {label: self.egw().lang("Cancel"), id: "cancel"} ]; return Et2Dialog.show_prompt(null, self.egw().lang('Do you want to overwrite existing file %1 in directory %2?', filename, this.path), self.egw().lang('File %1 already exists', filename), suggestion, saveModeDialogButtons, null).getComplete().then(([button, value]) => { if(button == "cancel") { return null; } return button == "rename" ? value.value : filename; }); } /** * Sets the selected files * @param {Et2VfsSelectRow | Et2VfsSelectRow[]} file * @private */ private setSelectedFiles(file : Et2VfsSelectRow | Et2VfsSelectRow[]) { const newSelectedOptions = Array.isArray(file) ? file : [file]; // Clear existing selection this._fileNodes.forEach(el => { el.selected = false; el.requestUpdate("selected"); }); // Set the new selection if(newSelectedOptions.length) { newSelectedOptions.forEach(el => { el.selected = true; el.requestUpdate("selected"); }); } // Update selection, value, and display label this.searchResultSelected(); } /** * Toggles a search result's selected state */ protected toggleResultSelection(result : HTMLElement & SearchResultElement, force? : boolean) { if(!this.multiple) { this._resultNodes.forEach(n => { n.selected = false; n.requestUpdate("selected"); }); } super.toggleResultSelection(result, force); } /** * This method must be called whenever the selection changes. It will update the selected file cache, the current * value, and the display value */ protected searchResultSelected() { super.searchResultSelected(); // Update the value if(this.multiple) { this.value.splice(0, this.value.length, ...this.selectedResults.map(el => el.value.path)); } else { this.value.splice(0, this.value.length, ...(this.selectedResults?.length ? [this.selectedResults[0].value.path] : []) ?? []); } } /** * Create a new directory in the current one * @param {MouseEvent | KeyboardEvent} event * @returns {Promise} * @protected */ protected async handleCreateDirectory(event : MouseEvent | KeyboardEvent) { // Get new directory name let [button, value] = await Et2Dialog.show_prompt( null, this.egw().lang('New directory'), this.egw().lang('Create directory') ).getComplete(); let dir = value.value; if(button && dir) { this.egw().request('EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_create_dir', [dir, this.path]) .then((msg) => { this.egw().message(msg); this.setPath(this.path + '/' + dir); }); } } /** * SearchMixin handles the actual selection, we just reject directories here. * * @param {MouseEvent} event */ handleFileClick(event : MouseEvent) { const target = event.target as HTMLElement; const file : Et2VfsSelectRow = target.closest('et2-vfs-select-row'); const oldValue = this.value; if(file && !file.disabled) { // Can't select a directory normally if(file.value.isDir) { this.setPath(file.value.path); event.preventDefault(); event.stopPropagation(); return; } // can't select anything in "saveas", but set the file name else if(this.mode == "saveas") { this._filenameNode.value = file.value.name; event.preventDefault(); event.stopPropagation(); this.updateComplete.then(() => this._filenameNode.focus()); return; } // Set focus after updating so the value is announced by screen readers //this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); } } handleKeyDown(event) { // Ignore selects if(event.target.tagName.startsWith('ET2-SELECT')) { return; } // Grab any keypresses, avoid EgwAction reacting on them too event.stopPropagation() // Navigate options if(["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { const files = this._fileNodes; const currentIndex = files.indexOf(this.currentResult); let newIndex = Math.max(0, currentIndex); // Prevent scrolling event.preventDefault(); if(event.key === "ArrowDown") { newIndex = currentIndex + 1; if(newIndex > files.length - 1) { return this._mimeNode.focus(); } } else if(event.key === "ArrowUp") { newIndex = currentIndex - 1; if(newIndex < 0) { return this._pathNode.focus(); } } else if(event.key === "Home") { newIndex = 0; } else if(event.key === "End") { newIndex = files.length - 1; } this.setCurrentResult(files[newIndex]); } else if([" "].includes(event.key) && this.currentResult) { // Prevent scrolling event.preventDefault(); return this.handleFileClick(event); } else if(["Enter"].includes(event.key) && this.currentResult && !this.currentResult.disabled) { return this.handleFileClick(event); } else if(["Escape"].includes(event.key)) { this.open = false; } } handleSearchKeyDown(event) { clearTimeout(this._searchTimeout); // Up / Down navigates options if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._searchResults.length) { return super.handleSearchKeyDown(event); } // Start search immediately else if(event.key == "Enter") { return super.handleSearchKeyDown(event); } else if(event.key == "Escape") { super.handleSearchKeyDown(event); event.preventDefault(); this.value.length = 0; this.hide(); return; } // Start the search automatically if they have something typed if(this._searchNode.value.length > 0) { this._searchTimeout = window.setTimeout(() => {this.startSearch()}, Et2VfsSelectDialog.SEARCH_TIMEOUT); } } protected toolbarTemplate() : TemplateResult { return html`
this.setPath("~")} > this.setPath("..")} > this.setPath("/apps/favorites")} > this.setPath("/apps/" + e.target.value)} >
`; } protected resultTemplate(file : FileInfo, index) { const classes = file.class ? Object.fromEntries((file.class).split(" ").map(k => [k, true])) : {}; return html` `; } protected noResultsTemplate() : TemplateResult { return html`
${this.egw().lang("no files in this directory.")}
`; } 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, button_id: Et2Dialog.OK_BUTTON}, {id: "cancel", label: this.egw().lang("cancel"), image: "cancel", button_id: Et2Dialog.CANCEL_BUTTON} ]; return html` ${repeat(buttons, (button : DialogButton) => button.id, (button, index) => { // style=order is to allow slotted buttons an opportunity to choose where they go. // Default is they'll go before our primary button 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"; const mime = typeof this.mime == "string" ? this.mime : (this.mimeList.length == 1 ? this.mimeList[0].value : ""); return html` ${hasFilename ? html` {this.filename = e.target.value;}} > ` : nothing}
${hasToolbar ? nothing : this.toolbarTemplate()}
{this.setPath(this._pathNode.value)}} >
${this.searchResultsTemplate()} ${this.egw().lang("mime filter")} { this.mime = e.target.value; this.startSearch(); }} > ${this.mimeOptionsTemplate()}
${this.helpText}
${this.footerTemplate()}
`; } } customElements.define("et2-vfs-select-dialog", Et2VfsSelectDialog); export type FileInfo = SearchResult & { mime : string, isDir : boolean, // Full VFS path path? : string, // Direct download link downloadUrl? : string } /** * We expect the server to respond with file data in this format */ interface FileResultsInterface extends SearchResultsInterface { // Something like a redirect or link followed - server is sending us a "corrected" path path? : string, // The current directory is not writable writable? : boolean }