WIP Et2VfsSelect: Now files showing up in list

This commit is contained in:
nathan 2024-01-17 16:32:09 -07:00
parent e014487e86
commit faeee31155
6 changed files with 540 additions and 67 deletions

View File

@ -8,6 +8,7 @@ export default css`
et2-dialog::part(body) {
display: flex;
flex-direction: column;
max-height: 40em;
}
.vfs_select__listbox {
@ -19,6 +20,7 @@ export default css`
.vfs_select__listbox .vfs_select__empty {
height: 100%;
min-height: 5em;
min-width: 20em;
display: flex;
flex-direction: column;
align-items: center;
@ -26,6 +28,18 @@ export default css`
user-select: none;
}
.vfs_select__file_row {
display: table-row;
}
.vfs_select__listbox .vfs_select__loading {
text-align: center;
line-height: 15em; // 3 * listbox min height
}
.vfs_select__listbox sl-spinner {
font-size: 4rem;
}
.vfs_select__listbox .vfs_select__empty et2-image {
margin-top: auto;
}

View File

@ -8,7 +8,7 @@
*/
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {html, LitElement, nothing, TemplateResult} from "lit";
import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
import shoelace from "../Styles/shoelace";
import styles from "./Et2VfsSelect.styles";
import {property} from "lit/decorators/property.js";
@ -22,6 +22,7 @@ import {DialogButton, Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {HasSlotController} from "../Et2Widget/slot";
import {IegwAppLocal} from "../../jsapi/egw_global";
import {Et2Select} from "../Et2Select/Et2Select";
import {Et2VfsSelectRow} from "./Et2VfsSelectRow";
/**
* @summary Select files (including directories) from the VFS
@ -29,6 +30,7 @@ import {Et2Select} from "../Et2Select/Et2Select";
*
* @dependency et2-dialog
* @dependency et2-select
* @dependency et2-vfs-select-row
*
* @slot title - Optional additions to title. Works best with `et2-button-icon`.
* @slot toolbar - Toolbar containing controls for search & navigation
@ -70,10 +72,10 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
* Dialog mode
* Quickly sets button label, multiple, selection and for "select-dir", mime-type
**/
@property() mode : "open" | "open-multiple" | "saveas" | "select-dir" = "open";
@property({type: String}) mode : "open" | "open-multiple" | "saveas" | "select-dir";
/** Button label */
@property() buttonLabel : string = "Select";
@property({type: String}) buttonLabel : string = "Select";
/** Provide a suggested filename for saving */
@property() filename : string = "";
@ -95,7 +97,9 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
@state() searching = false;
@state() open : boolean = false;
@state() currentFile;
@state() currentFile : Et2VfsSelectRow;
@state() selectedFiles : Et2VfsSelectRow[] = [];
// SearchMixinInterface //
@property() searchUrl : string = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectFiles";
@ -117,14 +121,16 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'toolbar', 'footer');
protected _fileList = [];
protected _fileList : FileInfo[] = [];
// @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() : HTMLElement[] { return Array.from(this.shadowRoot.querySelectorAll(".vfs_select__file"));}
get _fileNodes() : Et2VfsSelectRow[] { return Array.from(this.shadowRoot.querySelectorAll("et2-vfs-select-row"));}
get _searchNode() : HTMLInputElement { return this.shadowRoot.querySelector("#search");}
@ -191,7 +197,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
if(this.path == "")
{
this.path = "~";
this.path = <string>this.egw()?.preference("startfolder", "filemanager") || "~";
}
// Get file list
this.startSearch();
@ -207,6 +213,27 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
return result;
}
protected firstUpdated(changedProperties : PropertyValues)
{
super.firstUpdated(changedProperties);
debugger;
}
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)
{
if(path == '..')
@ -244,6 +271,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
this.startSearch();
}
return Promise.all([
this.updateComplete,
this._searchPromise,
this._dialog.show()
]);
@ -258,6 +286,14 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
return this._dialog.hide();
}
getComplete() : Promise<string[]>
{
return this._dialog.getComplete().then(() =>
{
return this.value;
});
}
startSearch() : Promise<void>
{
// Stop timeout timer
@ -285,8 +321,8 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
{
// 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,
path: this.path,
mime: this.mime,
num_rows: 100,
...options
}
@ -298,10 +334,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
processRemoteResults(results) : FileInfo[]
{
if(results.message)
{
this.helpText = results.message;
}
this.helpText = results.message ?? "";
this._fileList = results.files ?? [];
return this._fileList;
@ -338,22 +371,178 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
}
}
/**
* 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.selectionChanged();
}
/**
* Toggles a file's selected state
*/
private toggleFileSelection(file : Et2VfsSelectRow, force? : boolean)
{
if(force === true || force === false)
{
file.selected = force;
}
else
{
file.selected = !file.selected;
}
file.requestUpdate("selected");
this.selectionChanged();
}
/**
* This method must be called whenever the selection changes. It will update the selected file cache, the current
* value, and the display value
*/
private selectionChanged()
{
// Update selected files cache
this.selectedFiles = this._fileNodes.filter(el => el.selected);
// Update the value
if(this.multiple)
{
this.value = this.selectedFiles.map(el => el.value.path);
// TODO - show how many are selected?
/*
if(this.value.length === 0)
{
// When no items are selected, keep the value empty so the placeholder shows
this.displayLabel = '';
}
else
{
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedFiles.length);
}
*/
}
else
{
this.value = [this.selectedFiles[0]?.value.path] ?? [];
}
}
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)
/**
* Create a new directory in the current one
* @param {MouseEvent | KeyboardEvent} event
* @returns {Promise<void>}
* @protected
*/
protected async handleCreateDirectory(event : MouseEvent | KeyboardEvent)
{
throw new Error("Method not implemented.");
// 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);
});
}
}
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.mode != "select-dir")
{
return;
}
if(this.multiple)
{
this.toggleFileSelection(file);
}
else
{
this.setSelectedFiles(file);
}
// Set focus after updating so the value is announced by screen readers
//this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
if(this.value !== oldValue)
{
// Emit after updating
this.updateComplete.then(() =>
{
this.dispatchEvent(new Event('change', {bubbles: true}));
});
}
}
}
handleFileDoubleClick(event : MouseEvent)
{
const target = event.target as HTMLElement;
const file : Et2VfsSelectRow = target.closest('et2-vfs-select-row');
if(file.value.isDir)
{
this.toggleFileSelection(file, false);
const oldPath = this.path;
this.setPath(file.value.path);
}
else
{
// Not a dir, just select it
this.handleFileClick(event);
// If we only want one, we've got it. Close.
if(!this.multiple)
{
this.hide();
}
}
}
handleSearchKeyDown(event)
{
clearTimeout(this._searchTimeout);
@ -362,7 +551,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._fileList.length)
{
event.stopPropagation();
this.setCurrentOption(this._fileNodes[0]);
this.setCurrentFile(this._fileNodes[0]);
return;
}
// Start search immediately
@ -374,8 +563,10 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
}
else if(event.key == "Escape")
{
event.stopPropagation();
event.preventDefault();
this.value = [];
this.close();
this.hide();
return;
}
@ -408,7 +599,11 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
image="filemanager/fav_filter" noSubmit="true"
@click=${() => this.setPath("/apps/favorites")}
></et2-button>
<et2-select-app id="app" emptyLabel="Applications" noLang="1"></et2-select-app>
<et2-select id="app" emptyLabel="Applications" noLang="1"
.select_options=${this.appList}
@change=${(e) => this.setPath("/apps/" + e.target.value)}
>
</et2-select>
<et2-button statustext="Create directory" id="createdir" class="createDir"
arial-label=${this.egw().lang("Create directory")}
noSubmit="true"
@ -417,9 +612,10 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
@click=${this.handleCreateDirectory}
></et2-button>
<file id="upload_file" statustext="upload file" progress_dropdownlist="true" multiple="true"
onFinish="app.vfsSelectUI.storeFile"/>
onFinish="app.vfsSelectUI.storeFile"></file>
<et2-searchbox id="search"
@keydown=${this.handleSearchKeyDown}
@sl-clear=${this.startSearch}
></et2-searchbox>
</et2-box>
`;
@ -428,28 +624,38 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
protected filesTemplate()
{
const empty = this._fileList.length == 0;
const noFilesTemplate = html`
<div class="vfs_select__empty">
<et2-image src="filemanager"></et2-image>
${this.egw().lang("no files in this directory.")}
</div>`;
const promise = this._searchPromise.then(() =>
{
return html`
${empty ? noFilesTemplate : html`
${empty ? this.noFilesTemplate() : html`
${repeat(this._fileList, (file) => file.path, (file, index) =>
{
return html`
<et2-vfs-mime
.value=${file}
></et2-vfs-mime>
${file.name}`;
<et2-vfs-select-row
?disabled=${file.disabled || this.mode == "select-dir" && !file.isDir}
.value=${file}
@mouseup=${this.handleFileClick}
@dblclick=${this.handleFileDoubleClick}
></et2-vfs-select-row>`;
}
)}`
}`;
});
return html`
${until(promise, html`<sl-spinner></sl-spinner>`)}`;
${until(promise, html`
<div class="vfs_select__loading">
<sl-spinner></sl-spinner>
</div>`)}`;
}
protected noFilesTemplate() : TemplateResult
{
return html`
<div class="vfs_select__empty">
<et2-image src="filemanager"></et2-image>
${this.egw().lang("no files in this directory.")}
</div>`;
}
protected mimeOptionsTemplate()
@ -502,8 +708,8 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
return html`
<et2-dialog
.isModal="true"
.destroyOnClose="false"
.isModal=${true}
.destroyOnClose=${false}
.title=${this.title}
.open=${this.open}
>
@ -562,7 +768,6 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi
</et2-dialog>
`;
}
}
customElements.define("et2-vfs-select", Et2VfsSelect);
@ -573,4 +778,6 @@ export interface FileInfo
mime : string,
isDir : boolean,
path? : string,
// We want to show it, but not act with it. File is disabled for the UI
disabled? : boolean
}

View File

@ -0,0 +1,96 @@
import {css} from 'lit';
export default css`
:host {
display: block;
user-select: none;
}
:host(:focus) {
outline: none;
}
:host([disabled]) {
display: block;
}
.file {
position: relative;
display: flex;
align-items: center;
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-normal);
line-height: var(--sl-line-height-normal);
letter-spacing: var(--sl-letter-spacing-normal);
color: var(--sl-color-neutral-700);
padding: var(--sl-spacing-2x-small) var(--sl-spacing-medium) var(--sl-spacing-2x-small) var(--sl-spacing-x-small);
transition: var(--sl-transition-fast) fill;
cursor: pointer;
}
.file--hover:not(.file--current):not(.file--disabled) {
background-color: var(--sl-color-neutral-100);
color: var(--sl-color-neutral-1000);
}
.file--current,
.file--current.file--disabled {
background-color: var(--sl-color-primary-600);
color: var(--sl-color-neutral-0);
opacity: 1;
}
.file--disabled {
outline: none;
opacity: 0.5;
cursor: not-allowed;
}
.file__label {
flex: 1 1 auto;
display: inline-block;
line-height: var(--sl-line-height-dense);
}
.file .file__check {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
padding-inline-end: var(--sl-spacing-2x-small);
}
.file et2-vfs-mime {
/* line-height-normal has no unit */
height: calc(var(--sl-line-height-normal) * 1em);
padding-inline-end: var(--sl-spacing-medium);
}
.file--selected .file__check {
visibility: visible;
}
.file__prefix,
.file__suffix {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.file__prefix::slotted(*) {
margin-inline-end: var(--sl-spacing-x-small);
}
.file__suffix::slotted(*) {
margin-inline-start: var(--sl-spacing-x-small);
}
@media (forced-colors: active) {
:host(:hover:not([aria-disabled='true'])) .file {
outline: dashed 1px SelectedItem;
outline-offset: -1px;
}
}
`;

View File

@ -0,0 +1,75 @@
import {html, LitElement} from "lit";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {FileInfo} from "./Et2VfsSelect";
import {property} from "lit/decorators/property.js";
import {state} from "lit/decorators/state.js";
import {classMap} from "lit/directives/class-map.js";
import shoelace from "../Styles/shoelace";
import styles from "./Et2VfsSelectRow.styles";
export class Et2VfsSelectRow extends Et2Widget(LitElement)
{
static get styles()
{
return [
shoelace,
...super.styles,
styles
];
}
@property({type: Object}) value : FileInfo;
/** Draws the file in a disabled state, preventing selection. */
@property({type: Boolean, reflect: true}) disabled = false;
@state() current = false; // the user has keyed into the file, but hasn't selected it yet (shows a highlight)
@state() selected = false; // the file is selected and has aria-selected="true"
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
connectedCallback()
{
super.connectedCallback();
this.setAttribute('role', 'option');
this.setAttribute('aria-selected', 'false');
}
private handleMouseEnter()
{
this.hasHover = true;
this.requestUpdate("hasHover", false);
}
private handleMouseLeave()
{
this.hasHover = false;
this.requestUpdate("hasHover", true);
}
render()
{
return html`
<div
part="base"
class=${classMap({
file: true,
'file--current': this.current,
'file--disabled': this.disabled,
'file--selected': this.selected,
'file--hover': this.hasHover
})}
@mouseenter=${this.handleMouseEnter}
@mouseleave=${this.handleMouseLeave}
>
<sl-icon part="checked-icon" class="file__check" name="check" library="system"
aria-hidden="true"></sl-icon>
<slot part="prefix" name="prefix" class="file__prefix"></slot>
<et2-vfs-mime .value=${this.value}></et2-vfs-mime>
${this.value.name}
<slot part="suffix" name="suffix" class="file__suffix"></slot>
</div>
`;
}
}
customElements.define("et2-vfs-select-row", Et2VfsSelectRow);

View File

@ -98,6 +98,7 @@ import "./Layout/Et2Split/Et2Split";
import "./Layout/RowLimitedMixin";
import "./Et2Vfs/Et2VfsMime";
import "./Et2Vfs/Et2VfsSelect";
import "./Et2Vfs/Et2VfsSelectRow";
import "./Et2Vfs/Et2VfsUid";
import "./Et2Textbox/Et2Password";
import './Et2Textbox/Et2Searchbox';

View File

@ -612,49 +612,129 @@ class Vfs extends File
*/
public static function ajax_vfsSelectFiles($search, $content)
{
$pathIn = $options['path'] ?? '~';
if($pathIn == '~')
$response = [];
$content['path'] = $content['path'] ?? '~';
if($content['path'] == '~')
{
$pathIn = Api\Vfs::get_home_dir();
$content['path'] = Api\Vfs::get_home_dir();
}
$content = [];
if(!($files = Api\Vfs::find($pathIn, array(
// Filemanager favorites as directories
if(substr($content['path'], 0, strlen('/apps/favorites')) == '/apps/favorites')
{
$files = static::filesFromFavorites($search, $content);
}
else
{
$files = static::filesFromVfs($search, $content);
if(is_string($files))
{
$response['message'] = $files;
$files = [];
}
}
foreach($files as $path)
{
if(is_string($path) && $path == $content['path'] || is_array($path) && $path['path'] == $content['path'])
{
// remove directory itself
continue;
}
$name = $path['name'] ?? Api\Vfs::basename($path);
$is_dir = $path['isDir'] ?? Api\Vfs::is_dir($path);
$mime = $path['mime'] ?? Api\Vfs::mime_content_type($path);
if($content['mime'] && !$is_dir && $mime != $content['mime'])
{
continue; // does not match mime-filter --> ignore
}
$response['files'][] = array(
'name' => $name,
'path' => $path,
'mime' => $mime,
'isDir' => $is_dir
);
}
Json\Response::get()->data($response);
}
private static function filesFromVfs($search, $params)
{
$vfs_options = array(
'dirsontop' => true,
'order' => 'name',
'sort' => 'ASC',
'maxdepth' => 1,
))))
);
if($search)
{
$content['message'] = lang("Can't open directory %1!", $pathIn);
$vfs_options['name_preg'] = '/' . str_replace(array('\\?', '\\*'),
array('.{1}', '.*'),
preg_quote($search)) . '/i';
}
else
if($params['num_rows'])
{
$n = 0;
foreach($files as $path)
{
if($path == $pathIn)
{
continue;
} // remove directory itself
$vfs_options['limit'] = (int)$params['num_rows'];
}
if(!($files = Api\Vfs::find($params['path'], $vfs_options)))
{
return lang("Can't open directory %1!", $params['path']);
}
return $files;
}
$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,
/**
* Get favorites as if they were folders
*
* @return array
*/
private static function filesFromFavorites($search, $params)
{
// Display favorites as if they were folders
$files = array();
$favorites = Api\Framework\Favorites::get_favorites('filemanager');
//check for recent paths and add them to the top of favorites list
if(is_array($params['recentPaths']))
{
foreach($params['recentPaths'] as $p)
{
$mime = Api\Vfs::mime_content_type($p);
$files[] = array(
'name' => $p,
'path' => $p,
'mime' => $mime,
'is_dir' => $is_dir
'is_dir' => true
);
++$n;
}
}
$response = Json\Response::get();
$response->data($content);
foreach($favorites as $favorite)
{
$path = $favorite['state']['path'];
if(!$path)
{
continue;
}
// Search
if($search && !(str_contains($favorite['name'], $search) || str_contains($path, $search)))
{
continue;
}
if(!Api\Vfs::is_readable($path))
{
continue;
}
$mime = Api\Vfs::mime_content_type($path);
$files[] = array(
'name' => $favorite['name'],
'path' => $path,
'mime' => $mime,
'isDir' => true
);
}
return $files;
}
/**