Add multisort to header & labels

This commit is contained in:
Rob Garrison 2018-12-02 00:14:59 -06:00
parent 9368c27990
commit d379e5f34a
6 changed files with 185 additions and 129 deletions

View File

@ -1253,37 +1253,13 @@
"shortcutsNote": { "shortcutsNote": {
"message": "Define keyboard shortcuts" "message": "Define keyboard shortcuts"
}, },
"sortDateNewestFirst": {
"message": "newest first",
"description": "Text added to indicate that sorting a date would add the newest entries at the top"
},
"sortDateOldestFirst": {
"message": "oldest first",
"description": "Text added to indicate that sorting a date would add the oldest entries at the top"
},
"sortHeader": { "sortHeader": {
"message": "Sort", "message": "Sort",
"description": "Title of sort column, indicating that the style can be manually sorted by dragging & dropping" "description": "Title of sort column, indicating that the style can be manually sorted by dragging & dropping"
}, },
"sortLabel": { "sortLabel": {
"message": "Select a sort to apply to the installed styles", "message": "Click to sort; Shift + click to sort multiple columns",
"description": "Title on the sort select to indicate it is used for sorting entries" "description": "Title added to links in the manager page header to inform the user on how to sort the columns"
},
"sortLabelTitleAsc": {
"message": "Title Ascending",
"description": "Text added to option group to indicate a block of options that apply a title ascending (A to Z) sort"
},
"sortLabelTitleDesc": {
"message": "Title Descending",
"description": "Text added to option group to indicate a block of options that apply a title descending (Z to A) sort"
},
"sortStylesHelp": {
"message": "Choose the type of sort to apply to the installed entries from within the sort dropdown. The default setting applies an ascending sort (A to Z) to the entry titles. Sorts within the \"Title Descending\" group will apply a descending sort (Z to A) to the title.\nThere are other presets that will allow sorting the entries by multiple criteria. Think of this like sorting a table with multiple columns and each category in a select (between the plus signs) represents a column, or group.\nFor example, if the setting is \"Enabled (first) + Title\", the entries would sort so that all the enabled entries are sorted to the top of the list, then an entry title ascending sort (A to Z) is applied to both the enabled and disabled entries separately.",
"description": "Text in the minihelp displayed when clicking (i) icon to the right of the sort input field on the Manage styles page"
},
"sortStylesHelpTitle": {
"message": "Sort contents",
"description": "Label for the sort info popup on the Manage styles page"
}, },
"styleBadRegexp": { "styleBadRegexp": {
"message": "Regexp is invalid.", "message": "Regexp is invalid.",

View File

@ -364,7 +364,6 @@
<button id="manage-shortcuts-button"></button> <button id="manage-shortcuts-button"></button>
<button id="sorter-help"></button> <button id="sorter-help"></button>
<input id="manage.newUI" type="checkbox"> <input id="manage.newUI" type="checkbox">
<select id="manage.newUI.sort"></select>
<input id="manage.newUI.faviconsGray" type="checkbox"> <input id="manage.newUI.faviconsGray" type="checkbox">
<input id="manage.newUI.targets" type="number" min="1" max="100" value="3"> <input id="manage.newUI.targets" type="number" min="1" max="100" value="3">
<input id="manage.newUI.favicons" type="checkbox"> <input id="manage.newUI.favicons" type="checkbox">
@ -381,13 +380,15 @@
</svg> </svg>
</a> </a>
</div> </div>
<div class="entry-col header-id">#</div> <a href="#" class="entry-col sortable header-id" data-type="order" i18n-title="sortLabel">#</a>
<div class="entry-col header-state center-text" i18n-text="genericEnabledLabel"></div> <a href="#" class="entry-col sortable header-state center-text" i18n-text="genericEnabledLabel" i18n-title="sortLabel" data-type="disabled"></a>
<div class="entry-col header-name" i18n-text="genericName"></div> <div class="entry-col header-name">
<a href="#" class="sortable" i18n-text="genericName" i18n-title="sortLabel" data-type="title"></a>
</div>
<div class="entry-col header-actions" i18n-text="optionsActions"></div> <div class="entry-col header-actions" i18n-text="optionsActions"></div>
<div class="entry-col header-sort center-text" i18n-text="sortHeader"></div> <div class="entry-col header-sort center-text" i18n-text="sortHeader" i18n-title="sortOrderLabel"></div>
<div class="entry-col header-version">v#</div> <a href="#" class="entry-col sortable header-version" i18n-title="sortLabel" data-type="version">v#</a>
<div class="entry-col header-last-update center-text" i18n-text="searchResultUpdated"></div> <a href="#" class="entry-col sortable header-last-update center-text" i18n-text="searchResultUpdated" i18n-title="sortLabel" data-type="dateUpdated"></a>
<div class="entry-col header-applies-to" i18n-text="appliesLabel"></div> <div class="entry-col header-applies-to" i18n-text="appliesLabel"></div>
</header> </header>
</div> </div>

View File

@ -128,7 +128,8 @@ Object.assign(handleEvent, {
'.update': 'update', '.update': 'update',
'.entry-delete': 'delete', '.entry-delete': 'delete',
'.entry-configure-usercss': 'config', '.entry-configure-usercss': 'config',
'#toggle-actions': 'toggleBulkActions' '#toggle-actions': 'toggleBulkActions',
'.sortable': 'updateSort'
}, },
entryClicked(event) { entryClicked(event) {
@ -204,6 +205,12 @@ Object.assign(handleEvent, {
API[json.usercssData ? 'installUsercss' : 'installStyle'](json); API[json.usercssData ? 'installUsercss' : 'installStyle'](json);
}, },
updateSort(event) {
event.preventDefault();
sorter.updateSort(event);
removeSelection();
},
delete(event, entry) { delete(event, entry) {
event.preventDefault(); event.preventDefault();
const id = entry.styleId; const id = entry.styleId;
@ -427,6 +434,17 @@ function updateBulkFilters({target}) {
} }
} }
function removeSelection() {
const sel = window.getSelection ? window.getSelection() : document.selection;
if (sel) {
if (sel.removeAllRanges) {
sel.removeAllRanges();
} else if (sel.empty) {
sel.empty();
}
}
}
function lazyLoad() { function lazyLoad() {
setTimeout(() => { setTimeout(() => {
$$('link[data-href]').forEach(link => { $$('link[data-href]').forEach(link => {

View File

@ -46,6 +46,7 @@ const UI = {
}, },
showStyles: (styles = [], matchUrlIds) => { showStyles: (styles = [], matchUrlIds) => {
UI.addHeaderLabels();
const sorted = sorter.sort({ const sorted = sorter.sort({
styles: styles.map(style => ({ styles: styles.map(style => ({
style, style,
@ -275,6 +276,23 @@ const UI = {
} }
}, },
addHeaderLabels: () => {
const header = $('.header-name');
const labels = document.createElement('span');
const label = document.createElement('a');
labels.className = 'header-labels';
label.className = 'header-label sortable';
label.title = t('sortLabel');
label.href = '#';
Object.keys(UI.labels).forEach(item => {
const newLabel = label.cloneNode(true);
newLabel.dataset.type = item;
newLabel.textContent = UI.labels[item].text;
labels.appendChild(newLabel);
});
header.appendChild(labels);
},
addLabels: entry => { addLabels: entry => {
const style = entry.styleMeta; const style = entry.styleMeta;
const container = $('.entry-labels', entry); const container = $('.entry-labels', entry);
@ -285,7 +303,7 @@ const UI = {
Object.keys(UI.labels).forEach(item => { Object.keys(UI.labels).forEach(item => {
if (UI.labels[item].is({entry, style})) { if (UI.labels[item].is({entry, style})) {
const newLabel = label.cloneNode(true); const newLabel = label.cloneNode(true);
newLabel.dataset.label = item; newLabel.dataset.type = item;
newLabel.textContent = UI.labels[item].text; newLabel.textContent = UI.labels[item].text;
labels.appendChild(newLabel); labels.appendChild(newLabel);
} }

View File

@ -211,6 +211,31 @@ a:hover {
margin-right: 8px; margin-right: 8px;
} }
.entry-header:not(.sortable) {
cursor: default;
}
.sortable {
align-self: center;
cursor: pointer;
text-decoration: none;
}
.sortable[data-sort-dir="asc"]:after,
.sortable[data-sort-dir="desc"]:after {
font-size: 16px;
position: relative;
top: 2px;
}
.sortable[data-sort-dir="asc"]:after {
content: '▴';
}
.sortable[data-sort-dir="desc"]:after {
content: '▾';
}
.entry-sort { .entry-sort {
cursor: move; cursor: move;
} }
@ -267,6 +292,12 @@ body.dragging .entry:not(.dragging) .entry-name:before {
fill: #000; fill: #000;
} }
.header-labels {
margin-left: 16px;
justify-content: center
}
.header-label,
.entry-label { .entry-label {
padding: 2px 4px; padding: 2px 4px;
margin-left: 2px; margin-left: 2px;
@ -275,13 +306,15 @@ body.dragging .entry:not(.dragging) .entry-name:before {
text-transform: lowercase; text-transform: lowercase;
} }
.entry-label[data-label="usercss"] { .header-label[data-type="usercss"],
.entry-label[data-type="usercss"] {
background-color: hsla(180, 100%, 20%, 1); background-color: hsla(180, 100%, 20%, 1);
color: white; color: white;
text-transform: lowercase; text-transform: lowercase;
} }
.entry-label[data-label="disabled"] { .header-label[data-type="disabled"],
.entry-label[data-type="disabled"] {
background: rgba(128, 128, 128, .2); background: rgba(128, 128, 128, .2);
color: #222; color: #222;
} }

View File

@ -1,9 +1,18 @@
/* global installed messageBox t $ $create prefs */ /* global installed t $ prefs */
/* exported sorter */ /* exported sorter */
'use strict'; 'use strict';
const sorter = (() => { const sorter = (() => {
// Set up for only one column
const defaultSort = 'title,asc';
const sortOrder = [
'asc',
'desc',
'' // unsorted
];
const sorterType = { const sorterType = {
alpha: (a, b) => a < b ? -1 : a === b ? 0 : 1, alpha: (a, b) => a < b ? -1 : a === b ? 0 : 1,
number: (a, b) => (a || 0) - (b || 0), number: (a, b) => (a || 0) - (b || 0),
@ -15,115 +24,130 @@ const sorter = (() => {
parse: ({name}) => name, parse: ({name}) => name,
sorter: sorterType.alpha sorter: sorterType.alpha
}, },
order: {
text: '#',
parse: ({style}) => style.id,
sorter: sorterType.number,
},
usercss: { usercss: {
text: 'Usercss', text: 'Usercss',
parse: ({style}) => style.usercssData ? 0 : 1, parse: ({style}) => style.usercssData ? 0 : 1,
sorter: sorterType.number sorter: sorterType.number
}, },
disabled: { disabled: {
text: '', // added as either "enabled" or "disabled" by the addOptions function text: t('genericDisabledLabel'), // added as either "enabled" or "disabled" by the addOptions function
parse: ({style}) => style.enabled ? 1 : 0, parse: ({style}) => style.enabled ? 0 : 1,
sorter: sorterType.number
},
version: {
text: '#',
parse: ({style}) => (style.usercssData && style.usercssData.version || '')
.split(/[.-]/)
.splice(0, 3) // ignore extra labels; e.g. 1.2.3-beta.1
.map(n => n ? n.padStart(4, '0') : '')
.join(''),
sorter: sorterType.number sorter: sorterType.number
}, },
dateInstalled: { dateInstalled: {
text: t('dateInstalled'), text: t('dateInstalled'),
parse: ({style}) => style.installDate, parse: ({style}) => style.installDate || '',
sorter: sorterType.number sorter: sorterType.number
}, },
dateUpdated: { dateUpdated: {
text: t('dateUpdated'), text: t('dateUpdated'),
parse: ({style}) => style.updateDate, parse: ({style}) => style.updateDate || '',
sorter: sorterType.number sorter: sorterType.number
} }
}; };
// Adding (assumed) most commonly used ('title,asc' should always be first)
// whitespace before & after the comma is ignored
const selectOptions = [
'{groupAsc}',
'title,asc',
'dateInstalled,desc, title,asc',
'dateInstalled,asc, title,asc',
'dateUpdated,desc, title,asc',
'dateUpdated,asc, title,asc',
'usercss,asc, title,asc',
'usercss,desc, title,asc',
'disabled,asc, title,asc',
'disabled,desc, title,asc',
'disabled,desc, usercss,asc, title,asc',
'{groupDesc}',
'title,desc',
'usercss,asc, title,desc',
'usercss,desc, title,desc',
'disabled,desc, title,desc',
'disabled,desc, usercss,asc, title,desc'
];
const splitRegex = /\s*,\s*/; const splitRegex = /\s*,\s*/;
const whitespace = /\s+/g;
let columns = 1; let columns = 1;
let lastSort;
function addOptions() {
let container;
const select = $('#manage.newUI.sort');
const renderBin = document.createDocumentFragment();
const option = $create('option');
const optgroup = $create('optgroup');
const meta = {
desc: ' \u21E9',
enabled: t('genericEnabledLabel'),
disabled: t('genericDisabledLabel'),
dateNew: ` (${t('sortDateNewestFirst')})`,
dateOld: ` (${t('sortDateOldestFirst')})`,
groupAsc: t('sortLabelTitleAsc'),
groupDesc: t('sortLabelTitleDesc')
};
const optgroupRegex = /\{\w+\}/;
selectOptions.forEach(sort => {
if (optgroupRegex.test(sort)) {
if (container) {
renderBin.appendChild(container);
}
container = optgroup.cloneNode();
container.label = meta[sort.substring(1, sort.length - 1)];
return;
}
let lastTag = '';
const opt = option.cloneNode();
opt.textContent = sort.split(splitRegex).reduce((acc, val) => {
if (tagData[val]) {
lastTag = val;
return acc + (acc !== '' ? ' + ' : '') + tagData[val].text;
}
if (lastTag.indexOf('date') > -1) return acc + meta[val === 'desc' ? 'dateNew' : 'dateOld'];
if (lastTag === 'disabled') return acc + meta[val === 'desc' ? 'enabled' : 'disabled'];
return acc + (meta[val] || '');
}, '');
opt.value = sort;
container.appendChild(opt);
});
renderBin.appendChild(container);
select.appendChild(renderBin);
select.value = prefs.get('manage.newUI.sort');
}
function sort({styles}) { function sort({styles}) {
const sortBy = prefs.get('manage.newUI.sort').split(splitRegex); let sortBy = prefs.get('manage.newUI.sort').replace(whitespace, '');
if (lastSort === sortBy) {
return styles;
}
sortBy = sortBy.split(splitRegex);
if (sortBy.join('') === '') {
sortBy = defaultSort.split(splitRegex);
}
updateHeaders(sortBy);
const len = sortBy.length; const len = sortBy.length;
return styles.sort((a, b) => { return styles.sort((a, b) => {
let types, direction; let types, direction, x, y;
let result = 0; let result = 0;
let index = 0; let index = 0;
// multi-sort // multi-sort
while (result === 0 && index < len) { while (result === 0 && index < len) {
types = tagData[sortBy[index++]]; types = tagData[sortBy[index++]];
x = types.parse(a);
y = types.parse(b);
// sort empty values to the bottom
if (x === '') {
result = 1;
} else if (y === '') {
result = -1;
} else {
direction = sortBy[index++] === 'asc' ? 1 : -1; direction = sortBy[index++] === 'asc' ? 1 : -1;
result = types.sorter(types.parse(a), types.parse(b)) * direction; result = types.sorter(x, y) * direction;
}
} }
return result; return result;
}); });
} }
// Update default sort on init & when all other columns are unsorted
function updateHeaders(sortBy) {
let header, sortDir;
let i = 0;
const len = sortBy.length;
while (i < len) {
header = $(`.entry-header [data-type="${sortBy[i++]}"]`);
sortDir = sortBy[i++];
if (header) {
header.dataset.sortDir = sortDir;
}
}
}
function updateSort(event) {
const sortables = $$('.entry-header .sortable');
const elm = event.target;
// default sort column only allows asc, desc; not unsorted
const len = sortOrder.length - (elm.dataset.type === defaultSort.split(splitRegex)[0] ? 1 : 0);
let index = (sortOrder.indexOf(elm.dataset.sortDir) + 1) % len;
// shift key for multi-column sorting
if (!event.shiftKey) {
sortables.forEach(el => {
el.dataset.sortDir = '';
el.dataset.timestamp = '';
});
}
elm.dataset.sortDir = sortOrder[index];
elm.dataset.timestamp = Date.now();
const newSort = sortables
.filter(el => el.dataset.sortDir !== '')
.reduce((acc, el) => {
const {sortDir, type, timestamp = new Date()} = el.dataset;
if (sortDir) {
acc.push({sortDir, type, timestamp: parseFloat(timestamp)});
}
return acc;
}, [])
.sort((a, b) => a.timestamp - b.timestamp)
.reduce((acc, item) => {
acc = acc.concat(item.type, item.sortDir);
return acc;
}, [])
.join(',');
prefs.set('manage.newUI.sort', newSort || defaultSort);
}
function update() { function update() {
if (!installed) return; if (!installed) return;
const current = [...installed.children]; const current = [...installed.children];
@ -176,25 +200,11 @@ const sorter = (() => {
} }
} }
function showHelp(event) {
event.preventDefault();
messageBox({
className: 'help-text',
title: t('sortStylesHelpTitle'),
contents:
$create('div',
t('sortStylesHelp').split('\n').map(line =>
$create('p', line))),
buttons: [t('confirmOK')],
});
}
function init() { function init() {
prefs.subscribe(['manage.newUI.sort'], update); prefs.subscribe(['manage.newUI.sort'], update);
$('#sorter-help').onclick = showHelp; // addOptions();
addOptions();
updateColumnCount(); updateColumnCount();
} }
return {init, update, sort, updateStripes}; return {init, update, sort, updateSort, updateStripes};
})(); })();