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": {
"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": {
"message": "Sort",
"description": "Title of sort column, indicating that the style can be manually sorted by dragging & dropping"
},
"sortLabel": {
"message": "Select a sort to apply to the installed styles",
"description": "Title on the sort select to indicate it is used for sorting entries"
},
"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"
"message": "Click to sort; Shift + click to sort multiple columns",
"description": "Title added to links in the manager page header to inform the user on how to sort the columns"
},
"styleBadRegexp": {
"message": "Regexp is invalid.",

View File

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

View File

@ -128,7 +128,8 @@ Object.assign(handleEvent, {
'.update': 'update',
'.entry-delete': 'delete',
'.entry-configure-usercss': 'config',
'#toggle-actions': 'toggleBulkActions'
'#toggle-actions': 'toggleBulkActions',
'.sortable': 'updateSort'
},
entryClicked(event) {
@ -204,6 +205,12 @@ Object.assign(handleEvent, {
API[json.usercssData ? 'installUsercss' : 'installStyle'](json);
},
updateSort(event) {
event.preventDefault();
sorter.updateSort(event);
removeSelection();
},
delete(event, entry) {
event.preventDefault();
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() {
setTimeout(() => {
$$('link[data-href]').forEach(link => {

View File

@ -46,6 +46,7 @@ const UI = {
},
showStyles: (styles = [], matchUrlIds) => {
UI.addHeaderLabels();
const sorted = sorter.sort({
styles: styles.map(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 => {
const style = entry.styleMeta;
const container = $('.entry-labels', entry);
@ -285,7 +303,7 @@ const UI = {
Object.keys(UI.labels).forEach(item => {
if (UI.labels[item].is({entry, style})) {
const newLabel = label.cloneNode(true);
newLabel.dataset.label = item;
newLabel.dataset.type = item;
newLabel.textContent = UI.labels[item].text;
labels.appendChild(newLabel);
}

View File

@ -211,6 +211,31 @@ a:hover {
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 {
cursor: move;
}
@ -267,6 +292,12 @@ body.dragging .entry:not(.dragging) .entry-name:before {
fill: #000;
}
.header-labels {
margin-left: 16px;
justify-content: center
}
.header-label,
.entry-label {
padding: 2px 4px;
margin-left: 2px;
@ -275,13 +306,15 @@ body.dragging .entry:not(.dragging) .entry-name:before {
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);
color: white;
text-transform: lowercase;
}
.entry-label[data-label="disabled"] {
.header-label[data-type="disabled"],
.entry-label[data-type="disabled"] {
background: rgba(128, 128, 128, .2);
color: #222;
}

View File

@ -1,9 +1,18 @@
/* global installed messageBox t $ $create prefs */
/* global installed t $ prefs */
/* exported sorter */
'use strict';
const sorter = (() => {
// Set up for only one column
const defaultSort = 'title,asc';
const sortOrder = [
'asc',
'desc',
'' // unsorted
];
const sorterType = {
alpha: (a, b) => a < b ? -1 : a === b ? 0 : 1,
number: (a, b) => (a || 0) - (b || 0),
@ -15,115 +24,130 @@ const sorter = (() => {
parse: ({name}) => name,
sorter: sorterType.alpha
},
order: {
text: '#',
parse: ({style}) => style.id,
sorter: sorterType.number,
},
usercss: {
text: 'Usercss',
parse: ({style}) => style.usercssData ? 0 : 1,
sorter: sorterType.number
},
disabled: {
text: '', // added as either "enabled" or "disabled" by the addOptions function
parse: ({style}) => style.enabled ? 1 : 0,
text: t('genericDisabledLabel'), // added as either "enabled" or "disabled" by the addOptions function
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
},
dateInstalled: {
text: t('dateInstalled'),
parse: ({style}) => style.installDate,
parse: ({style}) => style.installDate || '',
sorter: sorterType.number
},
dateUpdated: {
text: t('dateUpdated'),
parse: ({style}) => style.updateDate,
parse: ({style}) => style.updateDate || '',
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 whitespace = /\s+/g;
let columns = 1;
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');
}
let lastSort;
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;
return styles.sort((a, b) => {
let types, direction;
let types, direction, x, y;
let result = 0;
let index = 0;
// multi-sort
while (result === 0 && index < len) {
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;
result = types.sorter(types.parse(a), types.parse(b)) * direction;
result = types.sorter(x, y) * direction;
}
}
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() {
if (!installed) return;
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() {
prefs.subscribe(['manage.newUI.sort'], update);
$('#sorter-help').onclick = showHelp;
addOptions();
// addOptions();
updateColumnCount();
}
return {init, update, sort, updateStripes};
return {init, update, sort, updateSort, updateStripes};
})();