mirror of
https://github.com/cosmocode/dokuwiki-plugin-struct.git
synced 2025-07-25 16:01:54 +00:00
@ -33,6 +33,40 @@ class SearchConfigTest extends StructTest
|
||||
$this->assertEquals(date('Y-m-d'), $searchConfig->applyFilterVars('$DATE(now)$'));
|
||||
}
|
||||
|
||||
public function test_filtervars_nsorid()
|
||||
{
|
||||
global $INFO;
|
||||
|
||||
|
||||
$searchConfig = new SearchConfig([]);
|
||||
|
||||
// normal page
|
||||
$INFO['id'] = 'foo:bar:baz';
|
||||
$this->assertEquals('foo:bar:baz', $searchConfig->applyFilterVars('$NSORID$'));
|
||||
|
||||
// start page: start in namespace
|
||||
$INFO['id'] = 'foo:bar:start';
|
||||
saveWikiText($INFO['id'], 'start page', 'start created');
|
||||
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
|
||||
saveWikiText($INFO['id'], '', 'start page deleted');
|
||||
clearstatcache();
|
||||
|
||||
// start page: same as namespace in namespace
|
||||
$INFO['id'] = 'foo:bar:bar';
|
||||
saveWikiText($INFO['id'], 'start page', 'start created');
|
||||
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
|
||||
saveWikiText($INFO['id'], '', 'start page deleted');
|
||||
clearstatcache();
|
||||
|
||||
// start page: same as namespace in above namespace
|
||||
// incidally this is the same as a normal page
|
||||
$INFO['id'] = 'foo:bar';
|
||||
saveWikiText($INFO['id'], 'start page', 'start created');
|
||||
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
|
||||
saveWikiText($INFO['id'], '', 'start page deleted');
|
||||
clearstatcache();
|
||||
}
|
||||
|
||||
public function test_filtervars_struct()
|
||||
{
|
||||
global $INFO;
|
||||
|
@ -413,7 +413,7 @@ class action_plugin_struct_migration extends ActionPlugin
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fixes)) {
|
||||
if ($fixes !== []) {
|
||||
$fixes = array_map(static fn($set, $key) => "$key = '$set'", $fixes, array_keys($fixes));
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ class helper_plugin_struct_config extends Plugin
|
||||
*/
|
||||
public function parseSort($val)
|
||||
{
|
||||
if (substr($val, 0, 1) == '^') {
|
||||
if (str_starts_with($val, '^')) {
|
||||
return [substr($val, 1), false];
|
||||
}
|
||||
return [$val, true];
|
||||
|
@ -28,6 +28,10 @@ jQuery(function () {
|
||||
this.updateEditor = function () {
|
||||
editor.setText($config.val());
|
||||
};
|
||||
// collapse all nodes except for the new element
|
||||
if(!$config.parents('.new').length) {
|
||||
editor.collapseAll();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -81,7 +81,6 @@ $lang['Exception No data saved'] = 'No data saved';
|
||||
$lang['Exception no sqlite'] = 'The struct plugin requires the sqlite plugin. Please install and enable it.';
|
||||
$lang['Exception column not in table'] = 'There is no column %s in schema %s.';
|
||||
$lang['Exception datefilter'] = 'The filter: \'<code>$Date(%s)$</code>\' contains an unsupported value.';
|
||||
$lang['Warning: no filters for cloud'] = 'Filters are not supported for struct clouds.';
|
||||
$lang['sort'] = 'Sort by this column';
|
||||
$lang['next'] = 'Next page';
|
||||
$lang['prev'] = 'Previous page';
|
||||
|
@ -291,12 +291,12 @@ class Assignments
|
||||
}
|
||||
|
||||
$ans = ':' . cleanID($pattern) . ':';
|
||||
if (substr($pattern, -2) == '**') {
|
||||
if (str_ends_with($pattern, '**')) {
|
||||
// upper namespaces match
|
||||
if (strpos($pns, $ans) === 0) {
|
||||
if (str_starts_with($pns, $ans)) {
|
||||
return true;
|
||||
}
|
||||
} elseif (substr($pattern, -1) == '*') {
|
||||
} elseif (str_ends_with($pattern, '*')) {
|
||||
// namespaces match exact
|
||||
if ($ans == $pns) {
|
||||
return true;
|
||||
|
@ -193,9 +193,9 @@ class Column
|
||||
$files = glob(DOKU_PLUGIN . 'struct/types/*.php');
|
||||
foreach ($files as $file) {
|
||||
$file = basename($file, '.php');
|
||||
if (substr($file, 0, 8) == 'Abstract') continue;
|
||||
if (substr($file, 0, 5) == 'Trait') continue;
|
||||
if (substr($file, 0, 4) == 'Auto') continue;
|
||||
if (str_starts_with($file, 'Abstract')) continue;
|
||||
if (str_starts_with($file, 'Trait')) continue;
|
||||
if (str_starts_with($file, 'Auto')) continue;
|
||||
$map[$file] = 'dokuwiki\\plugin\\struct\\types\\' . $file;
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,8 @@ class ConfigParser
|
||||
'csv' => true,
|
||||
'nesting' => 0,
|
||||
'index' => 0,
|
||||
'classes' => []
|
||||
'classes' => [],
|
||||
'actcol' => -1,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -131,6 +132,11 @@ class ConfigParser
|
||||
case 'classes':
|
||||
$this->config['classes'] = $this->parseClasses($val);
|
||||
break;
|
||||
case 'actcol':
|
||||
case 'actioncol':
|
||||
case 'actioncolumn':
|
||||
$this->config['actcol'] = (int) $val;
|
||||
break;
|
||||
default:
|
||||
$data = ['config' => &$this->config, 'key' => $key, 'val' => $val];
|
||||
$ev = new Event('PLUGIN_STRUCT_CONFIGPARSER_UNKNOWNKEY', $data);
|
||||
|
@ -63,7 +63,7 @@ class SchemaEditor
|
||||
$form->addHTML($this->adminColumn($col->getColref(), $col));
|
||||
}
|
||||
|
||||
// FIXME new one needs to be added dynamically, this is just for testing
|
||||
// Add one new field at the end
|
||||
$form->addHTML($this->adminColumn('new1', new Column($this->schema->getMaxsort() + 10, new Text()), 'new'));
|
||||
|
||||
$form->addHTML('</table>');
|
||||
@ -117,6 +117,7 @@ class SchemaEditor
|
||||
$base = 'schema[' . $key . '][' . $column_id . ']'; // base name for all fields
|
||||
|
||||
$class = $col->isEnabled() ? '' : 'disabled';
|
||||
if ($key === 'new') $class .= ' new';
|
||||
|
||||
$html = "<tr class=\"$class\">";
|
||||
|
||||
|
@ -14,48 +14,40 @@ class SearchCloud extends SearchConfig
|
||||
protected $limit = '';
|
||||
|
||||
/**
|
||||
* Transform the set search parameters into a statement
|
||||
* We do not have pagination in clouds, so we can work with a limit within SQL
|
||||
*
|
||||
* @return array ($sql, $opts) The SQL and parameters to execute
|
||||
* @param int $limit
|
||||
*/
|
||||
public function getSQL()
|
||||
public function setLimit($limit)
|
||||
{
|
||||
if (!$this->columns) throw new StructException('nocolname');
|
||||
$this->limit = " LIMIT $limit";
|
||||
}
|
||||
|
||||
$QB = new QueryBuilder();
|
||||
reset($this->schemas);
|
||||
$schema = current($this->schemas);
|
||||
$datatable = 'data_' . $schema->getTable();
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function runSQLBuilder()
|
||||
{
|
||||
$sqlBuilder = new SearchSQLBuilder();
|
||||
$sqlBuilder->setSelectLatest($this->selectLatest);
|
||||
$sqlBuilder->addSchemas($this->schemas, false);
|
||||
$this->addTagSelector($sqlBuilder);
|
||||
$sqlBuilder->getQueryBuilder()->addGroupByStatement('tag');
|
||||
$sqlBuilder->getQueryBuilder()->addOrderBy('count DESC');
|
||||
$sqlBuilder->addFilters($this->filter);
|
||||
return $sqlBuilder;
|
||||
}
|
||||
|
||||
$QB->addTable($datatable);
|
||||
|
||||
// add conditional page clauses if pid has a value
|
||||
$subAnd = $QB->filters()->whereSubAnd();
|
||||
$subAnd->whereAnd("$datatable.pid = ''");
|
||||
|
||||
$subOr = $subAnd->whereSubOr();
|
||||
$subOr->whereAnd("GETACCESSLEVEL($datatable.pid) > 0");
|
||||
$subOr->whereAnd("PAGEEXISTS($datatable.pid) = 1");
|
||||
$subOr->whereSubOr()
|
||||
->whereAnd('ASSIGNED == 1')
|
||||
->whereSubOr()
|
||||
->whereAnd("$datatable.rid > 0")
|
||||
->whereAnd("ASSIGNED IS NULL");
|
||||
|
||||
// add conditional schema assignment check
|
||||
$QB->addLeftJoin(
|
||||
$datatable,
|
||||
'schema_assignments',
|
||||
'',
|
||||
"$datatable.pid != ''
|
||||
AND $datatable.pid = schema_assignments.pid
|
||||
AND schema_assignments.tbl = '{$schema->getTable()}'"
|
||||
);
|
||||
|
||||
$QB->filters()->whereAnd("$datatable.latest = 1");
|
||||
$QB->filters()->where('AND', 'tag IS NOT \'\'');
|
||||
/**
|
||||
* Add the tag selector to the SQLBuilder
|
||||
*/
|
||||
protected function addTagSelector(SearchSQLBuilder $builder)
|
||||
{
|
||||
$QB = $builder->getQueryBuilder();
|
||||
|
||||
$col = $this->columns[0];
|
||||
$datatable = "data_{$col->getTable()}";
|
||||
|
||||
if ($col->isMulti()) {
|
||||
$multitable = "multi_{$col->getTable()}";
|
||||
$MN = $QB->generateTableAlias('M');
|
||||
@ -77,23 +69,8 @@ class SearchCloud extends SearchConfig
|
||||
$colname = $datatable . '.' . $col->getColName();
|
||||
}
|
||||
$QB->addSelectStatement("COUNT($colname)", 'count');
|
||||
$QB->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
|
||||
$QB->addGroupByStatement('tag');
|
||||
$QB->addOrderBy('count DESC');
|
||||
|
||||
[$sql, $opts] = $QB->getSQL();
|
||||
return [$sql . $this->limit, $opts];
|
||||
}
|
||||
|
||||
/**
|
||||
* We do not have pagination in clouds, so we can work with a limit within SQL
|
||||
*
|
||||
* @param int $limit
|
||||
*/
|
||||
public function setLimit($limit)
|
||||
{
|
||||
$this->limit = " LIMIT $limit";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute this search and return the result
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
namespace dokuwiki\plugin\struct\meta;
|
||||
|
||||
use dokuwiki\File\PageResolver;
|
||||
|
||||
/**
|
||||
* Class SearchConfig
|
||||
*
|
||||
@ -93,9 +95,9 @@ class SearchConfig extends Search
|
||||
foreach ($filters as $filter) {
|
||||
if (is_array($filter)) $filter = $filter[2]; // this is the format we get fro the config parser
|
||||
|
||||
if (strpos($filter, '$USER$') !== false) {
|
||||
if (str_contains($filter, '$USER$')) {
|
||||
$flags |= self::$CACHE_USER;
|
||||
} elseif (strpos($filter, '$TODAY$') !== false) {
|
||||
} elseif (str_contains($filter, '$TODAY$')) {
|
||||
$flags |= self::$CACHE_DATE;
|
||||
}
|
||||
}
|
||||
@ -116,6 +118,7 @@ class SearchConfig extends Search
|
||||
if (!isset($INFO['id'])) {
|
||||
$INFO['id'] = '';
|
||||
}
|
||||
$ns = getNS($INFO['id']);
|
||||
|
||||
// apply inexpensive filters first
|
||||
$filter = str_replace(
|
||||
@ -128,7 +131,7 @@ class SearchConfig extends Search
|
||||
],
|
||||
[
|
||||
$INFO['id'],
|
||||
getNS($INFO['id']),
|
||||
$ns,
|
||||
noNS($INFO['id']),
|
||||
$INPUT->server->str('REMOTE_USER'),
|
||||
date('Y-m-d')
|
||||
@ -136,6 +139,22 @@ class SearchConfig extends Search
|
||||
$filter
|
||||
);
|
||||
|
||||
// apply namespace or id placeholder #712
|
||||
// returns the namespace for start pages, otherwise the ID
|
||||
if (preg_match('/\$NSORID\$/', $filter)) {
|
||||
$resolver = new PageResolver('');
|
||||
|
||||
$start = $resolver->resolveId($ns . ':');
|
||||
if ($start === $INFO['id']) {
|
||||
// This is a start page, we return the namespace
|
||||
$val = $ns;
|
||||
} else {
|
||||
// This is a normal page, we return the ID
|
||||
$val = $INFO['id'];
|
||||
}
|
||||
$filter = str_replace('$NSORID$', $val, $filter);
|
||||
}
|
||||
|
||||
// apply struct column placeholder (we support only one!)
|
||||
// or apply date formula, given as strtotime
|
||||
if (preg_match('/^(.*?)(?:\$STRUCT\.(.*?)\$)(.*?)$/', $filter, $match)) {
|
||||
|
@ -27,8 +27,9 @@ class SearchSQLBuilder
|
||||
* Add the schemas to the query
|
||||
*
|
||||
* @param Schema[] $schemas Schema names to query
|
||||
* @param bool $selectMeta Select meta columns (pid, rid, rev, assigned)
|
||||
*/
|
||||
public function addSchemas($schemas)
|
||||
public function addSchemas($schemas, $selectMeta = true)
|
||||
{
|
||||
// basic tables
|
||||
$first_table = '';
|
||||
@ -60,12 +61,14 @@ class SearchSQLBuilder
|
||||
AND schema_assignments.tbl = '{$schema->getTable()}'"
|
||||
);
|
||||
|
||||
$this->qb->addSelectColumn($datatable, 'rid');
|
||||
$this->qb->addSelectColumn($datatable, 'pid', 'PID');
|
||||
$this->qb->addSelectColumn($datatable, 'rev');
|
||||
$this->qb->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
|
||||
$this->qb->addGroupByColumn($datatable, 'pid');
|
||||
$this->qb->addGroupByColumn($datatable, 'rid');
|
||||
if ($selectMeta) {
|
||||
$this->qb->addSelectColumn($datatable, 'rid');
|
||||
$this->qb->addSelectColumn($datatable, 'pid', 'PID');
|
||||
$this->qb->addSelectColumn($datatable, 'rev');
|
||||
$this->qb->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
|
||||
$this->qb->addGroupByColumn($datatable, 'pid');
|
||||
$this->qb->addGroupByColumn($datatable, 'rid');
|
||||
}
|
||||
|
||||
$first_table = $datatable;
|
||||
}
|
||||
|
@ -168,7 +168,7 @@ class renderer_plugin_struct_csv extends Doku_Renderer
|
||||
|
||||
public function plugin($name, $args, $state = '', $match = '')
|
||||
{
|
||||
if (substr($name, 0, 7) == 'struct_') {
|
||||
if (str_starts_with($name, 'struct_')) {
|
||||
parent::plugin($name, $args, $state, $match);
|
||||
} else {
|
||||
$this->cdata($match);
|
||||
|
@ -5,12 +5,13 @@ jQuery(function () {
|
||||
/* DOKUWIKI:include script/AggregationEditor.js */
|
||||
/* DOKUWIKI:include script/InlineEditor.js */
|
||||
/* DOKUWIKI:include script/StructFilter.js */
|
||||
/* DOKUWIKI:include_once script/vanilla-combobox.js */
|
||||
|
||||
function init() {
|
||||
EntryEditor(jQuery('#dw__editform, form.bureaucracy__plugin'));
|
||||
SchemaEditor();
|
||||
jQuery('div.structaggregationeditor table').each(AggregationEditor);
|
||||
InlineEditor(jQuery('div.structaggregation table'));
|
||||
InlineEditor(jQuery('div.structaggregation table, #plugin__struct_output table'));
|
||||
}
|
||||
|
||||
jQuery(init);
|
||||
|
@ -31,7 +31,7 @@ const AggregationEditor = function (idx, table) {
|
||||
|
||||
// empty header cells
|
||||
if (!rid) {
|
||||
$me.append('<th class="action">' + LANG.plugins.struct.actions + '</th>');
|
||||
insertActionCell($me, '<th class="action">' + LANG.plugins.struct.actions + '</th>');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -68,11 +68,30 @@ const AggregationEditor = function (idx, table) {
|
||||
});
|
||||
|
||||
$td.append($btn);
|
||||
$me.append($td);
|
||||
|
||||
insertActionCell($me, $td);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the action cell at the right position, depending on the actcol setting
|
||||
*
|
||||
* @param {jQuery<HTMLTableRowElement>} $row
|
||||
* @param {jQuery<HTMLTableCellElement>} $cell
|
||||
*/
|
||||
function insertActionCell($row, $cell) {
|
||||
const $children = $row.children();
|
||||
let insertAt = searchconf.actcol;
|
||||
if ( insertAt < 0 ) insertAt = $children.length + 1 + insertAt;
|
||||
|
||||
if(insertAt >= $children.length) {
|
||||
$row.append($cell);
|
||||
} else if (insertAt < 0) {
|
||||
$row.prepend($cell);
|
||||
} else {
|
||||
$children.eq(insertAt).before($cell);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the form for the editor and attaches handlers
|
||||
*
|
||||
|
@ -12,7 +12,8 @@ var InlineEditor = function ($table) {
|
||||
var pid = $self.parent().data('pid');
|
||||
var rid = $self.parent().data('rid');
|
||||
var rev = $self.parent().data('rev');
|
||||
var field = $self.parents('table').find('tr th').eq($self.index()).data('field');
|
||||
var field = $self.parent().data('field') ||
|
||||
$self.parents('table').find('tr th').eq($self.index()).data('field');
|
||||
|
||||
if ((!pid && !rid) || !field) return;
|
||||
|
||||
|
394
script/vanilla-combobox.js
Normal file
394
script/vanilla-combobox.js
Normal file
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* A custom web component that transforms a standard HTML select into a combo box,
|
||||
* allowing users to both select from a dropdown and search by typing.
|
||||
*/
|
||||
/* */
|
||||
class VanillaCombobox extends HTMLElement {
|
||||
#select;
|
||||
#input;
|
||||
#dropdown;
|
||||
#separator;
|
||||
#multiple;
|
||||
#placeholder;
|
||||
#outsideClickListener;
|
||||
|
||||
// region initialization
|
||||
|
||||
/**
|
||||
* Creates a new VanillaCombobox instance.
|
||||
* Initializes the shadow DOM.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode: 'open'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the element is connected to the DOM.
|
||||
* Initializes the component, sets up the shadow DOM, and binds event listeners.
|
||||
* @returns {void}
|
||||
*/
|
||||
connectedCallback() {
|
||||
this.#separator = this.getAttribute('separator') || ',';
|
||||
this.#placeholder = this.getAttribute('placeholder') || '';
|
||||
this.#setupShadowDOM();
|
||||
this.#initializeStyles();
|
||||
this.#updateInputFromSelect();
|
||||
this.#registerEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Most event handlers will be garbage collected when the element is removed from the DOM.
|
||||
* However, we need to remove the outside click listener attached to the document to prevent memory leaks.
|
||||
* @returns {void}
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('click', this.#outsideClickListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the shadow DOM with the necessary elements and styles.
|
||||
* Creates the input field and dropdown container.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#setupShadowDOM() {
|
||||
// Get the select element from the light DOM
|
||||
this.#select = this.querySelector('select');
|
||||
if (!this.#select) {
|
||||
console.error('VanillaCombobox: No select element found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.#multiple = this.#select.multiple;
|
||||
|
||||
// Create the input field
|
||||
this.#input = document.createElement('input');
|
||||
this.#input.type = 'text';
|
||||
this.#input.autocomplete = 'off';
|
||||
this.#input.part = 'input';
|
||||
this.#input.placeholder = this.#placeholder;
|
||||
this.#input.required = this.#select.required;
|
||||
this.#input.disabled = this.#select.disabled;
|
||||
|
||||
// Create the dropdown container
|
||||
this.#dropdown = document.createElement('div');
|
||||
this.#dropdown.className = 'dropdown';
|
||||
this.#dropdown.part = 'dropdown';
|
||||
this.#dropdown.style.display = 'none';
|
||||
|
||||
// Add styles to the shadow DOM
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid FieldText;
|
||||
background-color: Field;
|
||||
color: FieldText;
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.option {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.option:hover, .option.selected {
|
||||
background-color: Highlight;
|
||||
color: HighlightText;
|
||||
}
|
||||
`;
|
||||
|
||||
// Append elements to the shadow DOM
|
||||
this.shadowRoot.appendChild(style);
|
||||
|
||||
this.shadowRoot.appendChild(this.#input);
|
||||
this.shadowRoot.appendChild(this.#dropdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the styles for the combobox components.
|
||||
* Copies styles from browser defaults and applies them to the custom elements.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#initializeStyles() {
|
||||
// create a temporary input element to copy styles from
|
||||
const input = document.createElement('input');
|
||||
this.parentElement.insertBefore(input, this);
|
||||
const defaultStyles = window.getComputedStyle(input);
|
||||
|
||||
// browser default styles
|
||||
const inputStyles = window.getComputedStyle(this.#input);
|
||||
const dropdownStyles = window.getComputedStyle(this.#dropdown);
|
||||
|
||||
// copy styles from the temporary input to the input element
|
||||
for (const property of defaultStyles) {
|
||||
const newValue = defaultStyles.getPropertyValue(property);
|
||||
const oldValue = inputStyles.getPropertyValue(property);
|
||||
if (newValue === oldValue) continue;
|
||||
this.#input.style.setProperty(property, newValue);
|
||||
}
|
||||
this.#input.style.outline = 'none';
|
||||
|
||||
// copy select styles to the dropdown
|
||||
for (const property of defaultStyles) {
|
||||
const newValue = defaultStyles.getPropertyValue(property);
|
||||
const oldValue = dropdownStyles.getPropertyValue(property);
|
||||
if (newValue === oldValue) continue;
|
||||
if (!property.match(/^(border|color|background|font|padding)/)) continue;
|
||||
this.#dropdown.style.setProperty(property, newValue);
|
||||
}
|
||||
this.#dropdown.style.minWidth = `${this.#input.offsetWidth}px`;
|
||||
this.#dropdown.style.borderTop = 'none';
|
||||
|
||||
// remove the temporary input element
|
||||
this.parentElement.removeChild(input);
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region Event Handling
|
||||
|
||||
/**
|
||||
* Registers all event listeners for the combobox.
|
||||
* Sets up input, focus, blur, and keyboard events.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#registerEventListeners() {
|
||||
this.#input.addEventListener('focus', this.#onFocus.bind(this));
|
||||
// Delay to allow click event on dropdown
|
||||
this.#input.addEventListener('blur', () => setTimeout(() => this.#onBlur(), 150));
|
||||
this.#input.addEventListener('input', this.#onInput.bind(this));
|
||||
this.#input.addEventListener('keydown', this.#onKeyDown.bind(this));
|
||||
|
||||
this.#outsideClickListener = (event) => {
|
||||
if (this.contains(event.target) || this.shadowRoot.contains(event.target)) return;
|
||||
this.#closeDropdown();
|
||||
};
|
||||
document.addEventListener('click', this.#outsideClickListener);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles the focus event on the input field.
|
||||
* Updates the values and appends the separator if multiple selection is enabled.
|
||||
* Shows the dropdown with available options.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#onFocus() {
|
||||
this.#updateInputFromSelect();
|
||||
this.#showDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the blur event on the input field.
|
||||
* Closes the dropdown and updates the input field (removes the separator).
|
||||
* Synchronizes the select element with the input value.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#onBlur() {
|
||||
this.#closeDropdown();
|
||||
this.#updateSelectFromInput();
|
||||
this.#updateInputFromSelect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the input event on the input field.
|
||||
* Shows the dropdown with filtered options based on the current input.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#onInput() {
|
||||
this.#showDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation in the dropdown.
|
||||
* Supports arrow keys, Enter, and Escape.
|
||||
* @private
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
* @returns {void}
|
||||
*/
|
||||
#onKeyDown(event) {
|
||||
// Only handle keyboard navigation if dropdown is visible
|
||||
if (this.#dropdown.style.display !== 'block') return;
|
||||
|
||||
const items = this.#dropdown.querySelectorAll('.option');
|
||||
const selectedItem = this.#dropdown.querySelector('.option.selected');
|
||||
let selectedIndex = Array.from(items).indexOf(selectedItem);
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % items.length;
|
||||
this.#highlightItem(items, selectedIndex);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
||||
this.#highlightItem(items, selectedIndex);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedItem) {
|
||||
selectedItem.click();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
this.#dropdown.style.display = 'none';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region Data Handling
|
||||
|
||||
/**
|
||||
* Updates the input field value based on the selected options in the select element.
|
||||
* Joins multiple selections with the separator if multiple selection is enabled.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#updateInputFromSelect() {
|
||||
const selectedOptions = Array.from(this.#select.selectedOptions);
|
||||
|
||||
if (selectedOptions.length > 0) {
|
||||
this.#input.value = selectedOptions
|
||||
.map(option => option.textContent)
|
||||
.join(`${this.#separator} `);
|
||||
} else {
|
||||
this.#input.value = '';
|
||||
}
|
||||
|
||||
// If the input is focused and multiple selection is enabled, append the separator
|
||||
if ((this.shadowRoot.activeElement === this.#input) && this.#multiple && this.#input.value !== '') {
|
||||
this.#input.value += this.#separator + ' ';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all selected options and unselects those whose text is no longer in the input.
|
||||
* Synchronizes the select element with the input field content.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#updateSelectFromInput() {
|
||||
const selectedOptions = Array.from(this.#select.selectedOptions);
|
||||
let inputTexts = [this.#input.value];
|
||||
if (this.#multiple) {
|
||||
inputTexts = this.#input.value.split(this.#separator).map(text => text.trim());
|
||||
}
|
||||
|
||||
selectedOptions.forEach(option => {
|
||||
if (!inputTexts.includes(option.textContent)) {
|
||||
option.selected = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region Dropdown Handling
|
||||
|
||||
/**
|
||||
* Shows the dropdown with options filtered by the current input value.
|
||||
* Creates option elements for each matching option.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#showDropdown() {
|
||||
// get the currently edited value
|
||||
let query = this.#input.value.trim();
|
||||
if (this.#multiple) {
|
||||
query = query.split(this.#separator).pop().trim()
|
||||
}
|
||||
|
||||
// Filter the options matching the input value
|
||||
const options = Array.from(this.#select.options);
|
||||
const filteredOptions = options.filter(
|
||||
option => option.textContent.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
if (filteredOptions.length === 0) {
|
||||
this.#closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the dropdown items
|
||||
this.#dropdown.innerHTML = '';
|
||||
filteredOptions.forEach(option => {
|
||||
if (this.#multiple && option.value === '') return; // Ignore empty options in multiple mode
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = option.textContent;
|
||||
div.className = 'option';
|
||||
div.part = 'option';
|
||||
this.#dropdown.appendChild(div);
|
||||
|
||||
// Add click event to each option
|
||||
div.addEventListener('click', () => this.#selectOption(option));
|
||||
});
|
||||
|
||||
// Show the dropdown
|
||||
this.#dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the dropdown by hiding it.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
#closeDropdown() {
|
||||
this.#dropdown.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights a specific item in the dropdown.
|
||||
* Removes selection from all items and adds it to the specified one.
|
||||
* @private
|
||||
* @param {NodeListOf<Element>} items - The dropdown items
|
||||
* @param {number} index - The index of the item to highlight
|
||||
* @returns {void}
|
||||
*/
|
||||
#highlightItem(items, index) {
|
||||
// Remove selection from all items
|
||||
items.forEach(item => item.classList.remove('selected'));
|
||||
|
||||
// Add selection to current item
|
||||
if (items[index]) {
|
||||
items[index].classList.add('selected');
|
||||
// Ensure the selected item is visible in the dropdown
|
||||
items[index].scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an option from the dropdown.
|
||||
* Updates the select element and input field, then closes the dropdown.
|
||||
* @private
|
||||
* @param {HTMLOptionElement} option - The option to select
|
||||
* @returns {void}
|
||||
*/
|
||||
#selectOption(option) {
|
||||
option.selected = true;
|
||||
this.#updateInputFromSelect();
|
||||
this.#closeDropdown();
|
||||
this.#input.focus();
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define('vanilla-combobox', VanillaCombobox);
|
@ -88,9 +88,6 @@ class syntax_plugin_struct_cloud extends SyntaxPlugin
|
||||
{
|
||||
if ($mode != 'xhtml') return false;
|
||||
if (!$data) return false;
|
||||
if (!empty($data['filter'])) {
|
||||
msg($this->getLang('Warning: no filters for cloud'), -1);
|
||||
}
|
||||
global $INFO, $conf;
|
||||
try {
|
||||
$search = new SearchCloud($data);
|
||||
|
@ -7,6 +7,7 @@
|
||||
* @author Andreas Gohr, Michael Große <dokuwiki@cosmocode.de>
|
||||
*/
|
||||
|
||||
use dokuwiki\plugin\struct\meta\Value;
|
||||
use dokuwiki\Extension\SyntaxPlugin;
|
||||
use dokuwiki\Extension\Event;
|
||||
use dokuwiki\plugin\struct\meta\AccessTable;
|
||||
@ -182,7 +183,18 @@ class syntax_plugin_struct_output extends SyntaxPlugin
|
||||
|
||||
$renderer->tabletbody_open();
|
||||
foreach ($data as $field) {
|
||||
/** @var Value $field */
|
||||
$renderer->tablerow_open();
|
||||
if ($format == 'xhtml') {
|
||||
// add data attributes to the row for inline editing
|
||||
$renderer->doc = substr(trim($renderer->doc), 0, -1); // remove the last >
|
||||
$renderer->doc .= ' data-pid="' . hsc($schemadata->getPid()) . '"';
|
||||
$renderer->doc .= ' data-rev="' . hsc($schemadata->getTimestamp()) . '"';
|
||||
$renderer->doc .= ' data-rid="' . hsc($schemadata->getRid()) . '"';
|
||||
$renderer->doc .= ' data-field="' . hsc($field->getColumn()->getFullQualifiedLabel()) . '"';
|
||||
$renderer->doc .= '">';
|
||||
}
|
||||
|
||||
$renderer->tableheader_open();
|
||||
$renderer->cdata($field->getColumn()->getTranslatedLabel());
|
||||
$renderer->tableheader_close();
|
||||
|
@ -81,7 +81,7 @@ class Decimal extends AbstractMultiBaseType
|
||||
$this->config['thousands']
|
||||
);
|
||||
}
|
||||
if ($this->config['trimzeros'] && (strpos($value, (string) $this->config['decpoint']) !== false)) {
|
||||
if ($this->config['trimzeros'] && (str_contains($value, (string) $this->config['decpoint']))) {
|
||||
$value = rtrim($value, '0');
|
||||
$value = rtrim($value, $this->config['decpoint']);
|
||||
}
|
||||
|
@ -5,7 +5,8 @@ namespace dokuwiki\plugin\struct\types;
|
||||
class Dropdown extends AbstractBaseType
|
||||
{
|
||||
protected $config = [
|
||||
'values' => 'one, two, three'
|
||||
'values' => 'one, two, three',
|
||||
'combobox' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -50,6 +51,10 @@ class Dropdown extends AbstractBaseType
|
||||
}
|
||||
$html .= '</select>';
|
||||
|
||||
if ($this->config['combobox']){
|
||||
$html = "<vanilla-combobox>$html</vanilla-combobox>";
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
@ -84,6 +89,11 @@ class Dropdown extends AbstractBaseType
|
||||
}
|
||||
$html .= '</select> ';
|
||||
$html .= '<small>' . $this->getLang('multidropdown') . '</small>';
|
||||
|
||||
if ($this->config['combobox']){
|
||||
$html = "<vanilla-combobox>$html</vanilla-combobox>";
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,11 @@ use dokuwiki\plugin\struct\meta\Value;
|
||||
|
||||
class Lookup extends Dropdown
|
||||
{
|
||||
protected $config = ['schema' => '', 'field' => ''];
|
||||
protected $config = [
|
||||
'schema' => '',
|
||||
'field' => '',
|
||||
'combobox' => false,
|
||||
];
|
||||
|
||||
/** @var Column caches the referenced column */
|
||||
protected $column;
|
||||
|
@ -31,7 +31,7 @@ class Media extends AbstractBaseType
|
||||
|
||||
[, $mime, ] = mimetype($rawvalue, false);
|
||||
foreach ($allows as $allow) {
|
||||
if (strpos($mime, $allow) === 0) return $rawvalue;
|
||||
if (str_starts_with($mime, $allow)) return $rawvalue;
|
||||
}
|
||||
|
||||
throw new ValidationException('Media mime type', $mime, $this->config['mime']);
|
||||
@ -77,7 +77,7 @@ class Media extends AbstractBaseType
|
||||
// add gallery meta data in XHTML
|
||||
if ($mode == 'xhtml') {
|
||||
[, $mime, ] = mimetype($value, false);
|
||||
if (substr($mime, 0, 6) == 'image/') {
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
$hash = empty($R->info['struct_table_hash']) ? '' : "[gal-" . $R->info['struct_table_hash'] . "]";
|
||||
$html = str_replace('href', "rel=\"lightbox$hash\" href", $html);
|
||||
}
|
||||
|
@ -26,12 +26,14 @@ class Tag extends AbstractMultiBaseType
|
||||
*/
|
||||
public function renderValue($value, \Doku_Renderer $R, $mode)
|
||||
{
|
||||
global $INFO;
|
||||
|
||||
$context = $this->getContext();
|
||||
$filter = SearchConfigParameters::$PARAM_FILTER .
|
||||
'[' . $context->getTable() . '.' . $context->getLabel() . '*~]=' . $value;
|
||||
|
||||
$page = trim($this->config['page']);
|
||||
if (!$page) $page = cleanID($context->getLabel());
|
||||
if (!$page) $page = $INFO['id'];
|
||||
|
||||
$R->internallink($page . '?' . $filter, $value);
|
||||
return true;
|
||||
|
Reference in New Issue
Block a user