luci-base: extend FileUpload; create DirectoryPicker

FileUpload was extended to accommodate the new features, since
it has nearly everything already. Create a DirectoryPicker
convenience wrapper.

Additions are:
-directory creation (dialogue); set directory_create to true
-directory select mode (instead of file); set directory_select to true

Also fix a bug in the breadcrumb generation which produced:

/foo » » bar

for /foo/bar if root_directory is not '/', and another bug that
merged links together when navigating upward again using the
breadcrumbs.

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2025-07-06 04:04:28 +02:00
parent 31949c1b43
commit 2012b36215
2 changed files with 278 additions and 10 deletions

View File

@@ -4883,13 +4883,13 @@ const CBIHiddenValue = CBIValue.extend(/** @lends LuCI.form.HiddenValue.prototyp
* offers the ability to browse, upload and select remote files.
*
* @param {LuCI.form.Map|LuCI.form.JSONMap} form
* The configuration form to which this section is added to. It is automatically passed
* The configuration form to which this section is added. It is automatically passed
* by [option()]{@link LuCI.form.AbstractSection#option} or
* [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
* option to the section.
*
* @param {LuCI.form.AbstractSection} section
* The configuration section this option is added to. It is automatically passed
* The configuration section this option is added. It is automatically passed
* by [option()]{@link LuCI.form.AbstractSection#option} or
* [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
* option to the section.
@@ -4910,6 +4910,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype
this.super('__init__', args);
this.browser = false;
this.directory_create = false;
this.directory_select = false;
this.show_hidden = false;
this.enable_upload = true;
this.enable_remove = true;
@@ -4919,7 +4921,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype
/**
* Open in a file browser mode instead of selecting for a file
* Render the widget in browser mode initially instead of a button
* to 'Select File...'.
*
* @name LuCI.form.FileUpload.prototype#browser
* @type boolean
@@ -4955,6 +4958,35 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype
* @default true
*/
/**
* Toggle remote directory create functionality.
*
* When set to `true`, the underlying widget provides a button which lets
* the user create directories. Note that this is merely
* a cosmetic feature: remote create permissions are controlled by the
* session ACL rules.
*
* The default of `false` means the directory create button is hidden.
*
* @name LuCI.form.FileUpload.prototype#directory_create
* @type boolean
* @default false
*/
/**
* Toggle remote directory select functionality.
*
* When set to `true`, the underlying widget changes behaviour to select
* directories instead of files, in effect, becoming a directory
* picker.
*
* The default is `false`.
*
* @name LuCI.form.FileUpload.prototype#directory_select
* @type boolean
* @default false
*/
/**
* Toggle remote file delete functionality.
*
@@ -5001,6 +5033,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype
name: this.cbid(section_id),
browser: this.browser,
show_hidden: this.show_hidden,
directory_create: this.directory_create,
directory_select: this.directory_select,
enable_upload: this.enable_upload,
enable_remove: this.enable_remove,
enable_download: this.enable_download,
@@ -5012,6 +5046,165 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype
}
});
/**
* @class DirectoryPicker
* @memberof LuCI.form
* @augments LuCI.form.Value
* @hideconstructor
* @classdesc
*
* The `DirectoryPicker` element wraps a {@link LuCI.ui.FileUpload} widget and
* offers the ability to browse, create, delete and select remote directories.
*
* @param {LuCI.form.Map|LuCI.form.JSONMap} form
* The configuration form to which this section is added. It is automatically passed
* by [option()]{@link LuCI.form.AbstractSection#option} or
* [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
* option to the section.
*
* @param {LuCI.form.AbstractSection} section
* The configuration section this option is added. It is automatically passed
* by [option()]{@link LuCI.form.AbstractSection#option} or
* [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the
* option to the section.
*
* @param {string} option
* The name of the UCI option to map.
*
* @param {string} [title]
* The title caption of the option element.
*
* @param {string} [description]
* The description text of the option element.
*/
const CBIDirectoryPicker = CBIValue.extend(/** @lends LuCI.form.DirectoryPicker.prototype */ {
__name__: 'CBI.DirectoryPicker',
__init__(...args) {
this.super('__init__', args);
this.browser = false;
this.directory_create = false;
this.enable_download = false;
this.enable_remove = false;
this.enable_upload = false;
this.root_directory = '/tmp';
this.show_hidden = true;
},
/**
* Render the widget in browser mode initially instead of a button
* to 'Select Directory...'.
*
* @name LuCI.form.DirectoryPicker.prototype#browser
* @type boolean
* @default false
*/
/**
* Toggle remote directory create functionality.
*
* When set to `true`, the underlying widget provides a button which lets
* the user create directories. Note that this is merely
* a cosmetic feature: remote create permissions are controlled by the
* session ACL rules.
*
* The default of `false` means the directory create button is hidden.
*
* @name LuCI.form.DirectoryPicker.prototype#directory_create
* @type boolean
* @default false
*/
/**
* Toggle download file functionality.
*
* @name LuCI.form.DirectoryPicker.prototype#enable_download
* @type boolean
* @default false
*/
/**
* Toggle remote file delete functionality.
*
* When set to `true`, the underlying widget provides buttons which let
* the user delete files from remote directories. Note that this is merely
* a cosmetic feature: remote delete permissions are controlled by the
* session ACL rules.
*
* The default is `false`, means file removal buttons are not displayed.
*
* @name LuCI.form.DirectoryPicker.prototype#enable_remove
* @type boolean
* @default false
*/
/**
* Toggle file upload functionality.
*
* When set to `true`, the underlying widget provides a button which lets
* the user select and upload local files to the remote system.
* Note that this is merely a cosmetic feature: remote upload access is
* controlled by the session ACL rules.
*
* The default of `false` means file upload functionality is disabled.
*
* @name LuCI.form.DirectoryPicker.prototype#enable_upload
* @type boolean
* @default false
*/
/**
* Specify the root directory for file browsing.
*
* This property defines the topmost directory the file browser widget may
* navigate to. The UI will not allow browsing directories outside this
* prefix. Note that this is merely a cosmetic feature: remote file access
* and directory listing permissions are controlled by the session ACL
* rules.
*
* The default is `/tmp`.
*
* @name LuCI.form.DirectoryPicker.prototype#root_directory
* @type string
* @default /tmp
*/
/**
* Toggle display of hidden files.
*
* Display hidden files when rendering the remote directory listing.
* Note that this is merely a cosmetic feature: hidden files are always
* included in received remote file listings.
*
* The default of `true` means hidden files are displayed.
*
* @name LuCI.form.DirectoryPicker.prototype#show_hidden
* @type boolean
* @default true
*/
/** @private */
renderWidget(section_id, option_index, cfgvalue) {
const browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, {
id: this.cbid(section_id),
name: this.cbid(section_id),
browser: this.browser,
directory_create: this.directory_create,
directory_select: true,
enable_download: this.enable_download,
enable_remove: this.enable_remove,
enable_upload: this.enable_upload,
root_directory: this.root_directory,
show_hidden: this.show_hidden,
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
});
return browserEl.render();
}
});
/**
* @class SectionValue
* @memberof LuCI.form
@@ -5202,5 +5395,6 @@ return baseclass.extend(/** @lends LuCI.form.prototype */ {
Button: CBIButtonValue,
HiddenValue: CBIHiddenValue,
FileUpload: CBIFileUpload,
DirectoryPicker: CBIDirectoryPicker,
SectionValue: CBISectionValue
});

View File

@@ -2921,6 +2921,12 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
* remotely depends on the ACL setup for the current session. This option
* merely controls whether the file remove controls are rendered or not.
*
* @property {boolean} [directory_create=false]
* Specifies whether the widget allows the user to create directories.
*
* @property {boolean} [directory_select=false]
* Specifies whether the widget shall select directories only instead of files.
*
* @property {boolean} [enable_download=false]
* Specifies whether the widget allows the user to download files.
*
@@ -2935,6 +2941,8 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
this.value = value;
this.options = Object.assign({
browser: false,
directory_create: false,
directory_select: false,
show_hidden: false,
enable_upload: true,
enable_remove: true,
@@ -2960,15 +2968,17 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
const renderFileBrowser = L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind((stat) => {
let label;
if (L.isObject(stat) && stat.type != 'directory')
if (L.isObject(stat))
this.stat = stat;
if (this.stat != null)
if (this.stat != null && this.stat.type === 'directory')
label = [ this.iconForType(this.stat.type), ' %s'.format(this.truncatePath(this.stat.path)) ];
else if (this.stat != null && this.stat.type !== 'directory')
label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
else if (this.value != null)
label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
else
label = [ _('Select file…') ];
label = [ this.options.directory_select ? _('Select directory…') : _('Select file…') ];
let btnOpenFileBrowser = E('button', {
'class': 'btn open-file-browser',
'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
@@ -3051,13 +3061,65 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
if (cpath.length <= croot.length)
return [ croot ];
const parts = cpath.substring(croot.length).split(/\//);
const parts = cpath.substring(croot.length).split(/\//).filter(p => p !== '');
parts.unshift(croot);
return parts;
},
/** @private */
handleCreateDirectory(path, ev) {
const container = E('div', { 'class': 'uci-dialog' });
const input = E('input', {
'type': 'text',
'placeholder': _('Directory name'),
'style': 'margin-right: 0.5em'
});
const okBtn = E('button', {
'type': 'button',
'class': 'btn cbi-button',
'click': async () => {
var directoryName = input.value.trim();
if (!directoryName) {
alert(_('Directory name cannot be empty.'));
return;
}
try {
// Assume current upload path (you may need to retrieve or set this yourself)
var basePath = path || '/tmp';
var fullPath = basePath + '/' + directoryName;
await fs.exec('mkdir', ['-p', fullPath]).then(L.bind((path, ev) => {
return this.handleSelect(path, null, ev);
}, this, path, ev));
} catch (err) {
UI.prototype.addTimeLimitedNotification(_('Error'), E('p', _('Failed to create directory: %s').format(err.message)), 5000, 'error');
} finally {
UI.prototype.hideModal();
}
}
}, _('OK'));
var cancelBtn = E('button', {
'type': 'button',
'class': 'btn cbi-button',
'click': () => UI.prototype.hideModal(),
}, _('Cancel'));
container.appendChild(input);
container.appendChild(okBtn);
container.appendChild(cancelBtn);
UI.prototype.showModal(_('Create Directory'), [
container
]);
},
/** @private */
handleUpload(path, list, ev) {
const form = ev.target.parentNode;
@@ -3115,7 +3177,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
const hidden = this.node.lastElementChild;
if (path == hidden.value) {
dom.content(button, _('Select file…'));
dom.content(button, this.options.directory_select ? _('Select directory…') : _('Select file…'));
hidden.value = '';
}
@@ -3196,6 +3258,8 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
E('div', { 'class': 'name' }, [
this.iconForType(list[i].type),
' ',
(this.options.directory_select && list[i].type !== 'directory') ?
list[i].name :
E('a', {
'href': '#',
'style': selected ? 'font-weight:bold' : null,
@@ -3213,6 +3277,11 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
mtime.getSeconds())
]),
E('div', [
(this.options.directory_select && list[i].type === 'directory') ? E('button', {
'class': 'btn cbi-button',
'click': UI.prototype.createHandlerFn(this, 'handleSelect',
entrypath, list[i].type === 'directory' ? list[i] : null)
}, [ _('Select') ]) : '',
selected ? E('button', {
'class': 'btn',
'click': UI.prototype.createHandlerFn(this, 'handleReset')
@@ -3236,7 +3305,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
let cur = '';
for (let i = 0; i < dirs.length; i++) {
cur += dirs[i];
cur = (i === 0 || cur === '/') ? cur + dirs[i] : cur + '/' + dirs[i];
dom.append(breadcrumb, [
i ? ' » ' : '',
E('a', {
@@ -3251,6 +3320,11 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
rows,
E('div', { 'class': 'right' }, [
this.renderUpload(path, list),
(this.options.directory_create) ? E('a', {
'href': '#',
'class': 'btn cbi-button',
'click': UI.prototype.createHandlerFn(this, 'handleCreateDirectory', path)
}, _('Create')) : '',
!this.options.browser ? E('a', {
'href': '#',
'class': 'btn',
@@ -3279,7 +3353,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */
const hidden = this.node.lastElementChild;
hidden.value = '';
dom.content(button, _('Select file…'));
dom.content(button, this.options.directory_select ? _('Select directory…') : _('Select file…'));
this.handleCancel(ev);
},