mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Add condition grouping UI to Advanced Search (#5962)
Render the search as a tree of groups: a root group plus nested search-condition-group elements, each with its own join-mode menu and a remove control. Each condition row gets a "( )" button that wraps it in a new group in place, so further conditions can be added to combine with it under a separate join mode. Switch the builder to rebuild-from-tree -- the DOM is the source of truth and the search's flat conditions (with groupStart/joinMode/groupEnd markers) are regenerated on each edit, so the old conditionID-as-index tracking is gone.
This commit is contained in:
parent
c01def2a85
commit
d808988540
6 changed files with 593 additions and 226 deletions
|
|
@ -37,23 +37,11 @@
|
|||
|
||||
class ZoteroSearch extends SearchElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<groupbox>
|
||||
<caption align="center">
|
||||
<label id="joinModeMenu-label" value="&zotero.search.joinMode.prefix;"/>
|
||||
<menulist id="joinModeMenu" aria-labelledby="joinModeMenu-label" oncommand="this.closest('zoterosearch').updateJoinMode();" native="true">
|
||||
<menupopup>
|
||||
<menuitem label="&zotero.search.joinMode.any;" value="any"/>
|
||||
<menuitem label="&zotero.search.joinMode.all;" value="all" selected="true"/>
|
||||
</menupopup>
|
||||
</menulist>
|
||||
<label value="&zotero.search.joinMode.suffix;"/>
|
||||
</caption>
|
||||
<vbox id="conditions"/>
|
||||
</groupbox>
|
||||
<search-condition-group root="true"/>
|
||||
<hbox id="search-option-checkboxes">
|
||||
<checkbox id="recursiveCheckbox" label="&zotero.search.recursive.label;" oncommand="this.closest('zoterosearch').updateCheckbox('recursive');" native="true"/>
|
||||
<checkbox id="noChildrenCheckbox" label="&zotero.search.noChildren;" oncommand="this.closest('zoterosearch').updateCheckbox('noChildren');" native="true"/>
|
||||
<checkbox id="includeParentsAndChildrenCheckbox" label="&zotero.search.includeParentsAndChildren;" oncommand="this.closest('zoterosearch').updateCheckbox('includeParentsAndChildren');" native="true"/>
|
||||
<checkbox id="recursiveCheckbox" label="&zotero.search.recursive.label;" native="true"/>
|
||||
<checkbox id="noChildrenCheckbox" label="&zotero.search.noChildren;" native="true"/>
|
||||
<checkbox id="includeParentsAndChildrenCheckbox" label="&zotero.search.includeParentsAndChildren;" native="true"/>
|
||||
</hbox>
|
||||
`, ['chrome://zotero/locale/zotero.dtd', 'chrome://zotero/locale/searchbox.dtd']);
|
||||
|
||||
|
|
@ -63,162 +51,211 @@
|
|||
|
||||
set search(val) {
|
||||
this.searchRef = val;
|
||||
|
||||
this.querySelector('#joinModeMenu').removeAttribute('condition');
|
||||
this.querySelector('#joinModeMenu').value = 'all';
|
||||
|
||||
this.querySelector('#recursiveCheckbox').checked = false;
|
||||
this.querySelector('#noChildrenCheckbox').checked = false;
|
||||
this.querySelector('#includeParentsAndChildrenCheckbox').checked = false;
|
||||
|
||||
var conditionsBox = this.querySelector('#conditions');
|
||||
while (conditionsBox.hasChildNodes()) {
|
||||
conditionsBox.removeChild(conditionsBox.firstChild);
|
||||
// Setting condition controls' values during a render shouldn't trigger a
|
||||
// rebuild of the search from the half-built tree
|
||||
this._rendering = true;
|
||||
try {
|
||||
this.renderConditions();
|
||||
}
|
||||
|
||||
var conditions = this.search.getConditions();
|
||||
for (let id in conditions) {
|
||||
let condition = conditions[id];
|
||||
// Checkboxes
|
||||
switch (condition.condition) {
|
||||
case 'recursive':
|
||||
case 'noChildren':
|
||||
case 'includeParentsAndChildren':
|
||||
{
|
||||
let checkbox = condition.condition + 'Checkbox';
|
||||
this.querySelector(`#${checkbox}`).setAttribute('condition', id);
|
||||
this.querySelector(`#${checkbox}`).checked = condition.operator == 'true';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (condition.condition == 'joinMode') {
|
||||
this.querySelector('#joinModeMenu').setAttribute('condition', id);
|
||||
this.querySelector('#joinModeMenu').value = condition.operator;
|
||||
}
|
||||
else {
|
||||
this.addCondition(condition);
|
||||
}
|
||||
finally {
|
||||
this._rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init() {
|
||||
this.rootGroup = this.querySelector('search-condition-group[root]');
|
||||
this.addEventListener('keypress', event => this.handleKeyPress(event));
|
||||
// Re-evaluate which remove buttons are enabled as the conditions change
|
||||
this.addEventListener('input', () => this.updateRemoveButtons());
|
||||
this.addEventListener('command', () => this.updateRemoveButtons());
|
||||
}
|
||||
|
||||
addCondition(ref) {
|
||||
var conditionsBox = this.querySelector('#conditions');
|
||||
var condition = document.createXULElement('zoterosearchcondition');
|
||||
condition.setAttribute('flex', '1');
|
||||
|
||||
conditionsBox.appendChild(condition);
|
||||
|
||||
// Default to an empty 'title' condition
|
||||
if (!ref) {
|
||||
ref = this.search.getCondition(this.search.addCondition("title", "contains", ""));
|
||||
// Build the condition tree (root group, nested groups, and search-global
|
||||
// checkboxes) from the search's flat condition list
|
||||
renderConditions() {
|
||||
var root = this.rootGroup;
|
||||
|
||||
for (let name of ['recursive', 'noChildren', 'includeParentsAndChildren']) {
|
||||
this.querySelector('#' + name + 'Checkbox').checked = false;
|
||||
}
|
||||
|
||||
root.clear();
|
||||
|
||||
// Walk the flat conditions, pushing/popping a group stack on the group
|
||||
// markers. A 'joinMode' applies to the group on top of the stack; anything
|
||||
// else becomes a condition row or a nested group within it.
|
||||
var stack = [root];
|
||||
var conditions = this.search.getConditions();
|
||||
for (let id in conditions) {
|
||||
let condition = conditions[id];
|
||||
switch (condition.condition) {
|
||||
case 'recursive':
|
||||
case 'noChildren':
|
||||
case 'includeParentsAndChildren':
|
||||
this.querySelector('#' + condition.condition + 'Checkbox').checked
|
||||
= condition.operator == 'true';
|
||||
continue;
|
||||
|
||||
case 'joinMode':
|
||||
stack[stack.length - 1].joinMode = condition.operator;
|
||||
continue;
|
||||
|
||||
case 'groupStart': {
|
||||
let group = document.createXULElement('search-condition-group');
|
||||
stack[stack.length - 1].conditionsContainer.appendChild(group);
|
||||
stack.push(group);
|
||||
continue;
|
||||
}
|
||||
|
||||
case 'groupEnd':
|
||||
if (stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
continue;
|
||||
|
||||
default:
|
||||
stack[stack.length - 1].addCondition(condition);
|
||||
}
|
||||
}
|
||||
|
||||
// The root always shows at least one condition, even for an empty search
|
||||
if (!root.conditionsContainer.childElementCount) {
|
||||
root.addCondition();
|
||||
}
|
||||
|
||||
condition.initWithParentAndCondition(this, ref);
|
||||
|
||||
this.updateRemoveButtons();
|
||||
}
|
||||
|
||||
removeCondition(id, focusRemoveButton) {
|
||||
var conditionsBox = this.querySelector('#conditions');
|
||||
// Removing the only remaining condition resets it to the default empty
|
||||
// condition -- the same one shown when the pane is first opened -- rather
|
||||
// than leaving the search with no conditions
|
||||
var resetToDefault = conditionsBox.childNodes.length == 1;
|
||||
// Regenerate the search's flat condition list from the current tree. The DOM is
|
||||
// the source of truth: on any edit we walk the groups in order and rebuild
|
||||
// search._conditions from scratch.
|
||||
updateSearch() {
|
||||
if (this._rendering || !this.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.search.removeCondition(id);
|
||||
let found = false;
|
||||
for (var i = 0, len = conditionsBox.childNodes.length; i < len; i++) {
|
||||
if (conditionsBox.childNodes[i].conditionID == id) {
|
||||
conditionsBox.removeChild(conditionsBox.childNodes[i]);
|
||||
found = true;
|
||||
i--;
|
||||
len--;
|
||||
continue;
|
||||
}
|
||||
// When a condition is removed, ids of remaining conditions
|
||||
// are shifted to remain in arithmetic sequence. The conditionID
|
||||
// of the nodes need to be updated accordingly
|
||||
if (found) {
|
||||
conditionsBox.childNodes[i].conditionID--;
|
||||
var flat = [];
|
||||
this.collectGroup(this.rootGroup, flat, true);
|
||||
|
||||
// Search-global options
|
||||
for (let name of ['recursive', 'noChildren', 'includeParentsAndChildren']) {
|
||||
if (this.querySelector('#' + name + 'Checkbox').checked) {
|
||||
flat.push({ condition: name, operator: 'true', value: null });
|
||||
}
|
||||
}
|
||||
|
||||
if (resetToDefault) {
|
||||
this.addCondition();
|
||||
this.rebuildConditions(flat);
|
||||
}
|
||||
|
||||
// Append a group's serialized form to `flat`. The root contributes its
|
||||
// conditions directly; a nested group is wrapped in groupStart/groupEnd markers.
|
||||
// A 'joinMode' marker is emitted only for 'any'; 'all' is the default and is omitted.
|
||||
collectGroup(group, flat, isRoot) {
|
||||
if (!isRoot) {
|
||||
flat.push({ condition: 'groupStart', operator: 'true', value: '' });
|
||||
}
|
||||
if (group.joinMode == 'any') {
|
||||
flat.push({ condition: 'joinMode', operator: 'any', value: null });
|
||||
}
|
||||
for (let child of group.conditionsContainer.children) {
|
||||
if (child.localName == 'zoterosearchcondition') {
|
||||
let data = child.getConditionData();
|
||||
if (data) {
|
||||
flat.push(data);
|
||||
}
|
||||
}
|
||||
else if (child.localName == 'search-condition-group') {
|
||||
this.collectGroup(child, flat, false);
|
||||
}
|
||||
}
|
||||
if (!isRoot) {
|
||||
flat.push({ condition: 'groupEnd', operator: 'true', value: '' });
|
||||
}
|
||||
}
|
||||
|
||||
rebuildConditions(flat) {
|
||||
var search = this.search;
|
||||
var count = Object.keys(search.getConditions()).length;
|
||||
// removeCondition() renumbers the remaining conditions, so 0 is always the
|
||||
// next one to remove
|
||||
for (let i = 0; i < count; i++) {
|
||||
search.removeCondition(0);
|
||||
}
|
||||
for (let condition of flat) {
|
||||
search.addCondition(condition.condition, condition.operator, condition.value, condition.required);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the remove (-) button on each condition. The root's last remaining
|
||||
// condition can be removed only once it's populated, which resets it to the
|
||||
// default empty condition.
|
||||
updateRemoveButtons() {
|
||||
var rootContainer = this.rootGroup.conditionsContainer;
|
||||
var loneRootCondition = rootContainer.childElementCount == 1
|
||||
&& rootContainer.firstElementChild.localName == 'zoterosearchcondition'
|
||||
? rootContainer.firstElementChild
|
||||
: null;
|
||||
for (let row of this.querySelectorAll('zoterosearchcondition')) {
|
||||
if (row == loneRootCondition && !row.isPopulated()) {
|
||||
row.disableRemoveButton();
|
||||
}
|
||||
else {
|
||||
row.enableRemoveButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a condition row, pruning any groups it empties. The root always keeps
|
||||
// at least one condition, so emptying it resets it to a single empty default.
|
||||
removeRow(row, focusRemoveButton) {
|
||||
var group = row.closest('search-condition-group');
|
||||
// Remember the row's place so focus can move there after a keyboard removal
|
||||
var index = [...group.conditionsContainer.children].indexOf(row);
|
||||
row.remove();
|
||||
// A group left with no conditions is removed, bubbling up toward the root
|
||||
while (!group.isRoot && !group.conditionsContainer.childElementCount) {
|
||||
let parent = group.parentElement.closest('search-condition-group');
|
||||
group.remove();
|
||||
group = parent;
|
||||
}
|
||||
|
||||
var reset = this.ensureNotEmpty();
|
||||
this.updateSearch();
|
||||
this.updateRemoveButtons();
|
||||
if (reset) {
|
||||
// The removed button is gone, so move focus to the new condition's drop-down
|
||||
conditionsBox.firstChild.querySelector('#conditionsmenu').focus();
|
||||
this.rootGroup.conditionsContainer.firstElementChild
|
||||
.querySelector('#conditionsmenu').focus();
|
||||
}
|
||||
else {
|
||||
this.updateRemoveButtons();
|
||||
// After a keyboard removal, move focus to the remove button of the
|
||||
// row that took the removed row's place, or the previous (now last)
|
||||
// row if the last row was removed, so multiple conditions can be
|
||||
// deleted in succession from the keyboard
|
||||
if (focusRemoveButton) {
|
||||
let rows = conditionsBox.childNodes;
|
||||
let row = rows[id] || rows[rows.length - 1];
|
||||
let button = row.querySelector('#remove');
|
||||
// A disabled remove button can't take focus, so fall back to the
|
||||
// row's condition drop-down
|
||||
else if (focusRemoveButton && group.isConnected) {
|
||||
// After a keyboard removal, move focus to the remove button of the row
|
||||
// that took the removed row's place, or the last row if the removed row
|
||||
// was last, so conditions can be deleted in succession from the keyboard
|
||||
let rows = group.conditionsContainer.children;
|
||||
let next = rows[index] || rows[rows.length - 1];
|
||||
// A nested group may now hold that slot; focus its first condition
|
||||
if (next && next.localName == 'search-condition-group') {
|
||||
next = next.querySelector('zoterosearchcondition');
|
||||
}
|
||||
if (next) {
|
||||
let button = next.querySelector('#remove');
|
||||
// A disabled remove button can't take focus, so fall back to the drop-down
|
||||
let target = button.getAttribute('disabled') == 'true'
|
||||
? row.querySelector('#conditionsmenu')
|
||||
? next.querySelector('#conditionsmenu')
|
||||
: button;
|
||||
setTimeout(() => target.focus({ focusVisible: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the remove (-) button on each condition. The last remaining
|
||||
// condition can be removed only when it's populated, which resets it to the
|
||||
// default empty condition.
|
||||
updateRemoveButtons() {
|
||||
var conditionsBox = this.querySelector('#conditions');
|
||||
var rows = conditionsBox.childNodes;
|
||||
for (let row of rows) {
|
||||
if (rows.length > 1 || row.isPopulated()) {
|
||||
row.enableRemoveButton();
|
||||
}
|
||||
else {
|
||||
row.disableRemoveButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateJoinMode() {
|
||||
var menu = this.querySelector('#joinModeMenu');
|
||||
if (menu.hasAttribute('condition')) this.search.updateCondition(menu.getAttribute('condition'), 'joinMode', menu.value, null);
|
||||
else menu.setAttribute('condition', this.search.addCondition('joinMode', menu.value, null));
|
||||
}
|
||||
|
||||
updateCheckbox(condition) {
|
||||
var checkbox = this.querySelector('#' + condition + 'Checkbox');
|
||||
var value = checkbox.checked ? 'true' : 'false';
|
||||
if (checkbox.hasAttribute('condition')) {
|
||||
this.search.updateCondition(checkbox.getAttribute('condition'),
|
||||
condition, value, null);
|
||||
}
|
||||
else {
|
||||
checkbox.setAttribute('condition',
|
||||
this.search.addCondition(condition, value, null));
|
||||
}
|
||||
}
|
||||
|
||||
// Calls updateSearch() on all search conditions
|
||||
updateSearch() {
|
||||
var conditionsBox = this.querySelector('#conditions');
|
||||
if (conditionsBox.hasChildNodes()) {
|
||||
for (var i = 0, len = conditionsBox.childNodes.length; i < len; i++) {
|
||||
conditionsBox.childNodes[i].updateSearch();
|
||||
}
|
||||
// The root always shows at least one condition. Returns true if a default
|
||||
// condition had to be added back.
|
||||
ensureNotEmpty() {
|
||||
if (!this.rootGroup.conditionsContainer.childElementCount) {
|
||||
this.rootGroup.addCondition();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleKeyPress(event) {
|
||||
|
|
@ -232,10 +269,14 @@
|
|||
this.active = true;
|
||||
|
||||
if (event.shiftKey) {
|
||||
this.addCondition();
|
||||
// Move focus to the new row's drop-down so it can be set
|
||||
// from the keyboard
|
||||
this.focusNewCondition();
|
||||
// Add to the group holding the focused control, falling back to the root
|
||||
let group = event.target.closest
|
||||
&& event.target.closest('search-condition-group');
|
||||
let row = (group || this.rootGroup).addCondition();
|
||||
this.updateSearch();
|
||||
this.updateRemoveButtons();
|
||||
// Move focus to the new row's drop-down so it can be set from the keyboard
|
||||
this.focusNewCondition(row);
|
||||
}
|
||||
else {
|
||||
this.doCommand();
|
||||
|
|
@ -244,17 +285,131 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Move focus to the most recently added condition's drop-down. Deferred so
|
||||
// it isn't immediately undone by the platform's own handling of the key
|
||||
// event that triggered the addition (which otherwise keeps focus on the
|
||||
// source element).
|
||||
focusNewCondition() {
|
||||
let menu = this.querySelector('#conditions').lastChild.querySelector('#conditionsmenu');
|
||||
// Move focus to a newly added condition's drop-down. Deferred so it isn't
|
||||
// immediately undone by the platform's own handling of the key event that
|
||||
// triggered the addition (which otherwise keeps focus on the source element).
|
||||
focusNewCondition(row) {
|
||||
let menu = row.querySelector('#conditionsmenu');
|
||||
setTimeout(() => menu.focus({ focusVisible: true }));
|
||||
}
|
||||
}
|
||||
customElements.define("zoterosearch", ZoteroSearch);
|
||||
|
||||
class SearchConditionGroup extends SearchElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<groupbox class="search-condition-group">
|
||||
<caption align="center">
|
||||
<label class="join-mode-prefix" value="&zotero.search.joinMode.prefix;"/>
|
||||
<menulist class="join-mode-menu" native="true" aria-label="&zotero.search.joinMode.prefix;">
|
||||
<menupopup>
|
||||
<menuitem label="&zotero.search.joinMode.any;" value="any"/>
|
||||
<menuitem label="&zotero.search.joinMode.all;" value="all" selected="true"/>
|
||||
</menupopup>
|
||||
</menulist>
|
||||
<label class="join-mode-suffix" value="&zotero.search.joinMode.suffix;"/>
|
||||
<spacer flex="1"/>
|
||||
<toolbarbutton class="remove-group zotero-clicky zotero-clicky-minus" tabindex="0" hidden="true" data-l10n-id="advanced-search-remove-group-btn" onclick="this.closest('search-condition-group').onRemoveGroupClicked()"/>
|
||||
<toolbarbutton class="add-condition zotero-clicky zotero-clicky-plus" tabindex="0" data-l10n-id="advanced-search-add-btn" onclick="this.closest('search-condition-group').onAddSiblingClicked()"/>
|
||||
</caption>
|
||||
<vbox class="conditions"/>
|
||||
</groupbox>
|
||||
`, ['chrome://zotero/locale/zotero.dtd', 'chrome://zotero/locale/searchbox.dtd']);
|
||||
|
||||
init() {
|
||||
this.joinMenu = this.querySelector('.join-mode-menu');
|
||||
this.conditionsContainer = this.querySelector('.conditions');
|
||||
// At init the group has no nested groups yet, so these resolve to its own
|
||||
// caption buttons
|
||||
this.addConditionButton = this.querySelector('.add-condition');
|
||||
this.removeGroupButton = this.querySelector('.remove-group');
|
||||
|
||||
// The root group can't be removed and has no parent to add a sibling into, so
|
||||
// its caption's remove ("-") and add ("+") buttons stay hidden; a nested group
|
||||
// shows both
|
||||
if (this.isRoot) {
|
||||
this.addConditionButton.hidden = true;
|
||||
}
|
||||
else {
|
||||
this.removeGroupButton.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
get isRoot() {
|
||||
return this.hasAttribute('root');
|
||||
}
|
||||
|
||||
get searchElement() {
|
||||
return this.closest('zoterosearch');
|
||||
}
|
||||
|
||||
get search() {
|
||||
return this.searchElement && this.searchElement.search;
|
||||
}
|
||||
|
||||
get joinMode() {
|
||||
return this.joinMenu.value;
|
||||
}
|
||||
|
||||
set joinMode(val) {
|
||||
this.joinMenu.value = val;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.joinMode = 'all';
|
||||
while (this.conditionsContainer.firstChild) {
|
||||
this.conditionsContainer.removeChild(this.conditionsContainer.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a condition row to this group. Inserts before `beforeNode` if given (e.g.
|
||||
// right after the row whose "+" was clicked), otherwise appends.
|
||||
addCondition(ref, beforeNode) {
|
||||
var condition = document.createXULElement('zoterosearchcondition');
|
||||
condition.setAttribute('flex', '1');
|
||||
this.conditionsContainer.insertBefore(condition, beforeNode || null);
|
||||
|
||||
// Default to an empty 'title' condition
|
||||
if (!ref) {
|
||||
ref = { id: undefined, condition: 'title', operator: 'contains', value: '', mode: undefined, required: false };
|
||||
}
|
||||
|
||||
condition.initWithParentAndCondition(this.searchElement, ref);
|
||||
return condition;
|
||||
}
|
||||
|
||||
// "+" in the group caption: add a sibling condition in the parent group, after
|
||||
// this group -- the group's caption row acts as the group's single line item in
|
||||
// its parent, so its "+" mirrors a condition row's "+". The root has no parent,
|
||||
// so its "+" stays hidden.
|
||||
onAddSiblingClicked() {
|
||||
var parent = this.parentElement.closest('search-condition-group');
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
var row = parent.addCondition(null, this.nextElementSibling);
|
||||
var search = this.searchElement;
|
||||
search.updateSearch();
|
||||
search.updateRemoveButtons();
|
||||
row.querySelector('#conditionsmenu').focus();
|
||||
}
|
||||
|
||||
onRemoveGroupClicked() {
|
||||
var search = this.searchElement;
|
||||
var parent = this.parentElement.closest('search-condition-group');
|
||||
this.remove();
|
||||
// Removing a group can leave its parent empty; prune up toward the root
|
||||
while (parent && !parent.isRoot && !parent.conditionsContainer.childElementCount) {
|
||||
let grandparent = parent.parentElement.closest('search-condition-group');
|
||||
parent.remove();
|
||||
parent = grandparent;
|
||||
}
|
||||
search.ensureNotEmpty();
|
||||
search.updateSearch();
|
||||
search.updateRemoveButtons();
|
||||
}
|
||||
}
|
||||
customElements.define("search-condition-group", SearchConditionGroup);
|
||||
|
||||
class ZoteroSearchCondition extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<html:div class="search-condition">
|
||||
|
|
@ -277,6 +432,7 @@
|
|||
<zoterosearchagefield id="value-date-age" class="value-date-age" hidden="true"/>
|
||||
<toolbarbutton id="remove" tabindex="0" data-l10n-id="advanced-search-remove-btn" class="zotero-clicky zotero-clicky-minus" value="-" onclick="this.closest('zoterosearchcondition').onRemoveClicked(event)"/>
|
||||
<toolbarbutton id="add" tabindex="0" data-l10n-id="advanced-search-add-btn" class="zotero-clicky zotero-clicky-plus" value="+" onclick="this.closest('zoterosearchcondition').onAddClicked(event)"/>
|
||||
<toolbarbutton id="group" tabindex="0" data-l10n-id="advanced-search-group-btn" class="zotero-clicky search-group-button" onclick="this.closest('zoterosearchcondition').onGroupClicked(event)"/>
|
||||
</html:div>
|
||||
`, ['chrome://zotero/locale/zotero.dtd', 'chrome://zotero/locale/searchbox.dtd']);
|
||||
|
||||
|
|
@ -686,56 +842,62 @@
|
|||
this.onConditionSelected(menu.value);
|
||||
}
|
||||
|
||||
updateSearch() {
|
||||
if (this.parent && this.parent.search && !this.dontupdate) {
|
||||
var condition = this.selectedCondition;
|
||||
var operator = this.querySelector('#operatorsmenu').value;
|
||||
let value;
|
||||
|
||||
// Regular text field
|
||||
if (!this.querySelector('#valuefield').hidden) {
|
||||
value = this.querySelector('#valuefield').value;
|
||||
|
||||
// Convert datetimes to UTC before saving
|
||||
switch (condition) {
|
||||
case 'accessDate':
|
||||
case 'dateAdded':
|
||||
case 'dateModified':
|
||||
if (Zotero.Date.isSQLDateTime(value)) {
|
||||
value = Zotero.Date.dateToSQL(Zotero.Date.sqlToDate(value), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Append mode to condition
|
||||
if (this.querySelector('#valuefield').mode) {
|
||||
condition += '/' + this.querySelector('#valuefield').mode;
|
||||
}
|
||||
}
|
||||
|
||||
// isInTheLast operator
|
||||
else if (!this.querySelector('#value-date-age').hidden) {
|
||||
value = this.querySelector('#value-date-age').value;
|
||||
}
|
||||
|
||||
// Handle special C1234 and S5678 form for
|
||||
// collections and searches
|
||||
else if (condition == 'collection') {
|
||||
var letter = this.querySelector('#valuemenu').value.substr(0, 1);
|
||||
if (letter == 'C') {
|
||||
condition = 'collection';
|
||||
}
|
||||
else if (letter == 'S') {
|
||||
condition = 'savedSearch';
|
||||
}
|
||||
value = this.querySelector('#valuemenu').value.substr(1);
|
||||
}
|
||||
|
||||
// Regular drop-down menu
|
||||
else {
|
||||
value = this.querySelector('#valuemenu').value;
|
||||
}
|
||||
this.parent.search.updateCondition(this.conditionID, condition, operator, value);
|
||||
// Return this row's current {condition, operator, value} for serialization.
|
||||
// The owning <zoterosearch> collects these across the tree and rebuilds the
|
||||
// search from scratch. Returns null while the row is still being set up.
|
||||
getConditionData() {
|
||||
if (!(this.parent && this.parent.search) || this.dontupdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var condition = this.selectedCondition;
|
||||
var operator = this.querySelector('#operatorsmenu').value;
|
||||
let value;
|
||||
|
||||
// Regular text field
|
||||
if (!this.querySelector('#valuefield').hidden) {
|
||||
value = this.querySelector('#valuefield').value;
|
||||
|
||||
// Convert datetimes to UTC before saving
|
||||
switch (condition) {
|
||||
case 'accessDate':
|
||||
case 'dateAdded':
|
||||
case 'dateModified':
|
||||
if (Zotero.Date.isSQLDateTime(value)) {
|
||||
value = Zotero.Date.dateToSQL(Zotero.Date.sqlToDate(value), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Append mode to condition
|
||||
if (this.querySelector('#valuefield').mode) {
|
||||
condition += '/' + this.querySelector('#valuefield').mode;
|
||||
}
|
||||
}
|
||||
|
||||
// isInTheLast operator
|
||||
else if (!this.querySelector('#value-date-age').hidden) {
|
||||
value = this.querySelector('#value-date-age').value;
|
||||
}
|
||||
|
||||
// Handle special C1234 and S5678 form for
|
||||
// collections and searches
|
||||
else if (condition == 'collection') {
|
||||
var letter = this.querySelector('#valuemenu').value.substr(0, 1);
|
||||
if (letter == 'C') {
|
||||
condition = 'collection';
|
||||
}
|
||||
else if (letter == 'S') {
|
||||
condition = 'savedSearch';
|
||||
}
|
||||
value = this.querySelector('#valuemenu').value.substr(1);
|
||||
}
|
||||
|
||||
// Regular drop-down menu
|
||||
else {
|
||||
value = this.querySelector('#valuemenu').value;
|
||||
}
|
||||
|
||||
return { condition, operator, value };
|
||||
}
|
||||
|
||||
updateMenuCheckboxesRecursive(menu, value) {
|
||||
|
|
@ -859,27 +1021,59 @@
|
|||
onRemoveClicked(event) {
|
||||
if (this.parent) {
|
||||
// A keyboard-synthesized click has detail 0, unlike a mouse click
|
||||
this.parent.removeCondition(this.conditionID, event?.detail === 0);
|
||||
this.parent.removeRow(this, event?.detail === 0);
|
||||
}
|
||||
}
|
||||
|
||||
onAddClicked(event) {
|
||||
event.preventDefault();
|
||||
if (this.parent) {
|
||||
let ref = this.parent.search.getCondition(
|
||||
this.parent.search.addCondition(
|
||||
this.querySelector('#conditionsmenu').getAttribute('data-value'),
|
||||
this.querySelector('#operatorsmenu').value,
|
||||
""
|
||||
)
|
||||
);
|
||||
this.parent.addCondition(ref);
|
||||
// When activated from the keyboard (a synthesized click has detail
|
||||
// 0, unlike a mouse click), move focus to the new row's drop-down
|
||||
if (event.detail === 0) {
|
||||
this.parent.focusNewCondition();
|
||||
}
|
||||
if (!this.parent) {
|
||||
return;
|
||||
}
|
||||
// Add a sibling condition right after this one, seeded with this row's
|
||||
// condition and operator
|
||||
let group = this.closest('search-condition-group');
|
||||
let row = group.addCondition({
|
||||
id: undefined,
|
||||
condition: this.querySelector('#conditionsmenu').getAttribute('data-value'),
|
||||
operator: this.querySelector('#operatorsmenu').value,
|
||||
value: '',
|
||||
mode: undefined,
|
||||
required: false
|
||||
}, this.nextElementSibling);
|
||||
this.parent.updateSearch();
|
||||
this.parent.updateRemoveButtons();
|
||||
// When activated from the keyboard (a synthesized click has detail 0,
|
||||
// unlike a mouse click), move focus to the new row's drop-down
|
||||
if (event.detail === 0) {
|
||||
this.parent.focusNewCondition(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap this condition in a new group in its place, so further conditions can be
|
||||
// added to the group to combine with it under a separate join mode
|
||||
onGroupClicked(event) {
|
||||
event.preventDefault();
|
||||
if (!this.parent) {
|
||||
return;
|
||||
}
|
||||
var group = this.closest('search-condition-group');
|
||||
// Rebuild the condition inside the new group rather than moving the row, since
|
||||
// detaching a custom element wipes its contents
|
||||
var ref;
|
||||
var data = this.getConditionData();
|
||||
if (data) {
|
||||
let [condition, mode] = Zotero.SearchConditions.parseCondition(data.condition);
|
||||
ref = { id: undefined, condition, mode, operator: data.operator, value: data.value, required: false };
|
||||
}
|
||||
var newGroup = document.createXULElement('search-condition-group');
|
||||
group.conditionsContainer.insertBefore(newGroup, this);
|
||||
newGroup.addCondition(ref);
|
||||
this.remove();
|
||||
|
||||
this.parent.updateSearch();
|
||||
this.parent.updateRemoveButtons();
|
||||
newGroup.conditionsContainer.firstElementChild.querySelector('#conditionsmenu').focus();
|
||||
}
|
||||
|
||||
// Whether a value has been entered, used to decide whether the last
|
||||
|
|
|
|||
|
|
@ -902,9 +902,13 @@ first-run-guidance-authorMenu = { -app-name } lets you specify editors and trans
|
|||
first-run-guidance-readAloud = { -app-name } can now read your documents to you using natural-sounding voices.
|
||||
|
||||
advanced-search-remove-btn =
|
||||
.tooltiptext = { general-remove }
|
||||
.tooltiptext = Remove Condition
|
||||
advanced-search-add-btn =
|
||||
.tooltiptext = { general-add }
|
||||
.tooltiptext = Add Condition
|
||||
advanced-search-group-btn =
|
||||
.tooltiptext = Add Condition Group
|
||||
advanced-search-remove-group-btn =
|
||||
.tooltiptext = Remove Group
|
||||
advanced-search-conditions-menu =
|
||||
.aria-label = Search condition
|
||||
.label = { $label }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C2.8 5 2.8 11 7 13C4.6 11 4.6 5 7 3ZM9 3C13.2 5 13.2 11 9 13C11.4 11 11.4 5 9 3Z" fill="context-fill"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
|
@ -12,17 +12,52 @@ zoterosearch {
|
|||
max-height: 33vh;
|
||||
@include macOS-normalize-controls;
|
||||
|
||||
groupbox {
|
||||
.search-condition-group {
|
||||
gap: 8px;
|
||||
// Zero the toolkit groupbox margin so a nested group lines up flush with the
|
||||
// left edge of the condition rows above it (reads as the next condition)
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
& > #conditions {
|
||||
// The conditions container (root keeps id="conditions"; nested groups use the
|
||||
// class) -- a rounded, filled box that nests as groups nest
|
||||
.conditions {
|
||||
min-width: fit-content; // Grow with content, don't overflow
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
// Roomier spacing between items (conditions and nested groups alike) so the
|
||||
// list reads clearly without needing divider lines
|
||||
gap: 12px;
|
||||
background: var(--fill-senary);
|
||||
border: var(--material-border-quinary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// A nested group sits inside its parent's conditions box, indented so its
|
||||
// caption and box line up with the condition controls above it (which are
|
||||
// themselves inset by the row's leading gap). A group is just another item in
|
||||
// the list, so it keeps the same spacing from its siblings as conditions do.
|
||||
.search-condition-group {
|
||||
gap: 4px;
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.remove-group {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The per-condition "group" button (wraps the condition in a new group)
|
||||
.search-group-button {
|
||||
@include svgicon-menu("group-condition", "universal", "16");
|
||||
width: 20px;
|
||||
margin: 0;
|
||||
border: 0 !important;
|
||||
border-radius: 2px;
|
||||
color: var(--fill-secondary);
|
||||
|
||||
.toolbarbutton-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#search-option-checkboxes {
|
||||
|
|
@ -39,6 +74,7 @@ zoterosearch {
|
|||
font: inherit;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
|
||||
label {
|
||||
|
|
@ -73,7 +109,7 @@ zoterosearch {
|
|||
}
|
||||
}
|
||||
|
||||
#joinModeMenu {
|
||||
.join-mode-menu {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ describe("Advanced Search", function () {
|
|||
await zp.toggleAdvancedSearchState('open');
|
||||
pane = deck.pane;
|
||||
searchBox = pane.querySelector('zoterosearch');
|
||||
conditions = searchBox.querySelector('#conditions');
|
||||
conditions = searchBox.querySelector('.conditions');
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
|
|
@ -681,6 +681,22 @@ describe("Advanced Search", function () {
|
|||
});
|
||||
});
|
||||
|
||||
it("should insert a condition right after the row whose + is clicked", function () {
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'contains', 'a');
|
||||
s.addCondition('title', 'contains', 'b');
|
||||
pane.search = s;
|
||||
assert.lengthOf(conditions.children, 2);
|
||||
|
||||
conditions.children[0].onAddClicked({ preventDefault() {} });
|
||||
|
||||
// New (empty) condition lands between the two, not at the end
|
||||
var values = [...conditions.children]
|
||||
.map(row => row.querySelector('#valuefield').value);
|
||||
assert.deepEqual(values, ['a', '', 'b']);
|
||||
});
|
||||
|
||||
describe("Collection", function () {
|
||||
it("should show collections and saved searches", async function () {
|
||||
var col1 = await createDataObject('collection', { name: "A" });
|
||||
|
|
@ -875,7 +891,7 @@ describe("Advanced Search", function () {
|
|||
it("shouldn't appear", async function () {
|
||||
var searchCondition = conditions.firstChild;
|
||||
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
||||
|
||||
|
||||
// Make sure "Saved Search" isn't present
|
||||
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
||||
let menuitem = conditionsMenu.getItemAtIndex(i);
|
||||
|
|
@ -885,5 +901,119 @@ describe("Advanced Search", function () {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Groups", function () {
|
||||
function groupedSearch() {
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'contains', 'foo');
|
||||
s.addCondition('groupStart', 'true', '');
|
||||
s.addCondition('joinMode', 'any');
|
||||
s.addCondition('tag', 'is', 'x');
|
||||
s.addCondition('tag', 'is', 'y');
|
||||
s.addCondition('groupEnd', 'true', '');
|
||||
return s;
|
||||
}
|
||||
|
||||
it("should render a nested group from group markers", function () {
|
||||
pane.search = groupedSearch();
|
||||
|
||||
// Root holds the title row plus one nested group
|
||||
assert.lengthOf(conditions.children, 2);
|
||||
var group = conditions.querySelector('search-condition-group');
|
||||
assert.equal(group.joinMode, 'any');
|
||||
assert.lengthOf(group.conditionsContainer.children, 2);
|
||||
assert.equal(group.conditionsContainer.firstChild.selectedCondition, 'tag');
|
||||
});
|
||||
|
||||
it("should serialize a nested group back to markers", function () {
|
||||
pane.search = groupedSearch();
|
||||
searchBox.updateSearch();
|
||||
|
||||
var sequence = Object.values(searchBox.search.getConditions())
|
||||
.map(c => c.condition);
|
||||
assert.deepEqual(sequence,
|
||||
['title', 'groupStart', 'joinMode', 'tag', 'tag', 'groupEnd']);
|
||||
});
|
||||
|
||||
it("should wrap a condition in a new group in its place", function () {
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'contains', 'foo');
|
||||
pane.search = s;
|
||||
|
||||
conditions.firstChild.onGroupClicked({ preventDefault() {} });
|
||||
|
||||
// The condition is now the sole member of a group in its old place,
|
||||
// keeping its value
|
||||
assert.lengthOf(conditions.children, 1);
|
||||
var group = conditions.querySelector('search-condition-group');
|
||||
assert.lengthOf(group.conditionsContainer.children, 1);
|
||||
var row = group.conditionsContainer.firstChild;
|
||||
assert.equal(row.selectedCondition, 'title');
|
||||
assert.equal(row.querySelector('#valuefield').value, 'foo');
|
||||
});
|
||||
|
||||
it("should remove a group when its last condition is removed", function () {
|
||||
pane.search = groupedSearch();
|
||||
|
||||
var group = conditions.querySelector('search-condition-group');
|
||||
group.conditionsContainer.firstChild.onRemoveClicked();
|
||||
// Removing the group's first tag leaves it with the second
|
||||
assert.ok(conditions.querySelector('search-condition-group'));
|
||||
group.conditionsContainer.firstChild.onRemoveClicked();
|
||||
|
||||
// With no conditions left, the group itself is gone
|
||||
assert.notOk(conditions.querySelector('search-condition-group'));
|
||||
// The root's title condition remains
|
||||
assert.lengthOf(conditions.children, 1);
|
||||
assert.equal(conditions.firstChild.selectedCondition, 'title');
|
||||
});
|
||||
|
||||
it("should reset the root to an empty condition when its last group empties", function () {
|
||||
// A search whose only content is a single group with a single condition
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('groupStart', 'true', '');
|
||||
s.addCondition('tag', 'is', 'x');
|
||||
s.addCondition('groupEnd', 'true', '');
|
||||
pane.search = s;
|
||||
|
||||
var group = conditions.querySelector('search-condition-group');
|
||||
group.conditionsContainer.firstChild.onRemoveClicked();
|
||||
|
||||
// The emptied group is gone and the root falls back to one empty condition
|
||||
assert.notOk(conditions.querySelector('search-condition-group'));
|
||||
assert.lengthOf(conditions.children, 1);
|
||||
assert.equal(conditions.firstChild.selectedCondition, 'title');
|
||||
assert.equal(conditions.firstChild.querySelector('#valuefield').value, '');
|
||||
});
|
||||
|
||||
it("should add a sibling condition outside the group from the group's +", function () {
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'contains', 'foo');
|
||||
pane.search = s;
|
||||
|
||||
// The root has no parent, so its caption "+" stays hidden
|
||||
assert.isTrue(searchBox.rootGroup.addConditionButton.hidden);
|
||||
|
||||
// Wrap the only root condition, so the root now holds just the group
|
||||
conditions.firstChild.onGroupClicked({ preventDefault() {} });
|
||||
assert.lengthOf(conditions.children, 1);
|
||||
var group = conditions.querySelector('search-condition-group');
|
||||
|
||||
// The group's "+" adds a sibling in the parent (root), not inside the group
|
||||
group.onAddSiblingClicked();
|
||||
|
||||
assert.lengthOf(conditions.children, 2);
|
||||
var rows = [...conditions.children]
|
||||
.filter(c => c.localName === 'zoterosearchcondition');
|
||||
assert.lengthOf(rows, 1);
|
||||
assert.equal(rows[0].selectedCondition, 'title');
|
||||
// Nothing was added inside the group
|
||||
assert.lengthOf(group.conditionsContainer.children, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1043,7 +1043,7 @@ describe("ZoteroPane", function () {
|
|||
var c = searchBox.search.getCondition(
|
||||
searchBox.search.addCondition("title", "contains", "foo")
|
||||
);
|
||||
searchBox.addCondition(c);
|
||||
searchBox.rootGroup.addCondition(c);
|
||||
await pane.save();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue