mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
v1.209.0
This commit is contained in:
826
js/filter/filter-box.js
Normal file
826
js/filter/filter-box.js
Normal file
@@ -0,0 +1,826 @@
|
||||
import {EVNT_VALCHANGE, SOURCE_HEADER, SUB_HASH_PREFIX_LENGTH, TITLE_BTN_RESET} from "./filter-constants.js";
|
||||
import {FilterRegistry} from "./filter-registry.js";
|
||||
|
||||
export class FilterBox extends ProxyBase {
|
||||
static selectFirstVisible (entryList) {
|
||||
if (Hist.lastLoadedId == null && !Hist.initialLoad) {
|
||||
Hist._freshLoad();
|
||||
}
|
||||
|
||||
// This version deemed too annoying to be of practical use
|
||||
// Instead of always loading the URL, this would switch to the first visible item that matches the filter
|
||||
/*
|
||||
if (Hist.lastLoadedId && !Hist.initialLoad) {
|
||||
const last = entryList[Hist.lastLoadedId];
|
||||
const lastHash = UrlUtil.autoEncodeHash(last);
|
||||
const link = $("#listcontainer").find(`.list a[href="#${lastHash.toLowerCase()}"]`);
|
||||
if (!link.length) Hist._freshLoad();
|
||||
} else if (Hist.lastLoadedId == null && !Hist.initialLoad) {
|
||||
Hist._freshLoad();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param [opts.$wrpFormTop] Form input group.
|
||||
* @param opts.$btnReset Form reset button.
|
||||
* @param [opts.$btnOpen] A custom button to use to open the filter overlay.
|
||||
* @param [opts.$iptSearch] Search input associated with the "form" this filter is a part of. Only used for passing
|
||||
* through search terms in @filter tags.
|
||||
* @param [opts.$wrpMiniPills] Element to house mini pills.
|
||||
* @param [opts.$btnToggleSummaryHidden] Button which toggles the filter summary.
|
||||
* @param opts.filters Array of filters to be included in this box.
|
||||
* @param [opts.isCompact] True if this box should have a compact/reduced UI.
|
||||
* @param [opts.namespace] Namespace for this filter, to prevent collisions with other filters on the same page.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super();
|
||||
|
||||
this._$iptSearch = opts.$iptSearch;
|
||||
this._$wrpFormTop = opts.$wrpFormTop;
|
||||
this._$btnReset = opts.$btnReset;
|
||||
this._$btnOpen = opts.$btnOpen;
|
||||
this._$wrpMiniPills = opts.$wrpMiniPills;
|
||||
this._$btnToggleSummaryHidden = opts.$btnToggleSummaryHidden;
|
||||
this._filters = opts.filters;
|
||||
this._isCompact = opts.isCompact;
|
||||
this._namespace = opts.namespace;
|
||||
|
||||
this._doSaveStateThrottled = MiscUtil.throttle(() => this._pDoSaveState(), 50);
|
||||
this.__meta = this._getDefaultMeta();
|
||||
if (this._isCompact) this.__meta.isSummaryHidden = true;
|
||||
|
||||
this._meta = this._getProxy("meta", this.__meta);
|
||||
this.__minisHidden = {};
|
||||
this._minisHidden = this._getProxy("minisHidden", this.__minisHidden);
|
||||
this.__combineAs = {};
|
||||
this._combineAs = this._getProxy("combineAs", this.__combineAs);
|
||||
this._modalMeta = null;
|
||||
this._isRendered = false;
|
||||
|
||||
this._cachedState = null;
|
||||
|
||||
this._compSearch = BaseComponent.fromObject({search: ""});
|
||||
this._metaIptSearch = null;
|
||||
|
||||
this._filters.forEach(f => f.filterBox = this);
|
||||
|
||||
this._eventListeners = {};
|
||||
}
|
||||
|
||||
get filters () { return this._filters; }
|
||||
|
||||
teardown () {
|
||||
this._filters.forEach(f => f._doTeardown());
|
||||
if (this._modalMeta) this._modalMeta.doTeardown();
|
||||
}
|
||||
|
||||
// region Event listeners
|
||||
on (identifier, fn) {
|
||||
const [eventName, namespace] = identifier.split(".");
|
||||
(this._eventListeners[eventName] = this._eventListeners[eventName] || []).push({namespace, fn});
|
||||
return this;
|
||||
}
|
||||
|
||||
off (identifier, fn = null) {
|
||||
const [eventName, namespace] = identifier.split(".");
|
||||
this._eventListeners[eventName] = (this._eventListeners[eventName] || []).filter(it => {
|
||||
if (fn != null) return it.namespace !== namespace || it.fn !== fn;
|
||||
return it.namespace !== namespace;
|
||||
});
|
||||
if (!this._eventListeners[eventName].length) delete this._eventListeners[eventName];
|
||||
return this;
|
||||
}
|
||||
|
||||
fireChangeEvent () {
|
||||
this._doSaveStateThrottled();
|
||||
this.fireEvent(EVNT_VALCHANGE);
|
||||
}
|
||||
|
||||
fireEvent (eventName) {
|
||||
(this._eventListeners[eventName] || []).forEach(it => it.fn());
|
||||
}
|
||||
// endregion
|
||||
|
||||
_getNamespacedStorageKey () { return `${FilterBox._STORAGE_KEY}${this._namespace ? `.${this._namespace}` : ""}`; }
|
||||
getNamespacedHashKey (k) { return `${k || "_".repeat(SUB_HASH_PREFIX_LENGTH)}${this._namespace ? `.${this._namespace}` : ""}`; }
|
||||
|
||||
async pGetStoredActiveSources () {
|
||||
const stored = await StorageUtil.pGetForPage(this._getNamespacedStorageKey());
|
||||
if (stored) {
|
||||
const sourceFilterData = stored.filters[SOURCE_HEADER];
|
||||
if (sourceFilterData) {
|
||||
const state = sourceFilterData.state;
|
||||
const blue = [];
|
||||
const white = [];
|
||||
Object.entries(state).forEach(([src, mode]) => {
|
||||
if (mode === 1) blue.push(src);
|
||||
else if (mode !== -1) white.push(src);
|
||||
});
|
||||
if (blue.length) return blue; // if some are selected, we load those
|
||||
else return white; // otherwise, we load non-red
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
registerMinisHiddenHook (prop, hook) {
|
||||
this._addHook("minisHidden", prop, hook);
|
||||
}
|
||||
|
||||
isMinisHidden (header) {
|
||||
return !!this._minisHidden[header];
|
||||
}
|
||||
|
||||
async pDoLoadState () {
|
||||
const toLoad = await StorageUtil.pGetForPage(this._getNamespacedStorageKey());
|
||||
if (toLoad == null) return;
|
||||
this._setStateFromLoaded(toLoad, {isUserSavedState: true});
|
||||
}
|
||||
|
||||
_setStateFromLoaded (state, {isUserSavedState = false} = {}) {
|
||||
state.box = state.box || {};
|
||||
this._proxyAssign("meta", "_meta", "__meta", state.box.meta || {}, true);
|
||||
this._proxyAssign("minisHidden", "_minisHidden", "__minisHidden", state.box.minisHidden || {}, true);
|
||||
this._proxyAssign("combineAs", "_combineAs", "__combineAs", state.box.combineAs || {}, true);
|
||||
this._filters.forEach(it => it.setStateFromLoaded(state.filters, {isUserSavedState}));
|
||||
}
|
||||
|
||||
_getSaveableState () {
|
||||
const filterOut = {};
|
||||
this._filters.forEach(it => Object.assign(filterOut, it.getSaveableState()));
|
||||
return {
|
||||
box: {
|
||||
meta: {...this.__meta},
|
||||
minisHidden: {...this.__minisHidden},
|
||||
combineAs: {...this.__combineAs},
|
||||
},
|
||||
filters: filterOut,
|
||||
};
|
||||
}
|
||||
|
||||
async _pDoSaveState () {
|
||||
await StorageUtil.pSetForPage(this._getNamespacedStorageKey(), this._getSaveableState());
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
this._filters.forEach(f => f.trimState_());
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this._isRendered) {
|
||||
// already rendered previously; simply update the filters
|
||||
this._filters.map(f => f.update());
|
||||
return;
|
||||
}
|
||||
this._isRendered = true;
|
||||
|
||||
if (this._$wrpFormTop || this._$wrpMiniPills) {
|
||||
if (!this._$wrpMiniPills) {
|
||||
this._$wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`).insertAfter(this._$wrpFormTop);
|
||||
} else {
|
||||
this._$wrpMiniPills.addClass("fltr__mini-view");
|
||||
}
|
||||
}
|
||||
|
||||
if (this._$btnReset) {
|
||||
this._$btnReset
|
||||
.title(TITLE_BTN_RESET)
|
||||
.click((evt) => this.reset(evt.shiftKey));
|
||||
}
|
||||
|
||||
if (this._$wrpFormTop || this._$btnToggleSummaryHidden) {
|
||||
if (!this._$btnToggleSummaryHidden) {
|
||||
this._$btnToggleSummaryHidden = $(`<button class="btn btn-default ${this._isCompact ? "p-2" : ""}" title="Toggle Filter Summary"><span class="glyphicon glyphicon-resize-small"></span></button>`)
|
||||
.prependTo(this._$wrpFormTop);
|
||||
} else if (!this._$btnToggleSummaryHidden.parent().length) {
|
||||
this._$btnToggleSummaryHidden.prependTo(this._$wrpFormTop);
|
||||
}
|
||||
this._$btnToggleSummaryHidden
|
||||
.click(() => {
|
||||
this._meta.isSummaryHidden = !this._meta.isSummaryHidden;
|
||||
this._doSaveStateThrottled();
|
||||
});
|
||||
const summaryHiddenHook = () => {
|
||||
this._$btnToggleSummaryHidden.toggleClass("active", !!this._meta.isSummaryHidden);
|
||||
this._$wrpMiniPills.toggleClass("ve-hidden", !!this._meta.isSummaryHidden);
|
||||
};
|
||||
this._addHook("meta", "isSummaryHidden", summaryHiddenHook);
|
||||
summaryHiddenHook();
|
||||
}
|
||||
|
||||
if (this._$wrpFormTop || this._$btnOpen) {
|
||||
if (!this._$btnOpen) {
|
||||
this._$btnOpen = $(`<button class="btn btn-default ${this._isCompact ? "px-2" : ""}">Filter</button>`)
|
||||
.prependTo(this._$wrpFormTop);
|
||||
} else if (!this._$btnOpen.parent().length) {
|
||||
this._$btnOpen.prependTo(this._$wrpFormTop);
|
||||
}
|
||||
this._$btnOpen.click(() => this.show());
|
||||
}
|
||||
|
||||
const sourceFilter = this._filters.find(it => it.header === SOURCE_HEADER);
|
||||
if (sourceFilter) {
|
||||
const selFnAlt = (val) => !SourceUtil.isNonstandardSource(val) && !PrereleaseUtil.hasSourceJson(val) && !BrewUtil2.hasSourceJson(val);
|
||||
const hkSelFn = () => {
|
||||
if (this._meta.isBrewDefaultHidden) sourceFilter.setTempFnSel(selFnAlt);
|
||||
else sourceFilter.setTempFnSel(null);
|
||||
sourceFilter.updateMiniPillClasses();
|
||||
};
|
||||
this._addHook("meta", "isBrewDefaultHidden", hkSelFn);
|
||||
hkSelFn();
|
||||
}
|
||||
|
||||
if (this._$wrpMiniPills) this._filters.map((f, i) => f.$renderMinis({filterBox: this, isFirst: i === 0, $wrpMini: this._$wrpMiniPills}));
|
||||
}
|
||||
|
||||
async _render_pRenderModal () {
|
||||
this._isModalRendered = true;
|
||||
|
||||
this._modalMeta = await UiUtil.pGetShowModal({
|
||||
isHeight100: true,
|
||||
isWidth100: true,
|
||||
isUncappedHeight: true,
|
||||
isIndestructible: true,
|
||||
isClosed: true,
|
||||
isEmpty: true,
|
||||
title: "Filter", // Not shown due toe `isEmpty`, but useful for external overrides
|
||||
cbClose: (isDataEntered) => this._pHandleHide(!isDataEntered),
|
||||
});
|
||||
|
||||
const $children = this._filters.map((f, i) => f.$render({filterBox: this, isFirst: i === 0, $wrpMini: this._$wrpMiniPills}));
|
||||
|
||||
this._metaIptSearch = ComponentUiUtil.$getIptStr(
|
||||
this._compSearch, "search",
|
||||
{decorationRight: "clear", asMeta: true, html: `<input class="form-control input-xs" placeholder="Search...">`},
|
||||
);
|
||||
this._compSearch._addHookBase("search", () => {
|
||||
const searchTerm = this._compSearch._state.search.toLowerCase();
|
||||
this._filters.forEach(f => f.handleSearch(searchTerm));
|
||||
});
|
||||
|
||||
const $btnShowAllFilters = $(`<button class="btn btn-xs btn-default">Show All</button>`)
|
||||
.click(() => this.showAllFilters());
|
||||
const $btnHideAllFilters = $(`<button class="btn btn-xs btn-default">Hide All</button>`)
|
||||
.click(() => this.hideAllFilters());
|
||||
|
||||
const $btnReset = $(`<button class="btn btn-xs btn-default mr-3" title="${TITLE_BTN_RESET}">Reset</button>`)
|
||||
.click(evt => this.reset(evt.shiftKey));
|
||||
|
||||
const $btnSettings = $(`<button class="btn btn-xs btn-default mr-3"><span class="glyphicon glyphicon-cog"></span></button>`)
|
||||
.click(() => this._pOpenSettingsModal());
|
||||
|
||||
const $btnSaveAlt = $(`<button class="btn btn-xs btn-primary" title="Save"><span class="glyphicon glyphicon-ok"></span></button>`)
|
||||
.click(() => this._modalMeta.doClose(true));
|
||||
|
||||
const $wrpBtnCombineFilters = $(`<div class="btn-group mr-3"></div>`);
|
||||
const $btnCombineFilterSettings = $(`<button class="btn btn-xs btn-default"><span class="glyphicon glyphicon-cog"></span></button>`)
|
||||
.click(() => this._pOpenCombineAsModal());
|
||||
|
||||
const btnCombineFiltersAs = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default`,
|
||||
click: () => this._meta.modeCombineFilters = FilterBox._COMBINE_MODES.getNext(this._meta.modeCombineFilters),
|
||||
title: `"AND" requires every filter to match. "OR" requires any filter to match. "Custom" allows you to specify a combination (every "AND" filter must match; only one "OR" filter must match) .`,
|
||||
}).appendTo($wrpBtnCombineFilters[0]);
|
||||
|
||||
const hook = () => {
|
||||
btnCombineFiltersAs.innerText = this._meta.modeCombineFilters === "custom" ? this._meta.modeCombineFilters.uppercaseFirst() : this._meta.modeCombineFilters.toUpperCase();
|
||||
if (this._meta.modeCombineFilters === "custom") $wrpBtnCombineFilters.append($btnCombineFilterSettings);
|
||||
else $btnCombineFilterSettings.detach();
|
||||
this._doSaveStateThrottled();
|
||||
};
|
||||
this._addHook("meta", "modeCombineFilters", hook);
|
||||
hook();
|
||||
|
||||
const $btnSave = $(`<button class="btn btn-primary fltr__btn-close mr-2">Save</button>`)
|
||||
.click(() => this._modalMeta.doClose(true));
|
||||
|
||||
const $btnCancel = $(`<button class="btn btn-default fltr__btn-close">Cancel</button>`)
|
||||
.click(() => this._modalMeta.doClose(false));
|
||||
|
||||
$$(this._modalMeta.$modal)`<div class="split mb-2 mt-2 ve-flex-v-center mobile__ve-flex-col">
|
||||
<div class="ve-flex-v-baseline mobile__ve-flex-col">
|
||||
<h4 class="m-0 mr-2 mobile__mb-2">Filters</h4>
|
||||
${this._metaIptSearch.$wrp.addClass("mobile__mb-2")}
|
||||
</div>
|
||||
<div class="ve-flex-v-center mobile__ve-flex-col">
|
||||
<div class="ve-flex-v-center mobile__m-1">
|
||||
<div class="mr-2">Combine as</div>
|
||||
${$wrpBtnCombineFilters}
|
||||
</div>
|
||||
<div class="ve-flex-v-center mobile__m-1">
|
||||
<div class="btn-group mr-2 ve-flex-h-center">
|
||||
${$btnShowAllFilters}
|
||||
${$btnHideAllFilters}
|
||||
</div>
|
||||
${$btnReset}
|
||||
${$btnSettings}
|
||||
${$btnSaveAlt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="w-100 m-0 mb-2">
|
||||
|
||||
<hr class="mt-1 mb-1">
|
||||
<div class="ui-modal__scroller smooth-scroll px-1">
|
||||
${$children}
|
||||
</div>
|
||||
<hr class="my-1 w-100">
|
||||
<div class="w-100 ve-flex-vh-center my-1">${$btnSave}${$btnCancel}</div>`;
|
||||
}
|
||||
|
||||
async _pOpenSettingsModal () {
|
||||
const {$modalInner} = await UiUtil.pGetShowModal({title: "Settings"});
|
||||
|
||||
UiUtil.$getAddModalRowCb($modalInner, "Deselect Homebrew Sources by Default", this._meta, "isBrewDefaultHidden");
|
||||
|
||||
UiUtil.addModalSep($modalInner);
|
||||
|
||||
UiUtil.$getAddModalRowHeader($modalInner, "Hide summary for filter...", {helpText: "The summary is the small red and blue button panel which appear below the search bar."});
|
||||
this._filters.forEach(f => UiUtil.$getAddModalRowCb($modalInner, f.header, this._minisHidden, f.header));
|
||||
|
||||
UiUtil.addModalSep($modalInner);
|
||||
|
||||
const $rowResetAlwaysSave = UiUtil.$getAddModalRow($modalInner, "div").addClass("pr-2");
|
||||
$rowResetAlwaysSave.append(`<span>Always Save on Close</span>`);
|
||||
$(`<button class="btn btn-xs btn-default">Reset</button>`)
|
||||
.appendTo($rowResetAlwaysSave)
|
||||
.click(async () => {
|
||||
await StorageUtil.pRemove(FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED);
|
||||
JqueryUtil.doToast("Saved!");
|
||||
});
|
||||
}
|
||||
|
||||
async _pOpenCombineAsModal () {
|
||||
const {$modalInner} = await UiUtil.pGetShowModal({title: "Filter Combination Logic"});
|
||||
const $btnReset = $(`<button class="btn btn-xs btn-default">Reset</button>`)
|
||||
.click(() => {
|
||||
Object.keys(this._combineAs).forEach(k => this._combineAs[k] = "and");
|
||||
$sels.forEach($sel => $sel.val("0"));
|
||||
});
|
||||
UiUtil.$getAddModalRowHeader($modalInner, "Combine filters as...", {$eleRhs: $btnReset});
|
||||
const $sels = this._filters.map(f => UiUtil.$getAddModalRowSel($modalInner, f.header, this._combineAs, f.header, ["and", "or"], {fnDisplay: (it) => it.toUpperCase()}));
|
||||
}
|
||||
|
||||
getValues ({nxtStateOuter = null} = {}) {
|
||||
const outObj = {};
|
||||
this._filters.forEach(f => Object.assign(outObj, f.getValues({nxtState: nxtStateOuter?.filters})));
|
||||
return outObj;
|
||||
}
|
||||
|
||||
addEventListener (type, listener) {
|
||||
(this._$wrpFormTop ? this._$wrpFormTop[0] : this._$btnOpen[0]).addEventListener(type, listener);
|
||||
}
|
||||
|
||||
_mutNextState_reset_meta ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultMeta());
|
||||
}
|
||||
|
||||
_mutNextState_minisHidden ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultMinisHidden(tgt));
|
||||
}
|
||||
|
||||
_mutNextState_combineAs ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultCombineAs(tgt));
|
||||
}
|
||||
|
||||
_reset_meta () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_reset_meta({tgt: nxtBoxState.meta});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
_reset_minisHidden () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_minisHidden({tgt: nxtBoxState.minisHidden});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
_reset_combineAs () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_combineAs({tgt: nxtBoxState.combineAs});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
reset (isResetAll) {
|
||||
this._filters.forEach(f => f.reset({isResetAll}));
|
||||
if (isResetAll) {
|
||||
this._reset_meta();
|
||||
this._reset_minisHidden();
|
||||
this._reset_combineAs();
|
||||
}
|
||||
this.render();
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
async show () {
|
||||
if (!this._isModalRendered) await this._render_pRenderModal();
|
||||
this._cachedState = this._getSaveableState();
|
||||
this._modalMeta.doOpen();
|
||||
if (this._metaIptSearch?.$ipt) this._metaIptSearch.$ipt.focus();
|
||||
}
|
||||
|
||||
async _pHandleHide (isCancel = false) {
|
||||
if (this._cachedState && isCancel) {
|
||||
const curState = this._getSaveableState();
|
||||
const hasChanges = !CollectionUtil.deepEquals(curState, this._cachedState);
|
||||
|
||||
if (hasChanges) {
|
||||
const isSave = await InputUiUtil.pGetUserBoolean({
|
||||
title: "Unsaved Changes",
|
||||
textYesRemember: "Always Save",
|
||||
textYes: "Save",
|
||||
textNo: "Discard",
|
||||
storageKey: FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED,
|
||||
isGlobal: true,
|
||||
});
|
||||
if (isSave) {
|
||||
this._cachedState = null;
|
||||
this.fireChangeEvent();
|
||||
return;
|
||||
} else this._setStateFromLoaded(this._cachedState, {isUserSavedState: true});
|
||||
}
|
||||
} else {
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
this._cachedState = null;
|
||||
}
|
||||
|
||||
showAllFilters () {
|
||||
this._filters.forEach(f => f.show());
|
||||
}
|
||||
|
||||
hideAllFilters () {
|
||||
this._filters.forEach(f => f.hide());
|
||||
}
|
||||
|
||||
unpackSubHashes (subHashes, {force = false} = {}) {
|
||||
// TODO(unpack) refactor
|
||||
const unpacked = {};
|
||||
subHashes.forEach(s => {
|
||||
const unpackedPart = UrlUtil.unpackSubHash(s, true);
|
||||
if (Object.keys(unpackedPart).length > 1) throw new Error(`Multiple keys in subhash!`);
|
||||
const k = Object.keys(unpackedPart)[0];
|
||||
unpackedPart[k] = {clean: unpackedPart[k], raw: s};
|
||||
Object.assign(unpacked, unpackedPart);
|
||||
});
|
||||
|
||||
const urlHeaderToFilter = {};
|
||||
this._filters.forEach(f => {
|
||||
const childFilters = f.getChildFilters();
|
||||
if (childFilters.length) childFilters.forEach(f => urlHeaderToFilter[f.header.toLowerCase()] = f);
|
||||
urlHeaderToFilter[f.header.toLowerCase()] = f;
|
||||
});
|
||||
|
||||
const urlHeadersUpdated = new Set();
|
||||
const subHashesConsumed = new Set();
|
||||
let filterInitialSearch;
|
||||
|
||||
const filterBoxState = {};
|
||||
const statePerFilter = {};
|
||||
const prefixLen = this.getNamespacedHashKey().length;
|
||||
Object.entries(unpacked)
|
||||
.forEach(([hashKey, data]) => {
|
||||
const rawPrefix = hashKey.substring(0, prefixLen);
|
||||
const prefix = rawPrefix.substring(0, SUB_HASH_PREFIX_LENGTH);
|
||||
|
||||
const urlHeader = hashKey.substring(prefixLen);
|
||||
|
||||
if (FilterRegistry.SUB_HASH_PREFIXES.has(prefix) && urlHeaderToFilter[urlHeader]) {
|
||||
(statePerFilter[urlHeader] = statePerFilter[urlHeader] || {})[prefix] = data.clean;
|
||||
urlHeadersUpdated.add(urlHeader);
|
||||
subHashesConsumed.add(data.raw);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.values(FilterBox._SUB_HASH_PREFIXES).includes(prefix)) {
|
||||
// special case for the search """state"""
|
||||
if (prefix === VeCt.FILTER_BOX_SUB_HASH_SEARCH_PREFIX) filterInitialSearch = data.clean[0];
|
||||
else filterBoxState[prefix] = data.clean;
|
||||
subHashesConsumed.add(data.raw);
|
||||
return;
|
||||
}
|
||||
|
||||
if (FilterRegistry.SUB_HASH_PREFIXES.has(prefix)) throw new Error(`Could not find filter with header ${urlHeader} for subhash ${data.raw}`);
|
||||
});
|
||||
|
||||
if (!subHashesConsumed.size && !force) return null;
|
||||
|
||||
return {
|
||||
urlHeaderToFilter,
|
||||
filterBoxState,
|
||||
statePerFilter,
|
||||
urlHeadersUpdated,
|
||||
unpacked,
|
||||
subHashesConsumed,
|
||||
filterInitialSearch,
|
||||
};
|
||||
}
|
||||
|
||||
setFromSubHashes (subHashes, {force = false, $iptSearch = null} = {}) {
|
||||
const unpackedSubhashes = this.unpackSubHashes(subHashes, {force});
|
||||
|
||||
if (unpackedSubhashes == null) return subHashes;
|
||||
|
||||
const {
|
||||
unpacked,
|
||||
subHashesConsumed,
|
||||
filterInitialSearch,
|
||||
} = unpackedSubhashes;
|
||||
|
||||
// region Update filter state
|
||||
const {box: nxtStateBox, filters: nxtStatesFilters} = this.getNextStateFromSubHashes({unpackedSubhashes});
|
||||
|
||||
this._setBoxStateFromNextBoxState(nxtStateBox);
|
||||
|
||||
this._filters
|
||||
.flatMap(f => [
|
||||
f,
|
||||
...f.getChildFilters(),
|
||||
])
|
||||
.filter(filter => nxtStatesFilters[filter.header])
|
||||
.forEach(filter => filter.setStateFromNextState(nxtStatesFilters));
|
||||
// endregion
|
||||
|
||||
// region Update search input value
|
||||
if (filterInitialSearch && ($iptSearch || this._$iptSearch)) ($iptSearch || this._$iptSearch).val(filterInitialSearch).change().keydown().keyup().trigger("instantKeyup");
|
||||
// endregion
|
||||
|
||||
// region Re-assemble and return remaining subhashes
|
||||
const [link] = Hist.getHashParts();
|
||||
|
||||
const outSub = [];
|
||||
Object.values(unpacked)
|
||||
.filter(v => !subHashesConsumed.has(v.raw))
|
||||
.forEach(v => outSub.push(v.raw));
|
||||
|
||||
Hist.setSuppressHistory(true);
|
||||
Hist.replaceHistoryHash(`${link}${outSub.length ? `${HASH_PART_SEP}${outSub.join(HASH_PART_SEP)}` : ""}`);
|
||||
|
||||
this.fireChangeEvent();
|
||||
Hist.hashChange({isBlankFilterLoad: true});
|
||||
return outSub;
|
||||
// endregion
|
||||
}
|
||||
|
||||
getNextStateFromSubHashes ({unpackedSubhashes}) {
|
||||
const {
|
||||
urlHeaderToFilter,
|
||||
filterBoxState,
|
||||
statePerFilter,
|
||||
urlHeadersUpdated,
|
||||
} = unpackedSubhashes;
|
||||
|
||||
const nxtStateBox = this._getNextBoxStateFromSubHashes(urlHeaderToFilter, filterBoxState);
|
||||
|
||||
const nxtStateFilters = {};
|
||||
|
||||
Object.entries(statePerFilter)
|
||||
.forEach(([urlHeader, state]) => {
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
Object.assign(nxtStateFilters, filter.getNextStateFromSubhashState(state));
|
||||
});
|
||||
|
||||
// reset any other state/meta state/etc
|
||||
Object.keys(urlHeaderToFilter)
|
||||
.filter(k => !urlHeadersUpdated.has(k))
|
||||
.forEach(k => {
|
||||
const filter = urlHeaderToFilter[k];
|
||||
Object.assign(nxtStateFilters, filter.getNextStateFromSubhashState(null));
|
||||
});
|
||||
|
||||
return {box: nxtStateBox, filters: nxtStateFilters};
|
||||
}
|
||||
|
||||
_getNextBoxState_base () {
|
||||
return {
|
||||
meta: MiscUtil.copyFast(this.__meta),
|
||||
minisHidden: MiscUtil.copyFast(this.__minisHidden),
|
||||
combineAs: MiscUtil.copyFast(this.__combineAs),
|
||||
};
|
||||
}
|
||||
|
||||
_getNextBoxStateFromSubHashes (urlHeaderToFilter, filterBoxState) {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
|
||||
let hasMeta = false;
|
||||
let hasMinisHidden = false;
|
||||
let hasCombineAs = false;
|
||||
|
||||
Object.entries(filterBoxState).forEach(([k, vals]) => {
|
||||
const mappedK = this.getNamespacedHashKey(Parser._parse_bToA(FilterBox._SUB_HASH_PREFIXES, k));
|
||||
switch (mappedK) {
|
||||
case "meta": {
|
||||
hasMeta = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(this._getDefaultMeta()).forEach((k, i) => nxtBoxState.meta[k] = data[i]);
|
||||
break;
|
||||
}
|
||||
case "minisHidden": {
|
||||
hasMinisHidden = true;
|
||||
Object.keys(nxtBoxState.minisHidden).forEach(k => nxtBoxState.minisHidden[k] = false);
|
||||
vals.forEach(v => {
|
||||
const [urlHeader, isHidden] = v.split("=");
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
if (!filter) throw new Error(`Could not find filter with name "${urlHeader}"`);
|
||||
nxtBoxState.minisHidden[filter.header] = !!Number(isHidden);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "combineAs": {
|
||||
hasCombineAs = true;
|
||||
Object.keys(nxtBoxState.combineAs).forEach(k => nxtBoxState.combineAs[k] = "and");
|
||||
vals.forEach(v => {
|
||||
const [urlHeader, ixCombineMode] = v.split("=");
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
if (!filter) throw new Error(`Could not find filter with name "${urlHeader}"`);
|
||||
nxtBoxState.combineAs[filter.header] = FilterBox._COMBINE_MODES[ixCombineMode] || FilterBox._COMBINE_MODES[0];
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasMeta) this._mutNextState_reset_meta({tgt: nxtBoxState.meta});
|
||||
if (!hasMinisHidden) this._mutNextState_minisHidden({tgt: nxtBoxState.minisHidden});
|
||||
if (!hasCombineAs) this._mutNextState_combineAs({tgt: nxtBoxState.combineAs});
|
||||
|
||||
return nxtBoxState;
|
||||
}
|
||||
|
||||
_setBoxStateFromNextBoxState (nxtBoxState) {
|
||||
this._proxyAssignSimple("meta", nxtBoxState.meta, true);
|
||||
this._proxyAssignSimple("minisHidden", nxtBoxState.minisHidden, true);
|
||||
this._proxyAssignSimple("combineAs", nxtBoxState.combineAs, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param [opts] Options object.
|
||||
* @param [opts.isAddSearchTerm] If the active search should be added to the subhashes.
|
||||
*/
|
||||
getSubHashes (opts) {
|
||||
opts = opts || {};
|
||||
const out = [];
|
||||
const boxSubHashes = this.getBoxSubHashes();
|
||||
if (boxSubHashes) out.push(boxSubHashes);
|
||||
out.push(...this._filters.map(f => f.getSubHashes()).filter(Boolean));
|
||||
if (opts.isAddSearchTerm && this._$iptSearch) {
|
||||
const searchTerm = UrlUtil.encodeForHash(this._$iptSearch.val().trim());
|
||||
if (searchTerm) out.push(UrlUtil.packSubHash(this._getSubhashPrefix("search"), [searchTerm]));
|
||||
}
|
||||
return out.flat();
|
||||
}
|
||||
|
||||
getBoxSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const defaultMeta = this._getDefaultMeta();
|
||||
|
||||
// serialize base meta in a set order
|
||||
const anyNotDefault = Object.keys(defaultMeta).find(k => this._meta[k] !== defaultMeta[k]);
|
||||
if (anyNotDefault) {
|
||||
const serMeta = Object.keys(defaultMeta).map(k => UrlUtil.mini.compress(this._meta[k] === undefined ? defaultMeta[k] : this._meta[k]));
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("meta"), serMeta));
|
||||
}
|
||||
|
||||
// serialize minisHidden as `key=value` pairs
|
||||
const setMinisHidden = Object.entries(this._minisHidden).filter(([k, v]) => !!v).map(([k]) => `${k.toUrlified()}=1`);
|
||||
if (setMinisHidden.length) {
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("minisHidden"), setMinisHidden));
|
||||
}
|
||||
|
||||
// serialize combineAs as `key=value` pairs
|
||||
const setCombineAs = Object.entries(this._combineAs).filter(([k, v]) => v !== FilterBox._COMBINE_MODES[0]).map(([k, v]) => `${k.toUrlified()}=${FilterBox._COMBINE_MODES.indexOf(v)}`);
|
||||
if (setCombineAs.length) {
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("combineAs"), setCombineAs));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
getFilterTag ({isAddSearchTerm = false} = {}) {
|
||||
const parts = this._filters.map(f => f.getFilterTagPart()).filter(Boolean);
|
||||
if (isAddSearchTerm && this._$iptSearch) {
|
||||
const term = this._$iptSearch.val().trim();
|
||||
if (term) parts.push(`search=${term}`);
|
||||
}
|
||||
return `{@filter |${UrlUtil.getCurrentPage().replace(/\.html$/, "")}|${parts.join("|")}}`;
|
||||
}
|
||||
|
||||
getDisplayState ({nxtStateOuter = null} = {}) {
|
||||
return this._filters
|
||||
.map(filter => filter.getDisplayStatePart({nxtState: nxtStateOuter?.filters}))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
this._filters.forEach(it => it.setFromValues(values));
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
toDisplay (boxState, ...entryVals) {
|
||||
return this._toDisplay(boxState, this._filters, entryVals);
|
||||
}
|
||||
|
||||
/** `filterToValueTuples` should be an array of `{filter: <Filter>, value: <Any>}` objects */
|
||||
toDisplayByFilters (boxState, ...filterToValueTuples) {
|
||||
return this._toDisplay(
|
||||
boxState,
|
||||
filterToValueTuples.map(it => it.filter),
|
||||
filterToValueTuples.map(it => it.value),
|
||||
);
|
||||
}
|
||||
|
||||
_toDisplay (boxState, filters, entryVals) {
|
||||
switch (this._meta.modeCombineFilters) {
|
||||
case "and": return this._toDisplay_isAndDisplay(boxState, filters, entryVals);
|
||||
case "or": return this._toDisplay_isOrDisplay(boxState, filters, entryVals);
|
||||
case "custom": {
|
||||
if (entryVals.length !== filters.length) throw new Error(`Number of filters and number of values did not match!`);
|
||||
|
||||
const andFilters = [];
|
||||
const andValues = [];
|
||||
const orFilters = [];
|
||||
const orValues = [];
|
||||
|
||||
for (let i = 0; i < filters.length; ++i) {
|
||||
const f = filters[i];
|
||||
if (!this._combineAs[f.header] || this._combineAs[f.header] === "and") { // default to "and" if undefined
|
||||
andFilters.push(f);
|
||||
andValues.push(entryVals[i]);
|
||||
} else {
|
||||
orFilters.push(f);
|
||||
orValues.push(entryVals[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return this._toDisplay_isAndDisplay(boxState, andFilters, andValues) && this._toDisplay_isOrDisplay(boxState, orFilters, orValues);
|
||||
}
|
||||
default: throw new Error(`Unhandled combining mode "${this._meta.modeCombineFilters}"`);
|
||||
}
|
||||
}
|
||||
|
||||
_toDisplay_isAndDisplay (boxState, filters, vals) {
|
||||
return filters
|
||||
.map((f, i) => f.toDisplay(boxState, vals[i]))
|
||||
.every(it => it);
|
||||
}
|
||||
|
||||
_toDisplay_isOrDisplay (boxState, filters, vals) {
|
||||
const res = filters.map((f, i) => {
|
||||
// filter out "ignored" filter (i.e. all white)
|
||||
if (!f.isActive(boxState)) return null;
|
||||
return f.toDisplay(boxState, vals[i]);
|
||||
}).filter(it => it != null);
|
||||
return res.length === 0 || res.find(it => it);
|
||||
}
|
||||
|
||||
_getSubhashPrefix (prop) {
|
||||
if (FilterBox._SUB_HASH_PREFIXES[prop]) return this.getNamespacedHashKey(FilterBox._SUB_HASH_PREFIXES[prop]);
|
||||
throw new Error(`Unknown property "${prop}"`);
|
||||
}
|
||||
|
||||
_getDefaultMeta () {
|
||||
const out = MiscUtil.copy(FilterBox._DEFAULT_META);
|
||||
if (this._isCompact) out.isSummaryHidden = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
_getDefaultMinisHidden (minisHidden) {
|
||||
if (!minisHidden) throw new Error(`Missing "minisHidden" argument!`);
|
||||
return Object.keys(minisHidden)
|
||||
.mergeMap(k => ({[k]: false}));
|
||||
}
|
||||
|
||||
_getDefaultCombineAs (combineAs) {
|
||||
if (!combineAs) throw new Error(`Missing "combineAs" argument!`);
|
||||
return Object.keys(combineAs)
|
||||
.mergeMap(k => ({[k]: "and"}));
|
||||
}
|
||||
}
|
||||
FilterBox._PILL_STATES = ["ignore", "yes", "no"];
|
||||
FilterBox._COMBINE_MODES = ["and", "or", "custom"];
|
||||
FilterBox._STORAGE_KEY = "filterBoxState";
|
||||
FilterBox._DEFAULT_META = {
|
||||
modeCombineFilters: "and",
|
||||
isSummaryHidden: false,
|
||||
isBrewDefaultHidden: false,
|
||||
};
|
||||
FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED = "filterAlwaysSaveUnchanged";
|
||||
|
||||
// These are assumed to be the same length (4 characters)
|
||||
FilterBox._SUB_HASH_BOX_META_PREFIX = "fbmt";
|
||||
FilterBox._SUB_HASH_BOX_MINIS_HIDDEN_PREFIX = "fbmh";
|
||||
FilterBox._SUB_HASH_BOX_COMBINE_AS_PREFIX = "fbca";
|
||||
FilterBox._SUB_HASH_PREFIXES = {
|
||||
meta: FilterBox._SUB_HASH_BOX_META_PREFIX,
|
||||
minisHidden: FilterBox._SUB_HASH_BOX_MINIS_HIDDEN_PREFIX,
|
||||
combineAs: FilterBox._SUB_HASH_BOX_COMBINE_AS_PREFIX,
|
||||
search: VeCt.FILTER_BOX_SUB_HASH_SEARCH_PREFIX,
|
||||
};
|
||||
|
||||
FilterRegistry.registerSubhashes(Object.values(FilterBox._SUB_HASH_PREFIXES));
|
||||
5
js/filter/filter-constants.js
Normal file
5
js/filter/filter-constants.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUB_HASH_PREFIX_LENGTH = 4;
|
||||
|
||||
export const EVNT_VALCHANGE = "valchange";
|
||||
export const SOURCE_HEADER = "Source";
|
||||
export const TITLE_BTN_RESET = "Reset filters. SHIFT to reset everything.";
|
||||
25
js/filter/filter-item.js
Normal file
25
js/filter/filter-item.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export class FilterItem {
|
||||
/**
|
||||
* An alternative to string `Filter.items` with a change-handling function
|
||||
* @param options containing:
|
||||
* @param options.item the item string
|
||||
* @param [options.pFnChange] (optional) function to call when filter is changed
|
||||
* @param [options.group] (optional) group this item belongs to.
|
||||
* @param [options.nest] (optional) nest this item belongs to
|
||||
* @param [options.nestHidden] (optional) if nested, default visibility state
|
||||
* @param [options.isIgnoreRed] (optional) if this item should be ignored when negative filtering
|
||||
* @param [options.userData] (optional) extra data to be stored as part of the item
|
||||
*/
|
||||
constructor (options) {
|
||||
this.item = options.item;
|
||||
this.pFnChange = options.pFnChange;
|
||||
this.group = options.group;
|
||||
this.nest = options.nest;
|
||||
this.nestHidden = options.nestHidden;
|
||||
this.isIgnoreRed = options.isIgnoreRed;
|
||||
this.userData = options.userData;
|
||||
|
||||
this.rendered = null;
|
||||
this.searchText = null;
|
||||
}
|
||||
}
|
||||
298
js/filter/filter-modal-filter-base.js
Normal file
298
js/filter/filter-modal-filter-base.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import {EVNT_VALCHANGE} from "./filter-constants.js";
|
||||
|
||||
export class ModalFilterBase {
|
||||
static _$getFilterColumnHeaders (btnMeta) {
|
||||
return btnMeta.map((it, i) => $(`<button class="ve-col-${it.width} ${i === 0 ? "pl-0" : i === btnMeta.length ? "pr-0" : ""} ${it.disabled ? "" : "sort"} btn btn-default btn-xs" ${it.disabled ? "" : `data-sort="${it.sort}"`} ${it.title ? `title="${it.title}"` : ""} ${it.disabled ? "disabled" : ""}>${it.text}</button>`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param opts.modalTitle
|
||||
* @param opts.fnSort
|
||||
* @param opts.pageFilter
|
||||
* @param [opts.namespace]
|
||||
* @param [opts.allData]
|
||||
*/
|
||||
constructor (opts) {
|
||||
this._modalTitle = opts.modalTitle;
|
||||
this._fnSort = opts.fnSort;
|
||||
this._pageFilter = opts.pageFilter;
|
||||
this._namespace = opts.namespace;
|
||||
this._allData = opts.allData || null;
|
||||
this._isRadio = !!opts.isRadio;
|
||||
|
||||
this._list = null;
|
||||
this._filterCache = null;
|
||||
}
|
||||
|
||||
get pageFilter () { return this._pageFilter; }
|
||||
|
||||
get allData () { return this._allData; }
|
||||
|
||||
_$getWrpList () { return $(`<div class="list ui-list__wrp ve-overflow-x-hidden ve-overflow-y-auto h-100 min-h-0"></div>`); }
|
||||
|
||||
_$getColumnHeaderPreviewAll (opts) {
|
||||
return $(`<button class="btn btn-default btn-xs ${opts.isBuildUi ? "ve-col-1" : "ve-col-0-5"}">${ListUiUtil.HTML_GLYPHICON_EXPAND}</button>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $wrp
|
||||
* @param opts
|
||||
* @param opts.$iptSearch
|
||||
* @param opts.$btnReset
|
||||
* @param opts.$btnOpen
|
||||
* @param opts.$btnToggleSummaryHidden
|
||||
* @param opts.$wrpMiniPills
|
||||
* @param opts.isBuildUi If an alternate UI should be used, which has "send to right" buttons.
|
||||
*/
|
||||
async pPopulateWrapper ($wrp, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
await this._pInit();
|
||||
|
||||
const $ovlLoading = $(`<div class="w-100 h-100 ve-flex-vh-center"><i class="dnd-font ve-muted">Loading...</i></div>`).appendTo($wrp);
|
||||
|
||||
const $iptSearch = (opts.$iptSearch || $(`<input class="form-control lst__search lst__search--no-border-h h-100" type="search" placeholder="Search...">`)).disableSpellcheck();
|
||||
const $btnReset = opts.$btnReset || $(`<button class="btn btn-default">Reset</button>`);
|
||||
const $dispNumVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
|
||||
|
||||
const $wrpIptSearch = $$`<div class="w-100 relative">
|
||||
${$iptSearch}
|
||||
<div class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
|
||||
${$dispNumVisible}
|
||||
</div>`;
|
||||
|
||||
const $wrpFormTop = $$`<div class="ve-flex input-group btn-group w-100 lst__form-top">${$wrpIptSearch}${$btnReset}</div>`;
|
||||
|
||||
const $wrpFormBottom = opts.$wrpMiniPills || $(`<div class="w-100"></div>`);
|
||||
|
||||
const $wrpFormHeaders = $(`<div class="input-group input-group--bottom ve-flex no-shrink"></div>`);
|
||||
const $cbSelAll = opts.isBuildUi || this._isRadio ? null : $(`<input type="checkbox">`);
|
||||
const $btnSendAllToRight = opts.isBuildUi ? $(`<button class="btn btn-xxs btn-default ve-col-1" title="Add All"><span class="glyphicon glyphicon-arrow-right"></span></button>`) : null;
|
||||
|
||||
if (!opts.isBuildUi) {
|
||||
if (this._isRadio) $wrpFormHeaders.append(`<label class="btn btn-default btn-xs ve-col-0-5 ve-flex-vh-center" disabled></label>`);
|
||||
else $$`<label class="btn btn-default btn-xs ve-col-0-5 ve-flex-vh-center">${$cbSelAll}</label>`.appendTo($wrpFormHeaders);
|
||||
}
|
||||
|
||||
const $btnTogglePreviewAll = this._$getColumnHeaderPreviewAll(opts)
|
||||
.appendTo($wrpFormHeaders);
|
||||
|
||||
this._$getColumnHeaders().forEach($ele => $wrpFormHeaders.append($ele));
|
||||
if (opts.isBuildUi) $btnSendAllToRight.appendTo($wrpFormHeaders);
|
||||
|
||||
const $wrpForm = $$`<div class="ve-flex-col w-100 mb-1">${$wrpFormTop}${$wrpFormBottom}${$wrpFormHeaders}</div>`;
|
||||
const $wrpList = this._$getWrpList();
|
||||
|
||||
const $btnConfirm = opts.isBuildUi ? null : $(`<button class="btn btn-default">Confirm</button>`);
|
||||
|
||||
this._list = new List({
|
||||
$iptSearch,
|
||||
$wrpList,
|
||||
fnSort: this._fnSort,
|
||||
});
|
||||
const listSelectClickHandler = new ListSelectClickHandler({list: this._list});
|
||||
|
||||
if (!opts.isBuildUi && !this._isRadio) listSelectClickHandler.bindSelectAllCheckbox($cbSelAll);
|
||||
ListUiUtil.bindPreviewAllButton($btnTogglePreviewAll, this._list);
|
||||
SortUtil.initBtnSortHandlers($wrpFormHeaders, this._list);
|
||||
this._list.on("updated", () => $dispNumVisible.html(`${this._list.visibleItems.length}/${this._list.items.length}`));
|
||||
|
||||
this._allData = this._allData || await this._pLoadAllData();
|
||||
|
||||
await this._pageFilter.pInitFilterBox({
|
||||
$wrpFormTop,
|
||||
$btnReset,
|
||||
$wrpMiniPills: $wrpFormBottom,
|
||||
namespace: this._namespace,
|
||||
$btnOpen: opts.$btnOpen,
|
||||
$btnToggleSummaryHidden: opts.$btnToggleSummaryHidden,
|
||||
});
|
||||
|
||||
this._allData.forEach((it, i) => {
|
||||
this._pageFilter.mutateAndAddToFilters(it);
|
||||
const filterListItem = this._getListItem(this._pageFilter, it, i);
|
||||
this._list.addItem(filterListItem);
|
||||
if (!opts.isBuildUi) {
|
||||
if (this._isRadio) filterListItem.ele.addEventListener("click", evt => listSelectClickHandler.handleSelectClickRadio(filterListItem, evt));
|
||||
else filterListItem.ele.addEventListener("click", evt => listSelectClickHandler.handleSelectClick(filterListItem, evt));
|
||||
}
|
||||
});
|
||||
|
||||
this._list.init();
|
||||
this._list.update();
|
||||
|
||||
const handleFilterChange = () => {
|
||||
const f = this._pageFilter.filterBox.getValues();
|
||||
this._list.filter(li => this._isListItemMatchingFilter(f, li));
|
||||
};
|
||||
|
||||
this._pageFilter.trimState();
|
||||
|
||||
this._pageFilter.filterBox.on(EVNT_VALCHANGE, handleFilterChange);
|
||||
this._pageFilter.filterBox.render();
|
||||
handleFilterChange();
|
||||
|
||||
$ovlLoading.remove();
|
||||
|
||||
const $wrpInner = $$`<div class="ve-flex-col h-100">
|
||||
${$wrpForm}
|
||||
${$wrpList}
|
||||
${opts.isBuildUi ? null : $$`<hr class="hr-1"><div class="ve-flex-vh-center">${$btnConfirm}</div>`}
|
||||
</div>`.appendTo($wrp.empty());
|
||||
|
||||
return {
|
||||
$wrpIptSearch,
|
||||
$iptSearch,
|
||||
$wrpInner,
|
||||
$btnConfirm,
|
||||
pageFilter: this._pageFilter,
|
||||
list: this._list,
|
||||
$cbSelAll,
|
||||
$btnSendAllToRight,
|
||||
};
|
||||
}
|
||||
|
||||
_isListItemMatchingFilter (f, li) { return this._isEntityItemMatchingFilter(f, this._allData[li.ix]); }
|
||||
_isEntityItemMatchingFilter (f, it) { return this._pageFilter.toDisplay(f, it); }
|
||||
|
||||
async pPopulateHiddenWrapper () {
|
||||
await this._pInit();
|
||||
|
||||
this._allData = this._allData || await this._pLoadAllData();
|
||||
|
||||
await this._pageFilter.pInitFilterBox({namespace: this._namespace});
|
||||
|
||||
this._allData.forEach(it => {
|
||||
this._pageFilter.mutateAndAddToFilters(it);
|
||||
});
|
||||
|
||||
this._pageFilter.trimState();
|
||||
}
|
||||
|
||||
handleHiddenOpenButtonClick () {
|
||||
this._pageFilter.filterBox.show();
|
||||
}
|
||||
|
||||
handleHiddenResetButtonClick (evt) {
|
||||
this._pageFilter.filterBox.reset(evt.shiftKey);
|
||||
}
|
||||
|
||||
_getStateFromFilterExpression (filterExpression) {
|
||||
const filterSubhashMeta = Renderer.getFilterSubhashes(Renderer.splitTagByPipe(filterExpression), this._namespace);
|
||||
const subhashes = filterSubhashMeta.subhashes.map(it => `${it.key}${HASH_SUB_KV_SEP}${it.value}`);
|
||||
const unpackedSubhashes = this.pageFilter.filterBox.unpackSubHashes(subhashes, {force: true});
|
||||
return this.pageFilter.filterBox.getNextStateFromSubHashes({unpackedSubhashes});
|
||||
}
|
||||
|
||||
/**
|
||||
* N.b.: assumes any preloading has already been done
|
||||
* @param filterExpression
|
||||
*/
|
||||
getItemsMatchingFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
|
||||
const f = this._pageFilter.filterBox.getValues({nxtStateOuter});
|
||||
const filteredItems = this._filterCache.list.getFilteredItems({
|
||||
items: this._filterCache.list.items,
|
||||
fnFilter: li => this._isListItemMatchingFilter(f, li),
|
||||
});
|
||||
|
||||
return this._filterCache.list.getSortedItems({items: filteredItems});
|
||||
}
|
||||
|
||||
getEntitiesMatchingFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
|
||||
const f = this._pageFilter.filterBox.getValues({nxtStateOuter});
|
||||
return this._allData.filter(this._isEntityItemMatchingFilter.bind(this, f));
|
||||
}
|
||||
|
||||
getRenderedFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
return this.pageFilter.filterBox.getDisplayState({nxtStateOuter});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param [opts]
|
||||
* @param [opts.filterExpression] A filter expression, as usually found in @filter tags, which will be applied.
|
||||
*/
|
||||
async pGetUserSelection ({filterExpression = null} = {}) {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async resolve => {
|
||||
const {$modalInner, doClose} = await this._pGetShowModal(resolve);
|
||||
|
||||
await this.pPreloadHidden($modalInner);
|
||||
|
||||
this._doApplyFilterExpression(filterExpression);
|
||||
|
||||
this._filterCache.$btnConfirm.off("click").click(async () => {
|
||||
const checked = this._filterCache.list.visibleItems.filter(it => it.data.cbSel.checked);
|
||||
resolve(checked);
|
||||
|
||||
doClose(true);
|
||||
|
||||
// region reset selection state
|
||||
if (this._filterCache.$cbSelAll) this._filterCache.$cbSelAll.prop("checked", false);
|
||||
this._filterCache.list.items.forEach(it => {
|
||||
if (it.data.cbSel) it.data.cbSel.checked = false;
|
||||
it.ele.classList.remove("list-multi-selected");
|
||||
});
|
||||
// endregion
|
||||
});
|
||||
|
||||
await UiUtil.pDoForceFocus(this._filterCache.$iptSearch[0]);
|
||||
});
|
||||
}
|
||||
|
||||
async _pGetShowModal (resolve) {
|
||||
const {$modalInner, doClose} = await UiUtil.pGetShowModal({
|
||||
isHeight100: true,
|
||||
isWidth100: true,
|
||||
title: `Filter/Search for ${this._modalTitle}`,
|
||||
cbClose: (isDataEntered) => {
|
||||
this._filterCache.$wrpModalInner.detach();
|
||||
if (!isDataEntered) resolve([]);
|
||||
},
|
||||
isUncappedHeight: true,
|
||||
});
|
||||
|
||||
return {$modalInner, doClose};
|
||||
}
|
||||
|
||||
_doApplyFilterExpression (filterExpression) {
|
||||
if (!filterExpression) return;
|
||||
|
||||
const filterSubhashMeta = Renderer.getFilterSubhashes(Renderer.splitTagByPipe(filterExpression), this._namespace);
|
||||
const subhashes = filterSubhashMeta.subhashes.map(it => `${it.key}${HASH_SUB_KV_SEP}${it.value}`);
|
||||
this.pageFilter.filterBox.setFromSubHashes(subhashes, {force: true, $iptSearch: this._filterCache.$iptSearch});
|
||||
}
|
||||
|
||||
_getNameStyle () { return `bold`; }
|
||||
|
||||
/**
|
||||
* Pre-heat the modal, thus allowing access to the filter box underneath.
|
||||
*
|
||||
* @param [$modalInner]
|
||||
*/
|
||||
async pPreloadHidden ($modalInner) {
|
||||
// If we're rendering in "hidden" mode, create a dummy element to attach the UI to.
|
||||
$modalInner = $modalInner || $(`<div></div>`);
|
||||
|
||||
if (this._filterCache) {
|
||||
this._filterCache.$wrpModalInner.appendTo($modalInner);
|
||||
} else {
|
||||
const meta = await this.pPopulateWrapper($modalInner);
|
||||
const {$iptSearch, $btnConfirm, pageFilter, list, $cbSelAll} = meta;
|
||||
const $wrpModalInner = meta.$wrpInner;
|
||||
|
||||
this._filterCache = {$iptSearch, $wrpModalInner, $btnConfirm, pageFilter, list, $cbSelAll};
|
||||
}
|
||||
}
|
||||
|
||||
/** Widths should total to 11/12ths, as 1/12th is set aside for the checkbox column. */
|
||||
_$getColumnHeaders () { throw new Error(`Unimplemented!`); }
|
||||
async _pInit () { /* Implement as required */ }
|
||||
async _pLoadAllData () { throw new Error(`Unimplemented!`); }
|
||||
async _getListItem () { throw new Error(`Unimplemented!`); }
|
||||
}
|
||||
101
js/filter/filter-page-filter-base.js
Normal file
101
js/filter/filter-page-filter-base.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import {FilterItem} from "./filter-item.js";
|
||||
|
||||
export class PageFilterBase {
|
||||
static defaultSourceSelFn (val) {
|
||||
// Assume the user wants to select their loaded homebrew by default
|
||||
// Overridden by the "Deselect Homebrew Sources by Default" option
|
||||
return SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_STANDARD
|
||||
|| (SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_PARTNERED && (typeof BrewUtil2 === "undefined" || BrewUtil2.hasSourceJson(val)))
|
||||
|| SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_HOMEBREW;
|
||||
}
|
||||
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
this._sourceFilter = new SourceFilter(opts.sourceFilterOpts);
|
||||
this._filterBox = null;
|
||||
}
|
||||
|
||||
get filterBox () { return this._filterBox; }
|
||||
get sourceFilter () { return this._sourceFilter; }
|
||||
|
||||
mutateAndAddToFilters (entity, isExcluded, opts) {
|
||||
this.constructor.mutateForFilters(entity, opts);
|
||||
this.addToFilters(entity, isExcluded, opts);
|
||||
}
|
||||
|
||||
static mutateForFilters (entity, opts) { throw new Error("Unimplemented!"); }
|
||||
addToFilters (entity, isExcluded, opts) { throw new Error("Unimplemented!"); }
|
||||
toDisplay (values, entity) { throw new Error("Unimplemented!"); }
|
||||
async _pPopulateBoxOptions () { throw new Error("Unimplemented!"); }
|
||||
|
||||
async pInitFilterBox (opts) {
|
||||
opts = opts || {};
|
||||
await this._pPopulateBoxOptions(opts);
|
||||
this._filterBox = new FilterBox(opts);
|
||||
await this._filterBox.pDoLoadState();
|
||||
return this._filterBox;
|
||||
}
|
||||
|
||||
trimState () { return this._filterBox.trimState_(); }
|
||||
|
||||
// region Helpers
|
||||
static _getClassFilterItem ({className, classSource, isVariantClass, definedInSource}) {
|
||||
const nm = className.split("(")[0].trim();
|
||||
const variantSuffix = isVariantClass ? ` [${definedInSource ? Parser.sourceJsonToAbv(definedInSource) : "Unknown"}]` : "";
|
||||
const sourceSuffix = (
|
||||
SourceUtil.isNonstandardSource(classSource || Parser.SRC_PHB)
|
||||
|| (typeof PrereleaseUtil !== "undefined" && PrereleaseUtil.hasSourceJson(classSource || Parser.SRC_PHB))
|
||||
|| (typeof BrewUtil2 !== "undefined" && BrewUtil2.hasSourceJson(classSource || Parser.SRC_PHB))
|
||||
)
|
||||
? ` (${Parser.sourceJsonToAbv(classSource)})` : "";
|
||||
const name = `${nm}${variantSuffix}${sourceSuffix}`;
|
||||
|
||||
const opts = {
|
||||
item: name,
|
||||
userData: {
|
||||
group: SourceUtil.getFilterGroup(classSource || Parser.SRC_PHB),
|
||||
},
|
||||
};
|
||||
|
||||
if (isVariantClass) {
|
||||
opts.nest = definedInSource ? Parser.sourceJsonToFull(definedInSource) : "Unknown";
|
||||
opts.userData.equivalentClassName = `${nm}${sourceSuffix}`;
|
||||
opts.userData.definedInSource = definedInSource;
|
||||
}
|
||||
|
||||
return new FilterItem(opts);
|
||||
}
|
||||
|
||||
static _getSubclassFilterItem ({className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, isVariantClass, definedInSource}) {
|
||||
const group = SourceUtil.isSubclassReprinted(className, classSource, subclassShortName, subclassSource) || Parser.sourceJsonToFull(subclassSource).startsWith(Parser.UA_PREFIX) || Parser.sourceJsonToFull(subclassSource).startsWith(Parser.PS_PREFIX);
|
||||
|
||||
const classFilterItem = this._getClassFilterItem({
|
||||
className: subclassShortName || subclassName,
|
||||
classSource: subclassSource,
|
||||
});
|
||||
|
||||
return new FilterItem({
|
||||
item: `${className}: ${classFilterItem.item}${subSubclassName ? `, ${subSubclassName}` : ""}`,
|
||||
nest: className,
|
||||
userData: {
|
||||
group,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static _isReprinted ({reprintedAs, tag, page, prop}) {
|
||||
return reprintedAs?.length && reprintedAs.some(it => {
|
||||
const {name, source} = DataUtil.generic.unpackUid(it?.uid ?? it, tag);
|
||||
const hash = UrlUtil.URL_TO_HASH_BUILDER[page]({name, source});
|
||||
return !ExcludeUtil.isExcluded(hash, prop, source, {isNoCount: true});
|
||||
});
|
||||
}
|
||||
|
||||
static getListAliases (ent) {
|
||||
return (ent.alias || []).map(it => `"${it}"`).join(",");
|
||||
}
|
||||
|
||||
static _hasFluff (ent) { return ent.hasFluff || ent.fluff?.entries; }
|
||||
static _hasFluffImages (ent) { return ent.hasFluffImages || ent.fluff?.images; }
|
||||
// endregion
|
||||
}
|
||||
13
js/filter/filter-registry.js
Normal file
13
js/filter/filter-registry.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import {SUB_HASH_PREFIX_LENGTH} from "./filter-constants.js";
|
||||
|
||||
export class FilterRegistry {
|
||||
static SUB_HASH_PREFIXES = new Set();
|
||||
|
||||
static registerSubhashes (subhashes) {
|
||||
const subhashesInvalid = subhashes.filter(it => it.length !== SUB_HASH_PREFIX_LENGTH);
|
||||
|
||||
if (subhashesInvalid.length) throw new Error(`Invalid prefix! ${subhashesInvalid.map(it => `"${it}"`).join(", ")} ${subhashesInvalid.length === 1 ? `is` : `was`} not of length ${SUB_HASH_PREFIX_LENGTH}`);
|
||||
|
||||
subhashes.forEach(it => this.SUB_HASH_PREFIXES.add(it));
|
||||
}
|
||||
}
|
||||
693
js/filter/filter/filter-filter-ability-score.js
Normal file
693
js/filter/filter/filter-filter-ability-score.js
Normal file
@@ -0,0 +1,693 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class AbilityScoreFilter extends FilterBase {
|
||||
static _MODIFIER_SORT_OFFSET = 10000; // Arbitrarily large value
|
||||
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
this._items = [];
|
||||
this._isItemsDirty = false;
|
||||
this._itemsLookup = {}; // Cache items for fast lookup
|
||||
this._seenUids = {};
|
||||
|
||||
this.__$wrpFilter = null;
|
||||
this.__wrpPills = null;
|
||||
this.__wrpPillsRows = {};
|
||||
this.__wrpMiniPills = null;
|
||||
|
||||
this._maxMod = 2;
|
||||
this._minMod = 0;
|
||||
|
||||
// region Init state
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const itemAnyIncrease = new AbilityScoreFilter.FilterItem({isAnyIncrease: true, ability: ab});
|
||||
const itemAnyDecrease = new AbilityScoreFilter.FilterItem({isAnyDecrease: true, ability: ab});
|
||||
this._items.push(itemAnyIncrease, itemAnyDecrease);
|
||||
this._itemsLookup[itemAnyIncrease.uid] = itemAnyIncrease;
|
||||
this._itemsLookup[itemAnyDecrease.uid] = itemAnyDecrease;
|
||||
if (this.__state[itemAnyIncrease.uid] == null) this.__state[itemAnyIncrease.uid] = 0;
|
||||
if (this.__state[itemAnyDecrease.uid] == null) this.__state[itemAnyDecrease.uid] = 0;
|
||||
});
|
||||
|
||||
for (let i = this._minMod; i <= this._maxMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]});
|
||||
|
||||
const wrpControls = this._getHeaderControls(opts);
|
||||
|
||||
this.__wrpPills = e_({tag: "div", clazz: `fltr__wrp-pills ve-overflow-x-auto ve-flex-col w-100`});
|
||||
const hook = () => this.__wrpPills.toggleVe(!this._meta.isHidden);
|
||||
this._addHook("meta", "isHidden", hook);
|
||||
hook();
|
||||
|
||||
this._doRenderPills();
|
||||
|
||||
// FIXME refactor this so we're not stealing the private method
|
||||
const btnMobToggleControls = Filter.prototype._getBtnMobToggleControls.bind(this)(wrpControls);
|
||||
|
||||
this.__$wrpFilter = $$`<div>
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider ${opts.isMulti ? "fltr__dropdown-divider--indented" : ""} mb-1"></div>`}
|
||||
<div class="split fltr__h mb-1">
|
||||
<div class="ml-2 fltr__h-text ve-flex-h-center">${opts.isMulti ? `<span class="mr-2">\u2012</span>` : ""}${this._getRenderedHeader()}${btnMobToggleControls}</div>
|
||||
${wrpControls}
|
||||
</div>
|
||||
${this.__wrpPills}
|
||||
</div>`;
|
||||
|
||||
this.update(); // Force an update, to properly mute/unmute our pills
|
||||
|
||||
return this.__$wrpFilter;
|
||||
}
|
||||
|
||||
_getHeaderControls (opts) {
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--clear w-100`,
|
||||
click: () => this._doSetPillsClear(),
|
||||
html: "Clear",
|
||||
});
|
||||
|
||||
const wrpStateBtnsOuter = e_({
|
||||
tag: "div",
|
||||
clazz: "ve-flex-v-center fltr__h-wrp-state-btns-outer",
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "btn-group ve-flex-v-center w-100",
|
||||
children: [
|
||||
btnClear,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const wrpSummary = e_({tag: "div", clazz: "ve-flex-vh-center ve-hidden"});
|
||||
|
||||
const btnShowHide = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} ml-2`,
|
||||
click: () => this._meta.isHidden = !this._meta.isHidden,
|
||||
html: "Hide",
|
||||
});
|
||||
const hookShowHide = () => {
|
||||
e_({ele: btnShowHide}).toggleClass("active", this._meta.isHidden);
|
||||
wrpStateBtnsOuter.toggleVe(!this._meta.isHidden);
|
||||
|
||||
// TODO
|
||||
// region Render summary
|
||||
const cur = this.getValues()[this.header];
|
||||
|
||||
const htmlSummary = [
|
||||
cur._totals?.yes
|
||||
? `<span class="fltr__summary_item fltr__summary_item--include" title="${cur._totals.yes} hidden "required" tags">${cur._totals.yes}</span>`
|
||||
: null,
|
||||
].filter(Boolean).join("");
|
||||
e_({ele: wrpSummary, html: htmlSummary}).toggleVe(this._meta.isHidden);
|
||||
// endregion
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookShowHide);
|
||||
hookShowHide();
|
||||
|
||||
return e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center fltr__h-wrp-btns-outer`,
|
||||
children: [
|
||||
wrpSummary,
|
||||
wrpStateBtnsOuter,
|
||||
btnShowHide,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
_doRenderPills () {
|
||||
this._items.sort(this.constructor._ascSortItems.bind(this.constructor));
|
||||
|
||||
if (!this.__wrpPills) return;
|
||||
this._items.forEach(it => {
|
||||
if (!it.rendered) it.rendered = this._getPill(it);
|
||||
if (!it.isAnyIncrease && !it.isAnyDecrease) it.rendered.toggleClass("fltr__pill--muted", !this._seenUids[it.uid]);
|
||||
|
||||
if (!this.__wrpPillsRows[it.ability]) {
|
||||
this.__wrpPillsRows[it.ability] = {
|
||||
row: e_({
|
||||
tag: "div",
|
||||
clazz: "ve-flex-v-center w-100 my-1",
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "mr-3 text-right fltr__label-ability-score no-shrink no-grow",
|
||||
text: Parser.attAbvToFull(it.ability),
|
||||
}),
|
||||
],
|
||||
}).appendTo(this.__wrpPills),
|
||||
searchText: Parser.attAbvToFull(it.ability).toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
it.rendered.appendTo(this.__wrpPillsRows[it.ability].row);
|
||||
});
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const unsetRow = () => {
|
||||
const nxtState = {};
|
||||
for (let i = this._minMod; i <= this._maxMod; ++i) {
|
||||
if (!i || i === item.modifier) continue;
|
||||
const siblingUid = AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, modifier: i});
|
||||
nxtState[siblingUid] = 0;
|
||||
}
|
||||
|
||||
if (!item.isAnyIncrease) nxtState[AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, isAnyIncrease: true})] = 0;
|
||||
if (!item.isAnyDecrease) nxtState[AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, isAnyDecrease: true})] = 0;
|
||||
|
||||
this._proxyAssignSimple("state", nxtState);
|
||||
};
|
||||
|
||||
const btnPill = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__pill fltr__pill--ability-bonus px-2`,
|
||||
html: item.getPillDisplayHtml(),
|
||||
click: evt => {
|
||||
if (evt.shiftKey) {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state).forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState, true);
|
||||
}
|
||||
|
||||
this._state[item.uid] = this._state[item.uid] ? 0 : 1;
|
||||
if (this._state[item.uid]) unsetRow();
|
||||
},
|
||||
contextmenu: (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
this._state[item.uid] = this._state[item.uid] ? 0 : 1;
|
||||
if (this._state[item.uid]) unsetRow();
|
||||
},
|
||||
});
|
||||
|
||||
const hook = () => {
|
||||
const val = FilterBox._PILL_STATES[this._state[item.uid] || 0];
|
||||
btnPill.attr("state", val);
|
||||
};
|
||||
this._addHook("state", item.uid, hook);
|
||||
hook();
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
_doRenderMiniPills () {
|
||||
// create a list view so we can freely sort
|
||||
this._items.slice(0)
|
||||
.sort(this.constructor._ascSortMiniPills.bind(this.constructor))
|
||||
.forEach(it => {
|
||||
// re-append existing elements to sort them
|
||||
(it.btnMini = it.btnMini || this._getBtnMini(it)).appendTo(this.__wrpMiniPills);
|
||||
});
|
||||
}
|
||||
|
||||
_getBtnMini (item) {
|
||||
const btnMini = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""}`,
|
||||
text: item.getMiniPillDisplayText(),
|
||||
title: `Filter: ${this.header}`,
|
||||
click: () => {
|
||||
this._state[item.uid] = 0;
|
||||
this._filterBox.fireChangeEvent();
|
||||
},
|
||||
}).attr("state", FilterBox._PILL_STATES[this._state[item.uid] || 0]);
|
||||
|
||||
const hook = () => btnMini.attr("state", FilterBox._PILL_STATES[this._state[item.uid] || 0]);
|
||||
this._addHook("state", item.uid, hook);
|
||||
|
||||
const hideHook = () => btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header));
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
|
||||
return btnMini;
|
||||
}
|
||||
|
||||
static _ascSortItems (a, b) {
|
||||
return SortUtil.ascSort(Number(b.isAnyIncrease), Number(a.isAnyIncrease))
|
||||
|| SortUtil.ascSortAtts(a.ability, b.ability)
|
||||
// Offset ability scores to ensure they're all in positive space. This forces the "any decrease" section to
|
||||
// appear last.
|
||||
|| SortUtil.ascSort(b.modifier ? b.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : b.modifier, a.modifier ? a.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : a.modifier)
|
||||
|| SortUtil.ascSort(Number(b.isAnyDecrease), Number(a.isAnyDecrease));
|
||||
}
|
||||
|
||||
static _ascSortMiniPills (a, b) {
|
||||
return SortUtil.ascSort(Number(b.isAnyIncrease), Number(a.isAnyIncrease))
|
||||
|| SortUtil.ascSort(Number(b.isAnyDecrease), Number(a.isAnyDecrease))
|
||||
// Offset ability scores to ensure they're all in positive space. This forces the "any decrease" section to
|
||||
// appear last.
|
||||
|| SortUtil.ascSort(b.modifier ? b.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : b.modifier, a.modifier ? a.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : a.modifier)
|
||||
|| SortUtil.ascSortAtts(a.ability, b.ability);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$renderMinis (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]});
|
||||
|
||||
this._doRenderMiniPills();
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const out = {
|
||||
_totals: {yes: 0},
|
||||
};
|
||||
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
Object.entries(state)
|
||||
.filter(([, value]) => value)
|
||||
.forEach(([uid]) => {
|
||||
out._totals.yes++;
|
||||
out[uid] = true;
|
||||
});
|
||||
|
||||
return {[this.header]: out};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
Object.keys(nxtState[this.header].state).forEach(k => delete nxtState[this.header].state[k]);
|
||||
}
|
||||
|
||||
update () {
|
||||
if (this._isItemsDirty) {
|
||||
this._isItemsDirty = false;
|
||||
|
||||
this._doRenderPills();
|
||||
}
|
||||
|
||||
// always render the mini-pills, to ensure the overall order in the grid stays correct (shared between multiple filters)
|
||||
this._doRenderMiniPills();
|
||||
}
|
||||
|
||||
_doSetPillsClear () {
|
||||
Object.keys(this._state).forEach(k => {
|
||||
if (this._state[k] !== 0) this._state[k] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true;
|
||||
|
||||
const activeItems = Object.keys(filterState)
|
||||
.filter(it => !it.startsWith("_"))
|
||||
.map(it => this._itemsLookup[it])
|
||||
.filter(Boolean);
|
||||
|
||||
if (!activeItems.length) return true;
|
||||
if ((!entryVal || !entryVal.length) && activeItems.length) return false;
|
||||
|
||||
return entryVal.some(abilObject => {
|
||||
const cpyAbilObject = MiscUtil.copy(abilObject);
|
||||
const vewActiveItems = [...activeItems];
|
||||
|
||||
// region Stage 1. Exact ability score match.
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (!cpyAbilObject[ab] || !vewActiveItems.length) return;
|
||||
|
||||
const ixExact = vewActiveItems.findIndex(it => it.ability === ab && it.modifier === cpyAbilObject[ab]);
|
||||
if (~ixExact) return vewActiveItems.splice(ixExact, 1);
|
||||
});
|
||||
if (!vewActiveItems.length) return true;
|
||||
// endregion
|
||||
|
||||
// region Stage 2. "Choice" ability score match
|
||||
if (cpyAbilObject.choose?.from) {
|
||||
const amount = cpyAbilObject.choose.amount || 1;
|
||||
const count = cpyAbilObject.choose.count || 1;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (!vewActiveItems.length) break;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.from.includes(it.ability) && amount === it.modifier);
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.from = cpyAbilObject.choose.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
}
|
||||
} else if (cpyAbilObject.choose?.weighted?.weights && cpyAbilObject.choose?.weighted?.from) {
|
||||
cpyAbilObject.choose.weighted.weights.forEach(weight => {
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.weighted.from.includes(it.ability) && weight === it.modifier);
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.weighted.from = cpyAbilObject.choose.weighted.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!vewActiveItems.length) return true;
|
||||
// endregion
|
||||
|
||||
// region Stage 3. "Any" ability score match
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (!cpyAbilObject[ab] || !vewActiveItems.length) return;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => it.ability === ab && ((cpyAbilObject[ab] > 0 && it.isAnyIncrease) || (cpyAbilObject[ab] < 0 && it.isAnyDecrease)));
|
||||
if (~ix) return vewActiveItems.splice(ix, 1);
|
||||
});
|
||||
if (!vewActiveItems.length) return true;
|
||||
|
||||
if (cpyAbilObject.choose?.from) {
|
||||
const amount = cpyAbilObject.choose.amount || 1;
|
||||
const count = cpyAbilObject.choose.count || 1;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (!vewActiveItems.length) return true;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.from.includes(it.ability) && ((amount > 0 && it.isAnyIncrease) || (amount < 0 && it.isAnyDecrease)));
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.from = cpyAbilObject.choose.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
}
|
||||
} else if (cpyAbilObject.choose?.weighted?.weights && cpyAbilObject.choose?.weighted?.from) {
|
||||
cpyAbilObject.choose.weighted.weights.forEach(weight => {
|
||||
if (!vewActiveItems.length) return;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.weighted.from.includes(it.ability) && ((weight > 0 && it.isAnyIncrease) || (weight < 0 && it.isAnyDecrease)));
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.weighted.from = cpyAbilObject.choose.weighted.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
});
|
||||
}
|
||||
return !vewActiveItems.length;
|
||||
// endregion
|
||||
});
|
||||
}
|
||||
|
||||
addItem (abilArr) {
|
||||
if (!abilArr?.length) return;
|
||||
|
||||
// region Update our min/max scores
|
||||
let nxtMaxMod = this._maxMod;
|
||||
let nxtMinMod = this._minMod;
|
||||
|
||||
abilArr.forEach(abilObject => {
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (abilObject[ab] != null) {
|
||||
nxtMaxMod = Math.max(nxtMaxMod, abilObject[ab]);
|
||||
nxtMinMod = Math.min(nxtMinMod, abilObject[ab]);
|
||||
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: abilObject[ab]});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (abilObject.choose?.from) {
|
||||
const amount = abilObject.choose.amount || 1;
|
||||
nxtMaxMod = Math.max(nxtMaxMod, amount);
|
||||
nxtMinMod = Math.min(nxtMinMod, amount);
|
||||
|
||||
abilObject.choose.from.forEach(ab => {
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: amount});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (abilObject.choose?.weighted?.weights) {
|
||||
nxtMaxMod = Math.max(nxtMaxMod, ...abilObject.choose.weighted.weights);
|
||||
nxtMinMod = Math.min(nxtMinMod, ...abilObject.choose.weighted.weights);
|
||||
|
||||
abilObject.choose.weighted.from.forEach(ab => {
|
||||
abilObject.choose.weighted.weights.forEach(weight => {
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: weight});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
// endregion
|
||||
|
||||
// region If we have a new max score, populate items
|
||||
if (nxtMaxMod > this._maxMod) {
|
||||
for (let i = this._maxMod + 1; i <= nxtMaxMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
this._isItemsDirty = true;
|
||||
this._maxMod = nxtMaxMod;
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region If we have a new min score, populate items
|
||||
if (nxtMinMod < this._minMod) {
|
||||
for (let i = nxtMinMod; i < this._minMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
this._isItemsDirty = true;
|
||||
this._minMod = nxtMinMod;
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
Object.assign(this._state, toLoad.state);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const areNotDefaultState = Object.entries(this._state).filter(([k, v]) => {
|
||||
if (k.startsWith("_")) return false;
|
||||
return !!v;
|
||||
});
|
||||
if (areNotDefaultState.length) {
|
||||
// serialize state as `key=value` pairs
|
||||
const serPillStates = areNotDefaultState.map(([k, v]) => `${k.toUrlified()}=${v}`);
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serPillStates));
|
||||
}
|
||||
|
||||
if (!out.length) return null;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
switch (prop) {
|
||||
case "state": {
|
||||
hasState = true;
|
||||
Object.keys(nxtState[this.header].state).forEach(k => nxtState[this.header].state[k] = 0);
|
||||
|
||||
vals.forEach(v => {
|
||||
const [statePropLower, state] = v.split("=");
|
||||
const stateProp = Object.keys(nxtState[this.header].state).find(k => k.toLowerCase() === statePropLower);
|
||||
if (stateProp) nxtState[this.header].state[stateProp] = Number(state) ? 1 : 0;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
const nxtState = {};
|
||||
Object.keys(this._state).forEach(k => nxtState[k] = 0);
|
||||
Object.assign(nxtState, values[this.header]);
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const isHeaderMatch = this.header.toLowerCase().includes(searchTerm);
|
||||
|
||||
if (isHeaderMatch) {
|
||||
Object.values(this.__wrpPillsRows).forEach(meta => meta.row.removeClass("fltr__hidden--search"));
|
||||
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simply display all if the user searched a "+x" or "-x" value; we don't care if this produces false positives.
|
||||
const isModNumber = /^[-+]\d*$/.test(searchTerm);
|
||||
|
||||
let visibleCount = 0;
|
||||
Object.values(this.__wrpPillsRows).forEach(({row, searchText}) => {
|
||||
const isVisible = isModNumber || searchText.includes(searchTerm);
|
||||
row.toggleClass("fltr__hidden--search", !isVisible);
|
||||
if (isVisible) visibleCount++;
|
||||
});
|
||||
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", visibleCount === 0);
|
||||
|
||||
return visibleCount !== 0;
|
||||
}
|
||||
|
||||
_doTeardown () {
|
||||
this._items.forEach(it => {
|
||||
if (it.rendered) it.rendered.detach();
|
||||
if (it.btnMini) it.btnMini.detach();
|
||||
});
|
||||
|
||||
Object.values(this.__wrpPillsRows).forEach(meta => meta.row.detach());
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._state)
|
||||
.filter(([, v]) => !!v);
|
||||
}
|
||||
|
||||
getFilterTagPart () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
const compressedMeta = this._getCompressedMeta({isStripUiKeys: true});
|
||||
|
||||
// If _any_ value is non-default, we need to include _all_ values in the tag
|
||||
// The same goes for meta values
|
||||
if (!areNotDefaultState.length && !compressedMeta) return null;
|
||||
|
||||
const pt = Object.entries(this._state)
|
||||
.filter(([, v]) => !!v)
|
||||
.map(([k, v]) => `${v === 2 ? "!" : ""}${k}`)
|
||||
.join(";")
|
||||
.toLowerCase();
|
||||
|
||||
return [
|
||||
this.header.toLowerCase(),
|
||||
pt,
|
||||
compressedMeta ? compressedMeta.join(HASH_SUB_LIST_SEP) : null,
|
||||
]
|
||||
.filter(it => it != null)
|
||||
.join("=");
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const areNotDefaultState = this._getStateNotDefault({nxtState});
|
||||
|
||||
// If _any_ value is non-default, we need to include _all_ values in the tag
|
||||
// The same goes for meta values
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
const ptState = Object.entries(state)
|
||||
.filter(([, v]) => !!v)
|
||||
.map(([k, v]) => {
|
||||
const item = this._items.find(item => item.uid === k);
|
||||
if (!item) return null; // Should never occur
|
||||
return `${v === 2 ? "not " : ""}${item.getMiniPillDisplayText()}`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return `${this.header}: ${ptState}`;
|
||||
}
|
||||
}
|
||||
|
||||
AbilityScoreFilter.FilterItem = class {
|
||||
static getUid_ ({ability = null, isAnyIncrease = false, isAnyDecrease = false, modifier = null}) {
|
||||
return `${Parser.attAbvToFull(ability)} ${modifier != null ? UiUtil.intToBonus(modifier) : (isAnyIncrease ? `+any` : isAnyDecrease ? `-any` : "?")}`;
|
||||
}
|
||||
|
||||
constructor ({isAnyIncrease = false, isAnyDecrease = false, modifier = null, ability = null}) {
|
||||
if (isAnyIncrease && isAnyDecrease) throw new Error(`Invalid arguments!`);
|
||||
if ((isAnyIncrease || isAnyDecrease) && modifier != null) throw new Error(`Invalid arguments!`);
|
||||
|
||||
this._ability = ability;
|
||||
this._modifier = modifier;
|
||||
this._isAnyIncrease = isAnyIncrease;
|
||||
this._isAnyDecrease = isAnyDecrease;
|
||||
this._uid = AbilityScoreFilter.FilterItem.getUid_({
|
||||
isAnyIncrease: this._isAnyIncrease,
|
||||
isAnyDecrease: this._isAnyDecrease,
|
||||
modifier: this._modifier,
|
||||
ability: this._ability,
|
||||
});
|
||||
}
|
||||
|
||||
get ability () { return this._ability; }
|
||||
get modifier () { return this._modifier; }
|
||||
get isAnyIncrease () { return this._isAnyIncrease; }
|
||||
get isAnyDecrease () { return this._isAnyDecrease; }
|
||||
get uid () { return this._uid; }
|
||||
|
||||
getMiniPillDisplayText () {
|
||||
if (this._isAnyIncrease) return `+Any ${Parser.attAbvToFull(this._ability)}`;
|
||||
if (this._isAnyDecrease) return `\u2012Any ${Parser.attAbvToFull(this._ability)}`;
|
||||
return `${UiUtil.intToBonus(this._modifier, {isPretty: true})} ${Parser.attAbvToFull(this._ability)}`;
|
||||
}
|
||||
|
||||
getPillDisplayHtml () {
|
||||
if (this._isAnyIncrease) return `+Any`;
|
||||
if (this._isAnyDecrease) return `\u2012Any`;
|
||||
return UiUtil.intToBonus(this._modifier, {isPretty: true});
|
||||
}
|
||||
};
|
||||
186
js/filter/filter/filter-filter-base.js
Normal file
186
js/filter/filter/filter-filter-base.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import {FilterRegistry} from "../filter-registry.js";
|
||||
|
||||
export class FilterBase extends BaseComponent {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.header Filter header (name)
|
||||
* @param [opts.headerHelp] Filter header help text (tooltip)
|
||||
*/
|
||||
constructor (opts) {
|
||||
super();
|
||||
this._filterBox = null;
|
||||
|
||||
this.header = opts.header;
|
||||
this._headerHelp = opts.headerHelp;
|
||||
|
||||
this.__meta = {...this.getDefaultMeta()};
|
||||
this._meta = this._getProxy("meta", this.__meta);
|
||||
|
||||
this._hasUserSavedState = false;
|
||||
}
|
||||
|
||||
_getRenderedHeader () {
|
||||
return `<span ${this._headerHelp ? `title="${this._headerHelp.escapeQuotes()}" class="help-subtle"` : ""}>${this.header}</span>`;
|
||||
}
|
||||
|
||||
set filterBox (it) { this._filterBox = it; }
|
||||
|
||||
show () { this._meta.isHidden = false; }
|
||||
|
||||
hide () { this._meta.isHidden = true; }
|
||||
|
||||
getBaseSaveableState () { return {meta: {...this.__meta}}; }
|
||||
|
||||
_getNextState_base () {
|
||||
return {
|
||||
[this.header]: {
|
||||
state: MiscUtil.copyFast(this.__state),
|
||||
meta: MiscUtil.copyFast(this.__meta),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromNextState (nxtState) {
|
||||
this._proxyAssignSimple("state", nxtState[this.header].state, true);
|
||||
this._proxyAssignSimple("meta", nxtState[this.header].meta, true);
|
||||
}
|
||||
|
||||
reset ({isResetAll = false} = {}) {
|
||||
const nxtState = this._getNextState_base();
|
||||
this._mutNextState_reset(nxtState, {isResetAll});
|
||||
this.setStateFromNextState(nxtState);
|
||||
}
|
||||
|
||||
_mutNextState_resetBase (nxtState, {isResetAll = false} = {}) {
|
||||
Object.assign(nxtState[this.header].meta, MiscUtil.copy(this.getDefaultMeta()));
|
||||
}
|
||||
|
||||
getMetaSubHashes () {
|
||||
const compressedMeta = this._getCompressedMeta();
|
||||
if (!compressedMeta) return null;
|
||||
return [UrlUtil.packSubHash(this.getSubHashPrefix("meta", this.header), compressedMeta)];
|
||||
}
|
||||
|
||||
_mutNextState_meta_fromSubHashState (nxtState, subHashState) {
|
||||
const hasMeta = this._mutNextState_meta_fromSubHashState_mutGetHasMeta(nxtState, subHashState, this.getDefaultMeta());
|
||||
if (!hasMeta) this._mutNextState_resetBase(nxtState);
|
||||
}
|
||||
|
||||
_mutNextState_meta_fromSubHashState_mutGetHasMeta (nxtState, state, defaultMeta) {
|
||||
let hasMeta = false;
|
||||
|
||||
Object.entries(state)
|
||||
.forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop !== "meta") return;
|
||||
|
||||
hasMeta = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(defaultMeta).forEach((k, i) => {
|
||||
if (data[i] !== undefined) nxtState[this.header].meta[k] = data[i];
|
||||
else nxtState[this.header].meta[k] = defaultMeta[k];
|
||||
});
|
||||
});
|
||||
|
||||
return hasMeta;
|
||||
}
|
||||
|
||||
setBaseStateFromLoaded (toLoad) { Object.assign(this._meta, toLoad.meta); }
|
||||
|
||||
getSubHashPrefix (prop, header) {
|
||||
if (FilterBase._SUB_HASH_PREFIXES[prop]) {
|
||||
const prefix = this._filterBox.getNamespacedHashKey(FilterBase._SUB_HASH_PREFIXES[prop]);
|
||||
return `${prefix}${header.toUrlified()}`;
|
||||
}
|
||||
throw new Error(`Unknown property "${prop}"`);
|
||||
}
|
||||
|
||||
static getProp (prefix) {
|
||||
return Parser._parse_bToA(FilterBase._SUB_HASH_PREFIXES, prefix);
|
||||
}
|
||||
|
||||
_getBtnMobToggleControls (wrpControls) {
|
||||
const btnMobToggleControls = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default mobile__visible ml-auto px-3 mr-2`,
|
||||
html: `<span class="glyphicon glyphicon-option-vertical"></span>`,
|
||||
click: () => this._meta.isMobileHeaderHidden = !this._meta.isMobileHeaderHidden,
|
||||
});
|
||||
const hkMobHeaderHidden = () => {
|
||||
btnMobToggleControls.toggleClass("active", !this._meta.isMobileHeaderHidden);
|
||||
wrpControls.toggleClass("mobile__hidden", !!this._meta.isMobileHeaderHidden);
|
||||
};
|
||||
this._addHook("meta", "isMobileHeaderHidden", hkMobHeaderHidden);
|
||||
hkMobHeaderHidden();
|
||||
|
||||
return btnMobToggleControls;
|
||||
}
|
||||
|
||||
getChildFilters () { return []; }
|
||||
getDefaultMeta () { return {...FilterBase._DEFAULT_META}; }
|
||||
|
||||
/**
|
||||
* @param vals Previously-read filter value may be passed in for performance.
|
||||
*/
|
||||
isActive (vals) {
|
||||
vals = vals || this.getValues();
|
||||
return vals[this.header]._isActive;
|
||||
}
|
||||
|
||||
_getCompressedMeta ({isStripUiKeys = false} = {}) {
|
||||
const defaultMeta = this.getDefaultMeta();
|
||||
const isAnyNotDefault = Object.keys(defaultMeta).some(k => this._meta[k] !== defaultMeta[k]);
|
||||
if (!isAnyNotDefault) return null;
|
||||
|
||||
let keys = Object.keys(defaultMeta);
|
||||
|
||||
if (isStripUiKeys) {
|
||||
// Always pop the trailing n keys, as these are all UI options, which we don't want to embed in @filter tags
|
||||
const popCount = Object.keys(FilterBase._DEFAULT_META).length;
|
||||
if (popCount) keys = keys.slice(0, -popCount);
|
||||
}
|
||||
|
||||
// Pop keys from the end if they match the default value
|
||||
while (keys.length && defaultMeta[keys.last()] === this._meta[keys.last()]) keys.pop();
|
||||
|
||||
return keys.map(k => UrlUtil.mini.compress(this._meta[k] === undefined ? defaultMeta[k] : this._meta[k]));
|
||||
}
|
||||
|
||||
$render () { throw new Error(`Unimplemented!`); }
|
||||
$renderMinis () { throw new Error(`Unimplemented!`); }
|
||||
getValues ({nxtState = null} = {}) { throw new Error(`Unimplemented!`); }
|
||||
_mutNextState_reset () { throw new Error(`Unimplemented!`); }
|
||||
update () { throw new Error(`Unimplemented!`); }
|
||||
toDisplay () { throw new Error(`Unimplemented!`); }
|
||||
addItem () { throw new Error(`Unimplemented!`); }
|
||||
// N.B.: due to a bug in Chrome, these return a copy of the underlying state rather than a copy of the proxied state
|
||||
getSaveableState () { throw new Error(`Unimplemented!`); }
|
||||
setStateFromLoaded () { throw new Error(`Unimplemented!`); }
|
||||
getSubHashes () { throw new Error(`Unimplemented!`); }
|
||||
getNextStateFromSubhashState () { throw new Error(`Unimplemented!`); }
|
||||
setFromValues () { throw new Error(`Unimplemented!`); }
|
||||
handleSearch () { throw new Error(`Unimplemented`); }
|
||||
getFilterTagPart () { throw new Error(`Unimplemented`); }
|
||||
getDisplayStatePart ({nxtState = null} = {}) { throw new Error(`Unimplemented`); }
|
||||
_doTeardown () { /* No-op */ }
|
||||
trimState_ () { /* No-op */ }
|
||||
}
|
||||
|
||||
FilterBase._DEFAULT_META = {
|
||||
isHidden: false,
|
||||
isMobileHeaderHidden: true,
|
||||
};
|
||||
|
||||
// These are assumed to be the same length (4 characters)
|
||||
FilterBase._SUB_HASH_STATE_PREFIX = "flst";
|
||||
FilterBase._SUB_HASH_META_PREFIX = "flmt";
|
||||
FilterBase._SUB_HASH_NESTS_HIDDEN_PREFIX = "flnh";
|
||||
FilterBase._SUB_HASH_OPTIONS_PREFIX = "flop";
|
||||
FilterBase._SUB_HASH_PREFIXES = {
|
||||
state: FilterBase._SUB_HASH_STATE_PREFIX,
|
||||
meta: FilterBase._SUB_HASH_META_PREFIX,
|
||||
nestsHidden: FilterBase._SUB_HASH_NESTS_HIDDEN_PREFIX,
|
||||
options: FilterBase._SUB_HASH_OPTIONS_PREFIX,
|
||||
};
|
||||
|
||||
FilterRegistry.registerSubhashes(Object.values(FilterBase._SUB_HASH_PREFIXES));
|
||||
1084
js/filter/filter/filter-filter-generic.js
Normal file
1084
js/filter/filter/filter-filter-generic.js
Normal file
File diff suppressed because it is too large
Load Diff
307
js/filter/filter/filter-filter-multi.js
Normal file
307
js/filter/filter/filter-filter-multi.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {RangeFilter} from "./filter-filter-range.js";
|
||||
|
||||
export class MultiFilter extends FilterBase {
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
this._filters = opts.filters;
|
||||
this._isAddDropdownToggle = !!opts.isAddDropdownToggle;
|
||||
|
||||
Object.assign(
|
||||
this.__state,
|
||||
{
|
||||
...MultiFilter._DETAULT_STATE,
|
||||
mode: opts.mode || MultiFilter._DETAULT_STATE.mode,
|
||||
},
|
||||
);
|
||||
this._defaultState = MiscUtil.copy(this.__state);
|
||||
this._state = this._getProxy("state", this.__state);
|
||||
|
||||
this.__$wrpFilter = null;
|
||||
this._$wrpChildren = null;
|
||||
}
|
||||
|
||||
getChildFilters () {
|
||||
return [...this._filters, ...this._filters.map(f => f.getChildFilters())].flat();
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
const out = {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
this._filters.forEach(it => Object.assign(out, it.getSaveableState()));
|
||||
return out;
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
Object.assign(this._state, toLoad.state);
|
||||
this._filters.forEach(it => it.setStateFromLoaded(filterState, {isUserSavedState}));
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const anyNotDefault = this._getStateNotDefault();
|
||||
if (anyNotDefault.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), this._getCompressedState()));
|
||||
}
|
||||
|
||||
// each getSubHashes should return an array of arrays, or null
|
||||
// flatten any arrays of arrays into our array of arrays
|
||||
this._filters.map(it => it.getSubHashes()).filter(Boolean).forEach(it => out.push(...it));
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._defaultState)
|
||||
.filter(([k, v]) => this._state[k] !== v);
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
return [
|
||||
this._getFilterTagPart_self(),
|
||||
...this._filters.map(it => it.getFilterTagPart()).filter(Boolean),
|
||||
]
|
||||
.filter(it => it != null)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
_getFilterTagPart_self () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
return `${this.header.toLowerCase()}=${this._getCompressedState().join(HASH_SUB_LIST_SEP)}`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
return this._filters.map(it => it.getDisplayStatePart({nxtState}))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
_getCompressedState () {
|
||||
return Object.keys(this._defaultState)
|
||||
.map(k => UrlUtil.mini.compress(this._state[k] === undefined ? this._defaultState[k] : this._state[k]));
|
||||
}
|
||||
|
||||
setStateFromNextState (nxtState) {
|
||||
super.setStateFromNextState(nxtState);
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset_self(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop === "state") {
|
||||
hasState = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(this._defaultState).forEach((k, i) => nxtState[this.header].state[k] = data[i]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset_self(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
this._filters.forEach(it => it.setFromValues(values));
|
||||
}
|
||||
|
||||
_getHeaderControls (opts) {
|
||||
const wrpSummary = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr__summary_item",
|
||||
}).hideVe();
|
||||
|
||||
const btnForceMobile = this._isAddDropdownToggle ? ComponentUiUtil.getBtnBool(
|
||||
this,
|
||||
"isUseDropdowns",
|
||||
{
|
||||
$ele: $(`<button class="btn btn-default btn-xs ml-2">Show as Dropdowns</button>`),
|
||||
stateName: "meta",
|
||||
stateProp: "_meta",
|
||||
},
|
||||
) : null;
|
||||
// Propagate parent state to children
|
||||
const hkChildrenDropdowns = () => {
|
||||
this._filters
|
||||
.filter(it => it instanceof RangeFilter)
|
||||
.forEach(it => it.isUseDropdowns = this._meta.isUseDropdowns);
|
||||
};
|
||||
this._addHook("meta", "isUseDropdowns", hkChildrenDropdowns);
|
||||
hkChildrenDropdowns();
|
||||
|
||||
const btnResetAll = e_({
|
||||
tag: "button",
|
||||
clazz: "btn btn-default btn-xs ml-2",
|
||||
text: "Reset All",
|
||||
click: () => this._filters.forEach(it => it.reset()),
|
||||
});
|
||||
|
||||
const wrpBtns = e_({tag: "div", clazz: "ve-flex", children: [btnForceMobile, btnResetAll].filter(Boolean)});
|
||||
this._getHeaderControls_addExtraStateBtns(opts, wrpBtns);
|
||||
|
||||
const btnShowHide = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}`,
|
||||
text: "Hide",
|
||||
click: () => this._meta.isHidden = !this._meta.isHidden,
|
||||
});
|
||||
const wrpControls = e_({tag: "div", clazz: "ve-flex-v-center", children: [wrpSummary, wrpBtns, btnShowHide]});
|
||||
|
||||
const hookShowHide = () => {
|
||||
wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
this._$wrpChildren.toggleVe(!this._meta.isHidden);
|
||||
wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
const numActive = this._filters.map(it => it.getValues()[it.header]._isActive).filter(Boolean).length;
|
||||
if (numActive) {
|
||||
e_({ele: wrpSummary, title: `${numActive} hidden active filter${numActive === 1 ? "" : "s"}`, text: `(${numActive})`});
|
||||
}
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookShowHide);
|
||||
hookShowHide();
|
||||
|
||||
return wrpControls;
|
||||
}
|
||||
|
||||
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {}
|
||||
|
||||
$render (opts) {
|
||||
const btnAndOr = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__group-comb-toggle ve-muted`,
|
||||
click: () => this._state.mode = this._state.mode === "and" ? "or" : "and",
|
||||
title: `"Group AND" requires all filters in this group to match. "Group OR" required any filter in this group to match.`,
|
||||
});
|
||||
|
||||
const hookAndOr = () => btnAndOr.innerText = `(group ${this._state.mode.toUpperCase()})`;
|
||||
this._addHook("state", "mode", hookAndOr);
|
||||
hookAndOr();
|
||||
|
||||
const $children = this._filters.map((it, i) => it.$render({...opts, isMulti: true, isFirst: i === 0}));
|
||||
this._$wrpChildren = $$`<div>${$children}</div>`;
|
||||
|
||||
const wrpControls = this._getHeaderControls(opts);
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h fltr__h--multi ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="ve-flex-v-center">
|
||||
<div class="mr-2">${this._getRenderedHeader()}</div>
|
||||
${btnAndOr}
|
||||
</div>
|
||||
${wrpControls}
|
||||
</div>
|
||||
${this._$wrpChildren}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
this._filters.map((it, i) => it.$renderMinis({...opts, isMulti: true, isFirst: i === 0}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param vals Previously-read filter value may be passed in for performance.
|
||||
*/
|
||||
isActive (vals) {
|
||||
vals = vals || this.getValues();
|
||||
return this._filters.some(it => it.isActive(vals));
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const out = {};
|
||||
this._filters.forEach(it => Object.assign(out, it.getValues({nxtState})));
|
||||
return out;
|
||||
}
|
||||
|
||||
_mutNextState_reset_self (nxtState) {
|
||||
Object.assign(nxtState[this.header].state, MiscUtil.copy(this._defaultState));
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
this._mutNextState_reset_self(nxtState);
|
||||
}
|
||||
|
||||
reset ({isResetAll = false} = {}) {
|
||||
super.reset({isResetAll});
|
||||
this._filters.forEach(it => it.reset({isResetAll}));
|
||||
}
|
||||
|
||||
update () {
|
||||
this._filters.forEach(it => it.update());
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryValArr) {
|
||||
if (this._filters.length !== entryValArr.length) throw new Error("Number of filters and number of values did not match");
|
||||
|
||||
const results = [];
|
||||
for (let i = this._filters.length - 1; i >= 0; --i) {
|
||||
const f = this._filters[i];
|
||||
if (f instanceof RangeFilter) {
|
||||
results.push(f.toDisplay(boxState, entryValArr[i]));
|
||||
} else {
|
||||
const totals = boxState[f.header]._totals;
|
||||
|
||||
if (totals.yes === 0 && totals.no === 0) results.push(null);
|
||||
else results.push(f.toDisplay(boxState, entryValArr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
const resultsActive = results.filter(r => r !== null);
|
||||
if (this._state.mode === "or") {
|
||||
if (!resultsActive.length) return true;
|
||||
return resultsActive.find(r => r);
|
||||
} else {
|
||||
return resultsActive.filter(r => r).length === resultsActive.length;
|
||||
}
|
||||
}
|
||||
|
||||
addItem () { throw new Error(`Cannot add item to MultiFilter! Add the item to a child filter instead.`); }
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const isHeaderMatch = this.header.toLowerCase().includes(searchTerm);
|
||||
|
||||
if (isHeaderMatch) {
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false);
|
||||
// Force-display the children if the parent is visible
|
||||
this._filters.forEach(it => it.handleSearch(""));
|
||||
return true;
|
||||
}
|
||||
|
||||
const numVisible = this._filters.map(it => it.handleSearch(searchTerm)).reduce((a, b) => a + b, 0);
|
||||
if (!this.__$wrpFilter) return;
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", numVisible === 0);
|
||||
}
|
||||
|
||||
_doTeardown () { this._filters.forEach(it => it._doTeardown()); }
|
||||
trimState_ () { this._filters.forEach(it => it.trimState_()); }
|
||||
}
|
||||
MultiFilter._DETAULT_STATE = {
|
||||
mode: "and",
|
||||
};
|
||||
302
js/filter/filter/filter-filter-options.js
Normal file
302
js/filter/filter/filter-filter-options.js
Normal file
@@ -0,0 +1,302 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class OptionsFilter extends FilterBase {
|
||||
/**
|
||||
* A filter which has a selection of true/false options.
|
||||
* @param opts
|
||||
* @param opts.defaultState The default options.
|
||||
* @param opts.displayFn Display function which maps an option key to a user-friendly value.
|
||||
* @param [opts.displayFnMini] As per `displayFn`, but used for mini pills.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
this._defaultState = opts.defaultState;
|
||||
this._displayFn = opts.displayFn;
|
||||
this._displayFnMini = opts.displayFnMini;
|
||||
|
||||
Object.assign(
|
||||
this.__state,
|
||||
MiscUtil.copy(opts.defaultState),
|
||||
);
|
||||
|
||||
this._filterBox = null;
|
||||
this.__$wrpMini = null;
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
|
||||
const toAssign = {};
|
||||
Object.keys(this._defaultState).forEach(k => {
|
||||
if (toLoad.state[k] == null) return;
|
||||
if (typeof toLoad.state[k] !== typeof this._defaultState[k]) return; // Sanity check
|
||||
toAssign[k] = toLoad.state[k];
|
||||
});
|
||||
|
||||
Object.assign(this._state, toAssign);
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._state)
|
||||
.filter(([k, v]) => this._defaultState[k] !== v);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const serOptionState = [];
|
||||
Object.entries(this._defaultState)
|
||||
.forEach(([k, vDefault]) => {
|
||||
if (this._state[k] !== vDefault) serOptionState.push(`${k.toLowerCase()}=${UrlUtil.mini.compress(this._state[k])}`);
|
||||
});
|
||||
if (serOptionState.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serOptionState));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
const pt = areNotDefaultState
|
||||
.map(([k, v]) => `${v ? "" : "!"}${k}`)
|
||||
.join(";").toLowerCase();
|
||||
|
||||
return `${this.header.toLowerCase()}=::${pt}::`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
/* Implement if required */
|
||||
return null;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop !== "state") return;
|
||||
|
||||
hasState = true;
|
||||
vals.forEach(v => {
|
||||
const [prop, valCompressed] = v.split("=");
|
||||
const val = UrlUtil.mini.decompress(valCompressed);
|
||||
|
||||
const casedProp = Object.keys(this._defaultState).find(k => k.toLowerCase() === prop);
|
||||
if (!casedProp) return;
|
||||
|
||||
if (this._defaultState[casedProp] != null && typeof val === typeof this._defaultState[casedProp]) nxtState[this.header].state[casedProp] = val;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
const vals = values[this.header];
|
||||
Object.entries(vals).forEach(([k, v]) => {
|
||||
if (this._defaultState[k] && typeof this._defaultState[k] === typeof v) this._state[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
setValue (k, v) { this._state[k] = v; }
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $wrpControls = opts.isMulti ? null : this._$getHeaderControls();
|
||||
|
||||
const $btns = Object.keys(this._defaultState)
|
||||
.map(k => this._$render_$getPill(k));
|
||||
const $wrpButtons = $$`<div>${$btns}</div>`;
|
||||
|
||||
if (opts.isMulti) {
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex">
|
||||
<div class="fltr__range-inline-label mr-2">${this._getRenderedHeader()}</div>
|
||||
${$wrpButtons}
|
||||
</div>`;
|
||||
} else {
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="fltr__h-text ve-flex-h-center">${this._getRenderedHeader()}</div>
|
||||
${$wrpControls}
|
||||
</div>
|
||||
${$wrpButtons}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
if (!opts.$wrpMini) return;
|
||||
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $btnsMini = Object.keys(this._defaultState)
|
||||
.map(k => this._$render_$getMiniPill(k));
|
||||
$btnsMini.forEach($btn => $btn.appendTo(this.__$wrpMini));
|
||||
}
|
||||
|
||||
_$render_$getPill (key) {
|
||||
const displayText = this._displayFn(key);
|
||||
|
||||
const $btnPill = $(`<div class="fltr__pill">${displayText}</div>`)
|
||||
.click(() => {
|
||||
this._state[key] = !this._state[key];
|
||||
})
|
||||
.contextmenu((evt) => {
|
||||
evt.preventDefault();
|
||||
this._state[key] = !this._state[key];
|
||||
});
|
||||
const hook = () => {
|
||||
const val = FilterBox._PILL_STATES[this._state[key] ? 1 : 2];
|
||||
$btnPill.attr("state", val);
|
||||
};
|
||||
this._addHook("state", key, hook);
|
||||
hook();
|
||||
|
||||
return $btnPill;
|
||||
}
|
||||
|
||||
_$render_$getMiniPill (key) {
|
||||
const displayTextFull = this._displayFnMini ? this._displayFn(key) : null;
|
||||
const displayText = this._displayFnMini ? this._displayFnMini(key) : this._displayFn(key);
|
||||
|
||||
const $btnMini = $(`<div class="fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""}" state="${FilterBox._PILL_STATES[this._defaultState[key] === this._state[key] ? 0 : this._state[key] ? 1 : 2]}">${displayText}</div>`)
|
||||
.title(`${displayTextFull ? `${displayTextFull} (` : ""}Filter: ${this.header}${displayTextFull ? ")" : ""}`)
|
||||
.click(() => {
|
||||
this._state[key] = this._defaultState[key];
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
|
||||
const hook = () => $btnMini.attr("state", FilterBox._PILL_STATES[this._defaultState[key] === this._state[key] ? 0 : this._state[key] ? 1 : 2]);
|
||||
this._addHook("state", key, hook);
|
||||
|
||||
const hideHook = () => $btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header));
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
|
||||
return $btnMini;
|
||||
}
|
||||
|
||||
_$getHeaderControls () {
|
||||
const $btnReset = $(`<button class="btn btn-default btn-xs">Reset</button>`).click(() => this.reset());
|
||||
const $wrpBtns = $$`<div class="ve-flex-v-center">${$btnReset}</div>`;
|
||||
|
||||
const $wrpSummary = $(`<div class="ve-flex-v-center fltr__summary_item fltr__summary_item--include"></div>`).hideVe();
|
||||
|
||||
const $btnShowHide = $(`<button class="btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}">Hide</button>`)
|
||||
.click(() => this._meta.isHidden = !this._meta.isHidden);
|
||||
const hkIsHidden = () => {
|
||||
$btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
$wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
$wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
// render summary
|
||||
const cntNonDefault = Object.entries(this._defaultState).filter(([k, v]) => this._state[k] != null && this._state[k] !== v).length;
|
||||
|
||||
$wrpSummary
|
||||
.title(`${cntNonDefault} non-default option${cntNonDefault === 1 ? "" : "s"} selected`)
|
||||
.text(cntNonDefault);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hkIsHidden);
|
||||
hkIsHidden();
|
||||
|
||||
return $$`
|
||||
<div class="ve-flex-v-center">
|
||||
${$wrpBtns}
|
||||
${$wrpSummary}
|
||||
${$btnShowHide}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const out = Object.entries(this._defaultState)
|
||||
.mergeMap(([k, v]) => ({[k]: state[k] == null ? v : state[k]}));
|
||||
out._isActive = Object.entries(this._defaultState).some(([k, v]) => state[k] != null && state[k] !== v);
|
||||
return {
|
||||
[this.header]: out,
|
||||
};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
Object.assign(nxtState[this.header].state, MiscUtil.copy(this._defaultState));
|
||||
}
|
||||
|
||||
update () { /* No-op */ }
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true; // discount any filters which were not rendered
|
||||
|
||||
if (entryVal == null) return true; // Never filter if a null object, i.e. "no data," is passed in
|
||||
|
||||
// If an object has a relevant value, display if the incoming value matches our state.
|
||||
return Object.entries(entryVal)
|
||||
.every(([k, v]) => this._state[k] === v);
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
return {
|
||||
...super.getDefaultMeta(),
|
||||
...OptionsFilter._DEFAULT_META,
|
||||
};
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
if (this.__$wrpFilter == null) return;
|
||||
|
||||
const isVisible = this.header.toLowerCase().includes(searchTerm)
|
||||
|| Object.keys(this._defaultState).map(it => this._displayFn(it).toLowerCase()).some(it => it.includes(searchTerm));
|
||||
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", !isVisible);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
}
|
||||
OptionsFilter._DEFAULT_META = {};
|
||||
658
js/filter/filter/filter-filter-range.js
Normal file
658
js/filter/filter/filter-filter-range.js
Normal file
@@ -0,0 +1,658 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class RangeFilter extends FilterBase {
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param opts.header Filter header (name)
|
||||
* @param [opts.headerHelp] Filter header help text (tooltip)
|
||||
* @param [opts.min] Minimum slider value.
|
||||
* @param [opts.max] Maximum slider value.
|
||||
* @param [opts.isSparse] If this slider should only display known values, rather than a continual range.
|
||||
* @param [opts.isLabelled] If this slider has labels.
|
||||
* @param [opts.labels] Initial labels to populate this filter with.
|
||||
* @param [opts.isAllowGreater] If this slider should allow all items greater than its max.
|
||||
* @param [opts.isRequireFullRangeMatch] If range values, e.g. `[1, 5]`, must be entirely within the slider's
|
||||
* selected range in order to be produce a positive `toDisplay` result.
|
||||
* @param [opts.suffix] Suffix to add to number displayed above slider.
|
||||
* @param [opts.labelSortFn] Function used to sort labels if new labels are added. Defaults to ascending alphabetical.
|
||||
* @param [opts.labelDisplayFn] Function which converts a label to a display value.
|
||||
* @param [opts.displayFn] Function which converts a (non-label) value to a display value.
|
||||
* @param [opts.displayFnTooltip] Function which converts a (non-label) value to a tooltip display value.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
if (opts.labels && opts.min == null) opts.min = 0;
|
||||
if (opts.labels && opts.max == null) opts.max = opts.labels.length - 1;
|
||||
|
||||
this._min = Number(opts.min || 0);
|
||||
this._max = Number(opts.max || 0);
|
||||
this._labels = opts.isLabelled ? opts.labels : null;
|
||||
this._isAllowGreater = !!opts.isAllowGreater;
|
||||
this._isRequireFullRangeMatch = !!opts.isRequireFullRangeMatch;
|
||||
this._sparseValues = opts.isSparse ? [] : null;
|
||||
this._suffix = opts.suffix;
|
||||
this._labelSortFn = opts.labelSortFn === undefined ? SortUtil.ascSort : opts.labelSortFn;
|
||||
this._labelDisplayFn = opts.labelDisplayFn;
|
||||
this._displayFn = opts.displayFn;
|
||||
this._displayFnTooltip = opts.displayFnTooltip;
|
||||
|
||||
this._filterBox = null;
|
||||
Object.assign(
|
||||
this.__state,
|
||||
{
|
||||
min: this._min,
|
||||
max: this._max,
|
||||
curMin: this._min,
|
||||
curMax: this._max,
|
||||
},
|
||||
);
|
||||
this.__$wrpFilter = null;
|
||||
this.__$wrpMini = null;
|
||||
this._slider = null;
|
||||
|
||||
this._labelSearchCache = null;
|
||||
|
||||
this._$btnMiniGt = null;
|
||||
this._$btnMiniLt = null;
|
||||
this._$btnMiniEq = null;
|
||||
|
||||
// region Trimming
|
||||
this._seenMin = this._min;
|
||||
this._seenMax = this._max;
|
||||
// endregion
|
||||
}
|
||||
|
||||
set isUseDropdowns (val) { this._meta.isUseDropdowns = !!val; }
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
|
||||
// region Ensure to-be-loaded state is populated with sensible data
|
||||
const tgt = (toLoad.state || {});
|
||||
|
||||
if (tgt.max == null) tgt.max = this._max;
|
||||
else if (this._max > tgt.max) {
|
||||
if (tgt.max === tgt.curMax) tgt.curMax = this._max; // If it's set to "max", respect this
|
||||
tgt.max = this._max;
|
||||
}
|
||||
|
||||
if (tgt.curMax == null) tgt.curMax = tgt.max;
|
||||
else if (tgt.curMax > tgt.max) tgt.curMax = tgt.max;
|
||||
|
||||
if (tgt.min == null) tgt.min = this._min;
|
||||
else if (this._min < tgt.min) {
|
||||
if (tgt.min === tgt.curMin) tgt.curMin = this._min; // If it's set to "min", respect this
|
||||
tgt.min = this._min;
|
||||
}
|
||||
|
||||
if (tgt.curMin == null) tgt.curMin = tgt.min;
|
||||
else if (tgt.curMin < tgt.min) tgt.curMin = tgt.min;
|
||||
// endregion
|
||||
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
|
||||
Object.assign(this._state, toLoad.state);
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
if (this._seenMin <= this._state.min && this._seenMax >= this._state.max) return;
|
||||
|
||||
const nxtState = {min: this._seenMin, curMin: this._seenMin, max: this._seenMax, curMax: this._seenMax};
|
||||
this._proxyAssignSimple("state", nxtState);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const serSliderState = [
|
||||
this._state.min !== this._state.curMin ? `min=${this._state.curMin}` : null,
|
||||
this._state.max !== this._state.curMax ? `max=${this._state.curMax}` : null,
|
||||
].filter(Boolean);
|
||||
if (serSliderState.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serSliderState));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
_isAtDefaultPosition ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
return state.min === state.curMin && state.max === state.curMax;
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
if (this._isAtDefaultPosition()) return null;
|
||||
|
||||
if (!this._labels) {
|
||||
if (this._state.curMin === this._state.curMax) return `${this.header}=[${this._state.curMin}]`;
|
||||
return `${this.header}=[${this._state.curMin};${this._state.curMax}]`;
|
||||
}
|
||||
|
||||
if (this._state.curMin === this._state.curMax) {
|
||||
const label = this._labels[this._state.curMin];
|
||||
return `${this.header}=[&${label}]`;
|
||||
}
|
||||
|
||||
const labelLow = this._labels[this._state.curMin];
|
||||
const labelHigh = this._labels[this._state.curMax];
|
||||
return `${this.header}=[&${labelLow};&${labelHigh}]`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
if (this._isAtDefaultPosition({nxtState})) return null;
|
||||
|
||||
const {summary} = this._getDisplaySummary({nxtState});
|
||||
|
||||
return `${this.header}: ${summary}`;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop === "state") {
|
||||
hasState = true;
|
||||
vals.forEach(v => {
|
||||
const [prop, val] = v.split("=");
|
||||
if (val.startsWith("&") && !this._labels) throw new Error(`Could not dereference label: "${val}"`);
|
||||
|
||||
let num;
|
||||
if (val.startsWith("&")) { // prefixed with "&" for "address (index) of..."
|
||||
const clean = val.replace("&", "").toLowerCase();
|
||||
num = this._labels.findIndex(it => String(it).toLowerCase() === clean);
|
||||
if (!~num) throw new Error(`Could not find index for label "${clean}"`);
|
||||
} else num = Number(val);
|
||||
|
||||
switch (prop) {
|
||||
case "min":
|
||||
if (num < nxtState[this.header].state.min) nxtState[this.header].state.min = num;
|
||||
nxtState[this.header].state.curMin = Math.max(nxtState[this.header].state.min, num);
|
||||
break;
|
||||
case "max":
|
||||
if (num > nxtState[this.header].state.max) nxtState[this.header].state.max = num;
|
||||
nxtState[this.header].state.curMax = Math.min(nxtState[this.header].state.max, num);
|
||||
break;
|
||||
default: throw new Error(`Unknown prop "${prop}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
|
||||
const vals = values[this.header];
|
||||
|
||||
if (vals.min != null) this._state.curMin = Math.max(this._state.min, vals.min);
|
||||
else this._state.curMin = this._state.min;
|
||||
|
||||
if (vals.max != null) this._state.curMax = Math.max(this._state.max, vals.max);
|
||||
else this._state.curMax = this._state.max;
|
||||
}
|
||||
|
||||
_$getHeaderControls () {
|
||||
const $btnForceMobile = ComponentUiUtil.$getBtnBool(
|
||||
this,
|
||||
"isUseDropdowns",
|
||||
{
|
||||
$ele: $(`<button class="btn btn-default btn-xs mr-2">Show as Dropdowns</button>`),
|
||||
stateName: "meta",
|
||||
stateProp: "_meta",
|
||||
},
|
||||
);
|
||||
const $btnReset = $(`<button class="btn btn-default btn-xs">Reset</button>`).click(() => this.reset());
|
||||
const $wrpBtns = $$`<div>${$btnForceMobile}${$btnReset}</div>`;
|
||||
|
||||
const $wrpSummary = $(`<div class="ve-flex-v-center fltr__summary_item fltr__summary_item--include"></div>`).hideVe();
|
||||
|
||||
const $btnShowHide = $(`<button class="btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}">Hide</button>`)
|
||||
.click(() => this._meta.isHidden = !this._meta.isHidden);
|
||||
const hkIsHidden = () => {
|
||||
$btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
$wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
$wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
// render summary
|
||||
const {summaryTitle, summary} = this._getDisplaySummary();
|
||||
$wrpSummary
|
||||
.title(summaryTitle)
|
||||
.text(summary);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hkIsHidden);
|
||||
hkIsHidden();
|
||||
|
||||
return $$`
|
||||
<div class="ve-flex-v-center">
|
||||
${$wrpBtns}
|
||||
${$wrpSummary}
|
||||
${$btnShowHide}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_getDisplaySummary ({nxtState = null} = {}) {
|
||||
const cur = this.getValues({nxtState})[this.header];
|
||||
|
||||
const isRange = !cur.isMinVal && !cur.isMaxVal;
|
||||
const isCapped = !cur.isMinVal || !cur.isMaxVal;
|
||||
|
||||
return {
|
||||
summaryTitle: isRange ? `Hidden range` : isCapped ? `Hidden limit` : "",
|
||||
summary: isRange ? `${this._getDisplayText(cur.min)}-${this._getDisplayText(cur.max)}` : !cur.isMinVal ? `≥ ${this._getDisplayText(cur.min)}` : !cur.isMaxVal ? `≤ ${this._getDisplayText(cur.max)}` : "",
|
||||
};
|
||||
}
|
||||
|
||||
_getDisplayText (value, {isBeyondMax = false, isTooltip = false} = {}) {
|
||||
value = `${this._labels ? this._labelDisplayFn ? this._labelDisplayFn(this._labels[value]) : this._labels[value] : (isTooltip && this._displayFnTooltip) ? this._displayFnTooltip(value) : this._displayFn ? this._displayFn(value) : value}${isBeyondMax ? "+" : ""}`;
|
||||
if (this._suffix) value += this._suffix;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $wrpControls = opts.isMulti ? null : this._$getHeaderControls();
|
||||
|
||||
const $wrpSlider = $$`<div class="fltr__wrp-pills fltr__wrp-pills--flex"></div>`;
|
||||
const $wrpDropdowns = $$`<div class="fltr__wrp-pills fltr__wrp-pills--flex"></div>`;
|
||||
const hookHidden = () => {
|
||||
$wrpSlider.toggleVe(!this._meta.isHidden && !this._meta.isUseDropdowns);
|
||||
$wrpDropdowns.toggleVe(!this._meta.isHidden && !!this._meta.isUseDropdowns);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookHidden);
|
||||
this._addHook("meta", "isUseDropdowns", hookHidden);
|
||||
hookHidden();
|
||||
|
||||
// region Slider
|
||||
// ensure sparse values are correctly constrained
|
||||
if (this._sparseValues?.length) {
|
||||
const sparseMin = this._sparseValues[0];
|
||||
if (this._state.min < sparseMin) {
|
||||
this._state.curMin = Math.max(this._state.curMin, sparseMin);
|
||||
this._state.min = sparseMin;
|
||||
}
|
||||
|
||||
const sparseMax = this._sparseValues.last();
|
||||
if (this._state.max > sparseMax) {
|
||||
this._state.curMax = Math.min(this._state.curMax, sparseMax);
|
||||
this._state.max = sparseMax;
|
||||
}
|
||||
}
|
||||
|
||||
// prepare slider options
|
||||
const getSliderOpts = () => {
|
||||
const fnDisplay = (val, {isTooltip = false} = {}) => {
|
||||
return this._getDisplayText(val, {isBeyondMax: this._isAllowGreater && val === this._state.max, isTooltip});
|
||||
};
|
||||
|
||||
return {
|
||||
propMin: "min",
|
||||
propMax: "max",
|
||||
propCurMin: "curMin",
|
||||
propCurMax: "curMax",
|
||||
fnDisplay: (val) => fnDisplay(val),
|
||||
fnDisplayTooltip: (val) => fnDisplay(val, {isTooltip: true}),
|
||||
sparseValues: this._sparseValues,
|
||||
};
|
||||
};
|
||||
|
||||
const hkUpdateLabelSearchCache = () => {
|
||||
if (this._labels) return this._doUpdateLabelSearchCache();
|
||||
this._labelSearchCache = null;
|
||||
};
|
||||
this._addHook("state", "curMin", hkUpdateLabelSearchCache);
|
||||
this._addHook("state", "curMax", hkUpdateLabelSearchCache);
|
||||
hkUpdateLabelSearchCache();
|
||||
|
||||
this._slider = new ComponentUiUtil.RangeSlider({comp: this, ...getSliderOpts()});
|
||||
$wrpSlider.append(this._slider.get());
|
||||
// endregion
|
||||
|
||||
// region Dropdowns
|
||||
const selMin = e_({
|
||||
tag: "select",
|
||||
clazz: `form-control mr-2`,
|
||||
change: () => {
|
||||
const nxtMin = Number(selMin.val());
|
||||
const [min, max] = [nxtMin, this._state.curMax].sort(SortUtil.ascSort);
|
||||
this._state.curMin = min;
|
||||
this._state.curMax = max;
|
||||
},
|
||||
});
|
||||
const selMax = e_({
|
||||
tag: "select",
|
||||
clazz: `form-control`,
|
||||
change: () => {
|
||||
const nxMax = Number(selMax.val());
|
||||
const [min, max] = [this._state.curMin, nxMax].sort(SortUtil.ascSort);
|
||||
this._state.curMin = min;
|
||||
this._state.curMax = max;
|
||||
},
|
||||
});
|
||||
$$`<div class="ve-flex-v-center w-100 px-3 py-1">${selMin}${selMax}</div>`.appendTo($wrpDropdowns);
|
||||
// endregion
|
||||
|
||||
const handleCurUpdate = () => {
|
||||
// Dropdowns
|
||||
selMin.val(`${this._state.curMin}`);
|
||||
selMax.val(`${this._state.curMax}`);
|
||||
};
|
||||
|
||||
const handleLimitUpdate = () => {
|
||||
// Dropdowns
|
||||
this._doPopulateDropdown(selMin, this._state.curMin);
|
||||
this._doPopulateDropdown(selMax, this._state.curMax);
|
||||
};
|
||||
|
||||
this._addHook("state", "min", handleLimitUpdate);
|
||||
this._addHook("state", "max", handleLimitUpdate);
|
||||
this._addHook("state", "curMin", handleCurUpdate);
|
||||
this._addHook("state", "curMax", handleCurUpdate);
|
||||
handleCurUpdate();
|
||||
handleLimitUpdate();
|
||||
|
||||
if (opts.isMulti) {
|
||||
this._slider.get().classList.add("ve-grow");
|
||||
$wrpSlider.addClass("ve-grow");
|
||||
$wrpDropdowns.addClass("ve-grow");
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex">
|
||||
<div class="fltr__range-inline-label mr-2">${this._getRenderedHeader()}</div>
|
||||
${$wrpSlider}
|
||||
${$wrpDropdowns}
|
||||
</div>`;
|
||||
} else {
|
||||
const btnMobToggleControls = this._getBtnMobToggleControls($wrpControls);
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="fltr__h-text ve-flex-h-center">${this._getRenderedHeader()}${btnMobToggleControls}</div>
|
||||
${$wrpControls}
|
||||
</div>
|
||||
${$wrpSlider}
|
||||
${$wrpDropdowns}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
if (!opts.$wrpMini) return;
|
||||
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
// region Mini pills
|
||||
this._$btnMiniGt = this._$btnMiniGt || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMin = this._state.min;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniGt.appendTo(this.__$wrpMini);
|
||||
|
||||
this._$btnMiniLt = this._$btnMiniLt || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMax = this._state.max;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniLt.appendTo(this.__$wrpMini);
|
||||
|
||||
this._$btnMiniEq = this._$btnMiniEq || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMin = this._state.min;
|
||||
this._state.curMax = this._state.max;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniEq.appendTo(this.__$wrpMini);
|
||||
|
||||
const hideHook = () => {
|
||||
const isHidden = this._filterBox.isMinisHidden(this.header);
|
||||
this._$btnMiniGt.toggleClass("ve-hidden", isHidden);
|
||||
this._$btnMiniLt.toggleClass("ve-hidden", isHidden);
|
||||
this._$btnMiniEq.toggleClass("ve-hidden", isHidden);
|
||||
};
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
hideHook();
|
||||
|
||||
const handleMiniUpdate = () => {
|
||||
if (this._state.curMin === this._state.curMax) {
|
||||
this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
this._$btnMiniEq
|
||||
.attr("state", this._isAtDefaultPosition() ? FilterBox._PILL_STATES[0] : FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} = ${this._getDisplayText(this._state.curMin, {isBeyondMax: this._isAllowGreater && this._state.curMin === this._state.max})}`);
|
||||
} else {
|
||||
if (this._state.min !== this._state.curMin) {
|
||||
this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} ≥ ${this._getDisplayText(this._state.curMin)}`);
|
||||
} else this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
if (this._state.max !== this._state.curMax) {
|
||||
this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} ≤ ${this._getDisplayText(this._state.curMax)}`);
|
||||
} else this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
this._$btnMiniEq.attr("state", FilterBox._PILL_STATES[0]);
|
||||
}
|
||||
};
|
||||
// endregion
|
||||
|
||||
const handleCurUpdate = () => {
|
||||
handleMiniUpdate();
|
||||
};
|
||||
|
||||
const handleLimitUpdate = () => {
|
||||
handleMiniUpdate();
|
||||
};
|
||||
|
||||
this._addHook("state", "min", handleLimitUpdate);
|
||||
this._addHook("state", "max", handleLimitUpdate);
|
||||
this._addHook("state", "curMin", handleCurUpdate);
|
||||
this._addHook("state", "curMax", handleCurUpdate);
|
||||
handleCurUpdate();
|
||||
handleLimitUpdate();
|
||||
}
|
||||
|
||||
_doPopulateDropdown (sel, curVal) {
|
||||
let tmp = "";
|
||||
for (let i = 0, len = this._state.max - this._state.min + 1; i < len; ++i) {
|
||||
const val = i + this._state.min;
|
||||
const label = `${this._getDisplayText(val)}`.qq();
|
||||
tmp += `<option value="${val}" ${curVal === val ? "selected" : ""}>${label}</option>`;
|
||||
}
|
||||
sel.innerHTML = tmp;
|
||||
return sel;
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const out = {
|
||||
isMaxVal: state.max === state.curMax,
|
||||
isMinVal: state.min === state.curMin,
|
||||
max: state.curMax,
|
||||
min: state.curMin,
|
||||
};
|
||||
out._isActive = !(out.isMinVal && out.isMaxVal);
|
||||
return {[this.header]: out};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
nxtState[this.header].state.curMin = nxtState[this.header].state.min;
|
||||
nxtState[this.header].state.curMax = nxtState[this.header].state.max;
|
||||
}
|
||||
|
||||
update () {
|
||||
if (!this.__$wrpMini) return;
|
||||
|
||||
// (labels will be automatically updated by the slider handlers)
|
||||
// always render the mini-pills, to ensure the overall order in the grid stays correct (shared between multiple filters)
|
||||
if (this._$btnMiniGt) this.__$wrpMini.append(this._$btnMiniGt);
|
||||
if (this._$btnMiniLt) this.__$wrpMini.append(this._$btnMiniLt);
|
||||
if (this._$btnMiniEq) this.__$wrpMini.append(this._$btnMiniEq);
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true; // discount any filters which were not rendered
|
||||
|
||||
// match everything if filter is set to complete range
|
||||
if (entryVal == null) return filterState.min === this._state.min && filterState.max === this._state.max;
|
||||
|
||||
if (this._labels) {
|
||||
const slice = this._labels.slice(filterState.min, filterState.max + 1);
|
||||
|
||||
// Special case for "isAllowGreater" filters, which assumes the labels are numerical values
|
||||
if (this._isAllowGreater) {
|
||||
if (filterState.max === this._state.max && entryVal > this._labels[filterState.max]) return true;
|
||||
|
||||
const sliceMin = Math.min(...slice);
|
||||
const sliceMax = Math.max(...slice);
|
||||
|
||||
if (entryVal instanceof Array) return entryVal.some(it => it >= sliceMin && it <= sliceMax);
|
||||
return entryVal >= sliceMin && entryVal <= sliceMax;
|
||||
}
|
||||
|
||||
if (entryVal instanceof Array) return entryVal.some(it => slice.includes(it));
|
||||
return slice.includes(entryVal);
|
||||
} else {
|
||||
if (entryVal instanceof Array) {
|
||||
// If we require a full match on the range, take the lowest/highest input and test them against our min/max
|
||||
if (this._isRequireFullRangeMatch) return filterState.min <= entryVal[0] && filterState.max >= entryVal.last();
|
||||
|
||||
// Otherwise, If any of the item's values are in the range, return true
|
||||
return entryVal.some(ev => this._toDisplay_isToDisplayEntry(filterState, ev));
|
||||
}
|
||||
return this._toDisplay_isToDisplayEntry(filterState, entryVal);
|
||||
}
|
||||
}
|
||||
|
||||
_toDisplay_isToDisplayEntry (filterState, ev) {
|
||||
const isGtMin = filterState.min <= ev;
|
||||
const isLtMax = filterState.max >= ev;
|
||||
if (this._isAllowGreater) return isGtMin && (isLtMax || filterState.max === this._state.max);
|
||||
return isGtMin && isLtMax;
|
||||
}
|
||||
|
||||
addItem (item) {
|
||||
if (item == null) return;
|
||||
|
||||
if (item instanceof Array) {
|
||||
const len = item.length;
|
||||
for (let i = 0; i < len; ++i) this.addItem(item[i]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._labels) {
|
||||
if (!this._labels.some(it => it === item)) this._labels.push(item);
|
||||
|
||||
this._doUpdateLabelSearchCache();
|
||||
|
||||
// Fake an update to trigger label handling
|
||||
this._addItem_addNumber(this._labels.length - 1);
|
||||
} else {
|
||||
this._addItem_addNumber(item);
|
||||
}
|
||||
}
|
||||
|
||||
_doUpdateLabelSearchCache () {
|
||||
this._labelSearchCache = [...new Array(Math.max(0, this._max - this._min))]
|
||||
.map((_, i) => i + this._min)
|
||||
.map(val => this._getDisplayText(val, {isBeyondMax: this._isAllowGreater && val === this._state.max, isTooltip: true}))
|
||||
.join(" -- ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
_addItem_addNumber (number) {
|
||||
if (number == null || isNaN(number)) return;
|
||||
|
||||
this._seenMin = Math.min(this._seenMin, number);
|
||||
this._seenMax = Math.max(this._seenMax, number);
|
||||
|
||||
if (this._sparseValues && !this._sparseValues.includes(number)) {
|
||||
this._sparseValues.push(number);
|
||||
this._sparseValues.sort(SortUtil.ascSort);
|
||||
}
|
||||
|
||||
if (number >= this._state.min && number <= this._state.max) return; // it's already in the range
|
||||
if (this._state.min == null && this._state.max == null) this._state.min = this._state.max = number;
|
||||
else {
|
||||
const old = {...this.__state};
|
||||
|
||||
if (number < old.min) this._state.min = number;
|
||||
if (number > old.max) this._state.max = number;
|
||||
|
||||
// if the slider was previously at the full extent of its range, maintain this
|
||||
if (old.curMin === old.min) this._state.curMin = this._state.min;
|
||||
if (old.curMax === old.max) this._state.curMax = this._state.max;
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
const out = {
|
||||
...super.getDefaultMeta(),
|
||||
...RangeFilter._DEFAULT_META,
|
||||
};
|
||||
if (Renderer.hover.isSmallScreen()) out.isUseDropdowns = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
if (this.__$wrpFilter == null) return;
|
||||
|
||||
const isVisible = this.header.toLowerCase().includes(searchTerm)
|
||||
|| (this._labelSearchCache != null
|
||||
? this._labelSearchCache.includes(searchTerm)
|
||||
: [...new Array(this._state.max - this._state.min)].map((_, n) => n + this._state.min).join(" -- ").includes(searchTerm));
|
||||
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", !isVisible);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
}
|
||||
RangeFilter._DEFAULT_META = {
|
||||
isUseDropdowns: false,
|
||||
};
|
||||
295
js/filter/filter/filter-filter-searchable.js
Normal file
295
js/filter/filter/filter-filter-searchable.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
|
||||
export class SearchableFilter extends Filter {
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
this._compSearch = BaseComponent.fromObject({
|
||||
search: "",
|
||||
searchTermParent: "",
|
||||
});
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const out = super.handleSearch(searchTerm);
|
||||
|
||||
this._compSearch._state.searchTermParent = searchTerm;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const btnPill = super._getPill(item);
|
||||
|
||||
const hkIsVisible = () => {
|
||||
if (this._compSearch._state.searchTermParent) return btnPill.toggleClass("fltr__hidden--inactive", false);
|
||||
|
||||
btnPill.toggleClass("fltr__hidden--inactive", this._state[item.item] === 0);
|
||||
};
|
||||
this._addHook("state", item.item, hkIsVisible);
|
||||
this._compSearch._addHookBase("searchTermParent", hkIsVisible);
|
||||
hkIsVisible();
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
_getPill_handleClick ({evt, item}) {
|
||||
if (this._compSearch._state.searchTermParent) return super._getPill_handleClick({evt, item});
|
||||
|
||||
this._state[item.item] = 0;
|
||||
}
|
||||
|
||||
_getPill_handleContextmenu ({evt, item}) {
|
||||
if (this._compSearch._state.searchTermParent) return super._getPill_handleContextmenu({evt, item});
|
||||
|
||||
evt.preventDefault();
|
||||
this._state[item.item] = 0;
|
||||
}
|
||||
|
||||
_$render_getRowBtn ({fnsCleanup, $iptSearch, item, subtype, state}) {
|
||||
const handleClick = evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
|
||||
// Keep the dropdown open
|
||||
$iptSearch.focus();
|
||||
|
||||
if (evt.shiftKey) {
|
||||
this._doSetPillsClear();
|
||||
}
|
||||
|
||||
if (this._state[item.item] === state) this._state[item.item] = 0;
|
||||
else this._state[item.item] = state;
|
||||
};
|
||||
|
||||
const btn = e_({
|
||||
tag: "div",
|
||||
clazz: `no-shrink clickable fltr-search__btn-activate fltr-search__btn-activate--${subtype} ve-flex-vh-center`,
|
||||
click: evt => handleClick(evt),
|
||||
contextmenu: evt => handleClick(evt),
|
||||
mousedown: evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
const hkIsActive = () => {
|
||||
btn.innerText = this._state[item.item] === state ? "×" : "";
|
||||
};
|
||||
this._addHookBase(item.item, hkIsActive);
|
||||
hkIsActive();
|
||||
fnsCleanup.push(() => this._removeHookBase(item.item, hkIsActive));
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
$render (opts) {
|
||||
const $out = super.$render(opts);
|
||||
|
||||
const $iptSearch = ComponentUiUtil.$getIptStr(
|
||||
this._compSearch,
|
||||
"search",
|
||||
{
|
||||
html: `<input class="form-control form-control--minimal input-xs" placeholder="Search...">`,
|
||||
},
|
||||
);
|
||||
|
||||
const wrpValues = e_({
|
||||
tag: "div",
|
||||
clazz: "ve-overflow-y-auto bt-0 absolute fltr-search__wrp-values",
|
||||
});
|
||||
|
||||
const fnsCleanup = [];
|
||||
const rowMetas = [];
|
||||
|
||||
this._$render_bindSearchHandler_keydown({$iptSearch, fnsCleanup, rowMetas});
|
||||
this._$render_bindSearchHandler_focus({$iptSearch, fnsCleanup, rowMetas, wrpValues});
|
||||
this._$render_bindSearchHandler_blur({$iptSearch});
|
||||
|
||||
const $wrp = $$`<div class="fltr-search__wrp-search ve-flex-col relative mt-1 mx-2p mb-1">
|
||||
${$iptSearch}
|
||||
${wrpValues}
|
||||
</div>`.prependTo(this.__wrpPills);
|
||||
|
||||
const hkParentSearch = () => {
|
||||
$wrp.toggleVe(!this._compSearch._state.searchTermParent);
|
||||
};
|
||||
this._compSearch._addHookBase("searchTermParent", hkParentSearch);
|
||||
hkParentSearch();
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_keydown ({$iptSearch, rowMetas}) {
|
||||
$iptSearch
|
||||
.on("keydown", evt => {
|
||||
switch (evt.key) {
|
||||
case "Escape": evt.stopPropagation(); return $iptSearch.blur();
|
||||
|
||||
case "ArrowDown": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
visibleRowMetas[0].row.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
if (evt.shiftKey) this._doSetPillsClear();
|
||||
this._state[visibleRowMetas[0].item.item] = (EventUtil.isCtrlMetaKey(evt)) ? 2 : 1;
|
||||
$iptSearch.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus ({$iptSearch, fnsCleanup, rowMetas, wrpValues}) {
|
||||
$iptSearch
|
||||
.on("focus", () => {
|
||||
fnsCleanup
|
||||
.splice(0, fnsCleanup.length)
|
||||
.forEach(fn => fn());
|
||||
|
||||
rowMetas.splice(0, rowMetas.length);
|
||||
|
||||
wrpValues.innerHTML = "";
|
||||
|
||||
rowMetas.push(
|
||||
...this._items
|
||||
.map(item => this._$render_bindSearchHandler_focus_getRowMeta({$iptSearch, fnsCleanup, rowMetas, wrpValues, item})),
|
||||
);
|
||||
|
||||
this._$render_bindSearchHandler_focus_addHookSearch({rowMetas, fnsCleanup});
|
||||
|
||||
wrpValues.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus_getRowMeta ({$iptSearch, fnsCleanup, rowMetas, wrpValues, item}) {
|
||||
const dispName = this._getDisplayText(item);
|
||||
|
||||
const eleName = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr-search__disp-name ml-2",
|
||||
});
|
||||
|
||||
const btnBlue = this._$render_getRowBtn({
|
||||
fnsCleanup,
|
||||
$iptSearch,
|
||||
item,
|
||||
subtype: "yes",
|
||||
state: 1,
|
||||
});
|
||||
btnBlue.addClass("br-0");
|
||||
btnBlue.addClass("btr-0");
|
||||
btnBlue.addClass("bbr-0");
|
||||
|
||||
const btnRed = this._$render_getRowBtn({
|
||||
fnsCleanup,
|
||||
$iptSearch,
|
||||
item,
|
||||
subtype: "no",
|
||||
state: 2,
|
||||
});
|
||||
btnRed.addClass("bl-0");
|
||||
btnRed.addClass("btl-0");
|
||||
btnRed.addClass("bbl-0");
|
||||
|
||||
const row = e_({
|
||||
tag: "div",
|
||||
clazz: "py-1p px-2 ve-flex-v-center fltr-search__wrp-row",
|
||||
children: [
|
||||
btnBlue,
|
||||
btnRed,
|
||||
eleName,
|
||||
],
|
||||
attrs: {
|
||||
tabindex: "0",
|
||||
},
|
||||
keydown: evt => {
|
||||
switch (evt.key) {
|
||||
case "Escape": evt.stopPropagation(); return row.blur();
|
||||
|
||||
case "ArrowDown": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
const ixCur = visibleRowMetas.indexOf(out);
|
||||
const nxt = visibleRowMetas[ixCur + 1];
|
||||
if (nxt) nxt.row.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
const ixCur = visibleRowMetas.indexOf(out);
|
||||
const prev = visibleRowMetas[ixCur - 1];
|
||||
if (prev) return prev.row.focus();
|
||||
$iptSearch.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
if (evt.shiftKey) this._doSetPillsClear();
|
||||
this._state[item.item] = (EventUtil.isCtrlMetaKey(evt)) ? 2 : 1;
|
||||
row.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
wrpValues.appendChild(row);
|
||||
|
||||
const out = {
|
||||
isVisible: true,
|
||||
item,
|
||||
row,
|
||||
dispName,
|
||||
eleName,
|
||||
};
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus_addHookSearch ({rowMetas, fnsCleanup}) {
|
||||
const hkSearch = () => {
|
||||
const searchTerm = this._compSearch._state.search.toLowerCase();
|
||||
|
||||
rowMetas.forEach(({item, row}) => {
|
||||
row.isVisible = item.searchText.includes(searchTerm);
|
||||
row.toggleVe(row.isVisible);
|
||||
});
|
||||
|
||||
// region Underline matching part
|
||||
if (!this._compSearch._state.search) {
|
||||
rowMetas.forEach(({dispName, eleName}) => eleName.textContent = dispName);
|
||||
return;
|
||||
}
|
||||
|
||||
const re = new RegExp(this._compSearch._state.search.qq().escapeRegexp(), "gi");
|
||||
|
||||
rowMetas.forEach(({dispName, eleName}) => {
|
||||
eleName.innerHTML = dispName
|
||||
.qq()
|
||||
.replace(re, (...m) => `<u>${m[0]}</u>`);
|
||||
});
|
||||
// endregion
|
||||
};
|
||||
this._compSearch._addHookBase("search", hkSearch);
|
||||
hkSearch();
|
||||
fnsCleanup.push(() => this._compSearch._removeHookBase("search", hkSearch));
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_blur ({$iptSearch}) {
|
||||
$iptSearch
|
||||
.on("blur", () => {
|
||||
this._compSearch._state.search = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
594
js/filter/filter/filter-filter-source.js
Normal file
594
js/filter/filter/filter-filter-source.js
Normal file
@@ -0,0 +1,594 @@
|
||||
import {FilterItem} from "../filter-item.js";
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
import {SOURCE_HEADER} from "../filter-constants.js";
|
||||
import {PageFilterBase} from "../filter-page-filter-base.js";
|
||||
|
||||
export class SourceFilterItem extends FilterItem {
|
||||
/**
|
||||
* @param options
|
||||
* @param [options.isOtherSource] If this is not the primary source of the entity.
|
||||
*/
|
||||
constructor (options) {
|
||||
super(options);
|
||||
this.isOtherSource = options.isOtherSource;
|
||||
}
|
||||
}
|
||||
|
||||
export class SourceFilter extends Filter {
|
||||
static _SORT_ITEMS_MINI (a, b) {
|
||||
a = a.item ?? a;
|
||||
b = b.item ?? b;
|
||||
const valA = BrewUtil2.hasSourceJson(a) ? 2 : (SourceUtil.isNonstandardSource(a) || PrereleaseUtil.hasSourceJson(a)) ? 1 : 0;
|
||||
const valB = BrewUtil2.hasSourceJson(b) ? 2 : (SourceUtil.isNonstandardSource(b) || PrereleaseUtil.hasSourceJson(b)) ? 1 : 0;
|
||||
return SortUtil.ascSort(valA, valB) || SortUtil.ascSortLower(Parser.sourceJsonToFull(a), Parser.sourceJsonToFull(b));
|
||||
}
|
||||
|
||||
static _getDisplayHtmlMini (item) {
|
||||
item = item.item || item;
|
||||
return `${this._getDisplayHtmlMini_icon(item)} ${Parser.sourceJsonToAbv(item)}`;
|
||||
}
|
||||
|
||||
static _getDisplayHtmlMini_icon (item) {
|
||||
const group = SourceUtil.getFilterGroup(item);
|
||||
switch (group) {
|
||||
case SourceUtil.FILTER_GROUP_STANDARD: return `<span class="glyphicon glyphicon-book"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_NON_STANDARD: return `<span title="(Other)" class="glyphicon glyphicon-file"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_PARTNERED: return `<span title="(Partnered)" class="glyphicon glyphicon-star-empty"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_PRERELEASE: return `<span title="(Prerelease)" class="glyphicon glyphicon-wrench"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_HOMEBREW: return `<span title="(Homebrew)" class="glyphicon glyphicon-glass"></span>`;
|
||||
default: throw new Error(`Unhandled source filter group "${group}"`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
|
||||
opts.header = opts.header === undefined ? SOURCE_HEADER : opts.header;
|
||||
opts.displayFn = opts.displayFn === undefined ? item => Parser.sourceJsonToFullCompactPrefix(item.item || item) : opts.displayFn;
|
||||
opts.displayFnMini = opts.displayFnMini === undefined ? SourceFilter._getDisplayHtmlMini.bind(SourceFilter) : opts.displayFnMini;
|
||||
opts.displayFnTitle = opts.displayFnTitle === undefined ? item => Parser.sourceJsonToFull(item.item || item) : opts.displayFnTitle;
|
||||
opts.itemSortFnMini = opts.itemSortFnMini === undefined ? SourceFilter._SORT_ITEMS_MINI.bind(SourceFilter) : opts.itemSortFnMini;
|
||||
opts.itemSortFn = opts.itemSortFn === undefined ? (a, b) => SortUtil.ascSortLower(Parser.sourceJsonToFull(a.item), Parser.sourceJsonToFull(b.item)) : opts.itemSortFn;
|
||||
opts.groupFn = opts.groupFn === undefined ? SourceUtil.getFilterGroup : opts.groupFn;
|
||||
opts.groupNameFn = opts.groupNameFn === undefined ? SourceUtil.getFilterGroupName : opts.groupNameFn;
|
||||
opts.selFn = opts.selFn === undefined ? PageFilterBase.defaultSourceSelFn : opts.selFn;
|
||||
|
||||
super(opts);
|
||||
|
||||
this.__tmpState = {ixAdded: 0};
|
||||
this._tmpState = this._getProxy("tmpState", this.__tmpState);
|
||||
}
|
||||
|
||||
doSetPillsClear () { return this._doSetPillsClear(); }
|
||||
|
||||
_getFilterItem (item) {
|
||||
return item instanceof FilterItem ? item : new SourceFilterItem({item});
|
||||
}
|
||||
|
||||
addItem (item) {
|
||||
const out = super.addItem(item);
|
||||
this._tmpState.ixAdded++;
|
||||
return out;
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
if (!this._items?.length) return;
|
||||
|
||||
const sourcesLoaded = new Set(this._items.map(itm => itm.item));
|
||||
const nxtState = MiscUtil.copyFast(this.__state);
|
||||
Object.keys(nxtState)
|
||||
.filter(k => !sourcesLoaded.has(k))
|
||||
.forEach(k => delete nxtState[k]);
|
||||
|
||||
this._proxyAssignSimple("state", nxtState, true);
|
||||
}
|
||||
|
||||
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {
|
||||
const btnSupplements = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection; CTRL to include UA/etc.`,
|
||||
html: `Core/Supplements`,
|
||||
click: evt => this._doSetPinsSupplements({isIncludeUnofficial: EventUtil.isCtrlMetaKey(evt), isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnAdventures = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection; CTRL to include UA`,
|
||||
html: `Adventures`,
|
||||
click: evt => this._doSetPinsAdventures({isIncludeUnofficial: EventUtil.isCtrlMetaKey(evt), isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnPartnered = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection`,
|
||||
html: `Partnered`,
|
||||
click: evt => this._doSetPinsPartnered({isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnHomebrew = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection`,
|
||||
html: `Homebrew`,
|
||||
click: evt => this._doSetPinsHomebrew({isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const hkIsButtonsActive = () => {
|
||||
const hasPartnered = Object.keys(this.__state).some(src => SourceUtil.getFilterGroup(src) === SourceUtil.FILTER_GROUP_PARTNERED);
|
||||
btnPartnered.toggleClass("ve-hidden", !hasPartnered);
|
||||
|
||||
const hasBrew = Object.keys(this.__state).some(src => SourceUtil.getFilterGroup(src) === SourceUtil.FILTER_GROUP_HOMEBREW);
|
||||
btnHomebrew.toggleClass("ve-hidden", !hasBrew);
|
||||
};
|
||||
this._addHook("tmpState", "ixAdded", hkIsButtonsActive);
|
||||
hkIsButtonsActive();
|
||||
|
||||
const actionSelectDisplayMode = new ContextUtil.ActionSelect({
|
||||
values: Object.keys(SourceFilter._PILL_DISPLAY_MODE_LABELS).map(Number),
|
||||
fnGetDisplayValue: val => SourceFilter._PILL_DISPLAY_MODE_LABELS[val] || SourceFilter._PILL_DISPLAY_MODE_LABELS[0],
|
||||
fnOnChange: val => this._meta.pillDisplayMode = val,
|
||||
});
|
||||
this._addHook("meta", "pillDisplayMode", () => {
|
||||
actionSelectDisplayMode.setValue(this._meta.pillDisplayMode);
|
||||
})();
|
||||
|
||||
const menu = ContextUtil.getMenu([
|
||||
new ContextUtil.Action(
|
||||
"Select All Standard Sources",
|
||||
() => this._doSetPinsStandard(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Partnered Sources",
|
||||
() => this._doSetPinsPartnered(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Non-Standard Sources",
|
||||
() => this._doSetPinsNonStandard(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Prerelease Sources",
|
||||
() => this._doSetPinsPrerelease(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Homebrew Sources",
|
||||
() => this._doSetPinsHomebrew(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
`Select "Vanilla" Sources`,
|
||||
() => this._doSetPinsVanilla(),
|
||||
{title: `Select a baseline set of sources suitable for any campaign.`},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Non-UA Sources",
|
||||
() => this._doSetPinsNonUa(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Select SRD Sources",
|
||||
() => this._doSetPinsSrd(),
|
||||
{title: `Select System Reference Document Sources.`},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select Basic Rules Sources",
|
||||
() => this._doSetPinsBasicRules(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Invert Selection",
|
||||
() => this._doInvertPins(),
|
||||
),
|
||||
null,
|
||||
actionSelectDisplayMode,
|
||||
]);
|
||||
const btnBurger = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
html: `<span class="glyphicon glyphicon-option-vertical"></span>`,
|
||||
click: evt => ContextUtil.pOpenMenu(evt, menu),
|
||||
title: "Other Options",
|
||||
});
|
||||
|
||||
const btnOnlyPrimary = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
html: `Include References`,
|
||||
title: `Consider entities as belonging to every source they appear in (i.e. reprints) as well as their primary source`,
|
||||
click: () => this._meta.isIncludeOtherSources = !this._meta.isIncludeOtherSources,
|
||||
});
|
||||
const hkIsIncludeOtherSources = () => {
|
||||
btnOnlyPrimary.toggleClass("active", !!this._meta.isIncludeOtherSources);
|
||||
};
|
||||
hkIsIncludeOtherSources();
|
||||
this._addHook("meta", "isIncludeOtherSources", hkIsIncludeOtherSources);
|
||||
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `btn-group mr-2 w-100 ve-flex-v-center mobile__m-1 mobile__mb-2`,
|
||||
children: [
|
||||
btnSupplements,
|
||||
btnAdventures,
|
||||
btnPartnered,
|
||||
btnHomebrew,
|
||||
btnBurger,
|
||||
btnOnlyPrimary,
|
||||
],
|
||||
}).prependTo(wrpStateBtnsOuter);
|
||||
}
|
||||
|
||||
_doSetPinsStandard () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_STANDARD ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsPartnered ({isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_PARTNERED ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsNonStandard () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_NON_STANDARD ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsPrerelease () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_PRERELEASE ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsSupplements ({isIncludeUnofficial = false, isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.isCoreOrSupplement(k) && (isIncludeUnofficial || !SourceUtil.isNonstandardSource(k)) ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsAdventures ({isIncludeUnofficial = false, isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.isAdventure(k) && (isIncludeUnofficial || !SourceUtil.isNonstandardSource(k)) ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsHomebrew ({isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_HOMEBREW ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsVanilla () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = Parser.SOURCES_VANILLA.has(k) ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsNonUa () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = !SourceUtil.isPrereleaseSource(k) ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsSrd () {
|
||||
SourceFilter._SRD_SOURCES = SourceFilter._SRD_SOURCES || new Set([Parser.SRC_PHB, Parser.SRC_MM, Parser.SRC_DMG]);
|
||||
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceFilter._SRD_SOURCES.has(k) ? 1 : 0);
|
||||
|
||||
const srdFilter = this._filterBox.filters.find(it => it.isSrdFilter);
|
||||
if (srdFilter) srdFilter.setValue("SRD", 1);
|
||||
|
||||
const basicRulesFilter = this._filterBox.filters.find(it => it.isBasicRulesFilter);
|
||||
if (basicRulesFilter) basicRulesFilter.setValue("Basic Rules", 0);
|
||||
|
||||
// also disable "Reprinted" otherwise some Deities are missing
|
||||
const reprintedFilter = this._filterBox.filters.find(it => it.isReprintedFilter);
|
||||
if (reprintedFilter) reprintedFilter.setValue("Reprinted", 0);
|
||||
}
|
||||
|
||||
_doSetPinsBasicRules () {
|
||||
SourceFilter._BASIC_RULES_SOURCES = SourceFilter._BASIC_RULES_SOURCES || new Set([Parser.SRC_PHB, Parser.SRC_MM, Parser.SRC_DMG]);
|
||||
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceFilter._BASIC_RULES_SOURCES.has(k) ? 1 : 0);
|
||||
|
||||
const basicRulesFilter = this._filterBox.filters.find(it => it.isBasicRulesFilter);
|
||||
if (basicRulesFilter) basicRulesFilter.setValue("Basic Rules", 1);
|
||||
|
||||
const srdFilter = this._filterBox.filters.find(it => it.isSrdFilter);
|
||||
if (srdFilter) srdFilter.setValue("SRD", 0);
|
||||
|
||||
// also disable "Reprinted" otherwise some Deities are missing
|
||||
const reprintedFilter = this._filterBox.filters.find(it => it.isReprintedFilter);
|
||||
if (reprintedFilter) reprintedFilter.setValue("Reprinted", 0);
|
||||
}
|
||||
|
||||
static getCompleteFilterSources (ent) {
|
||||
if (!ent.otherSources) return ent.source;
|
||||
|
||||
const otherSourcesFilt = ent.otherSources
|
||||
// Avoid `otherSources` from e.g. homebrews which are not loaded, and so lack their metadata
|
||||
.filter(src => !ExcludeUtil.isExcluded("*", "*", src.source, {isNoCount: true}) && SourceUtil.isKnownSource(src.source));
|
||||
if (!otherSourcesFilt.length) return ent.source;
|
||||
|
||||
return [ent.source].concat(otherSourcesFilt.map(src => new SourceFilterItem({item: src.source, isIgnoreRed: true, isOtherSource: true})));
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders (group) {
|
||||
switch (group) {
|
||||
case SourceUtil.FILTER_GROUP_PRERELEASE: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupPrerelease(group);
|
||||
case SourceUtil.FILTER_GROUP_HOMEBREW: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupBrew(group);
|
||||
default: return super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
}
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders_groupPrerelease (group) {
|
||||
let dates = [];
|
||||
const comp = BaseComponent.fromObject({
|
||||
min: 0,
|
||||
max: 0,
|
||||
curMin: 0,
|
||||
curMax: 0,
|
||||
});
|
||||
|
||||
const wrpSlider = new ComponentUiUtil.RangeSlider({
|
||||
comp,
|
||||
propMin: "min",
|
||||
propMax: "max",
|
||||
propCurMin: "curMin",
|
||||
propCurMax: "curMax",
|
||||
fnDisplay: val => dates[val]?.str,
|
||||
}).get();
|
||||
|
||||
const wrpWrpSlider = e_({
|
||||
tag: "div",
|
||||
clazz: `"w-100 ve-flex pt-2 pb-5 mb-2 mt-1 fltr-src__wrp-slider`,
|
||||
children: [
|
||||
wrpSlider,
|
||||
],
|
||||
}).hideVe();
|
||||
|
||||
const btnCancel = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default px-1`,
|
||||
html: "Cancel",
|
||||
click: () => {
|
||||
grpBtnsInactive.showVe();
|
||||
wrpWrpSlider.hideVe();
|
||||
grpBtnsActive.hideVe();
|
||||
},
|
||||
});
|
||||
|
||||
const btnConfirm = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default px-1`,
|
||||
html: "Confirm",
|
||||
click: () => {
|
||||
grpBtnsInactive.showVe();
|
||||
wrpWrpSlider.hideVe();
|
||||
grpBtnsActive.hideVe();
|
||||
|
||||
const min = comp._state.curMin;
|
||||
const max = comp._state.curMax;
|
||||
|
||||
const allowedDateSet = new Set(dates.slice(min, max + 1).map(it => it.str));
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => SourceUtil.isNonstandardSource(k))
|
||||
.forEach(k => {
|
||||
const sourceDate = Parser.sourceJsonToDate(k);
|
||||
nxtState[k] = allowedDateSet.has(sourceDate) ? 1 : 0;
|
||||
});
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const btnShowSlider = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Select by Date",
|
||||
click: () => {
|
||||
grpBtnsInactive.hideVe();
|
||||
wrpWrpSlider.showVe();
|
||||
grpBtnsActive.showVe();
|
||||
|
||||
dates = Object.keys(this._state)
|
||||
.filter(it => SourceUtil.isPrereleaseSource(it))
|
||||
.map(it => Parser.sourceJsonToDate(it))
|
||||
.filter(Boolean)
|
||||
.unique()
|
||||
.map(it => ({str: it, date: new Date(it)}))
|
||||
.sort((a, b) => SortUtil.ascSortDate(a.date, b.date))
|
||||
.reverse();
|
||||
|
||||
comp._proxyAssignSimple(
|
||||
"state",
|
||||
{
|
||||
min: 0,
|
||||
max: dates.length - 1,
|
||||
curMin: 0,
|
||||
curMax: dates.length - 1,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Clear",
|
||||
click: () => {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => SourceUtil.isPrereleaseSource(k))
|
||||
.forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const grpBtnsActive = e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnCancel,
|
||||
btnConfirm,
|
||||
],
|
||||
}).hideVe();
|
||||
|
||||
const grpBtnsInactive = e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnClear,
|
||||
btnShowSlider,
|
||||
],
|
||||
});
|
||||
|
||||
const elesDividerHeaders = super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
if (!elesDividerHeaders.length) elesDividerHeaders.push(e_({clazz: "div"}));
|
||||
if (elesDividerHeaders.length > 1) throw new Error("Unimplemented!");
|
||||
|
||||
return [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `split-v-center w-100`,
|
||||
children: [
|
||||
...elesDividerHeaders,
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `mb-1 ve-flex-h-right`,
|
||||
children: [
|
||||
grpBtnsActive,
|
||||
grpBtnsInactive,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
wrpWrpSlider,
|
||||
];
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders_groupBrew (group) {
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Clear",
|
||||
click: () => {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => BrewUtil2.hasSourceJson(k))
|
||||
.forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const elesDividerHeaders = super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
if (!elesDividerHeaders.length) elesDividerHeaders.push(e_({clazz: "div"}));
|
||||
if (elesDividerHeaders.length > 1) throw new Error("Unimplemented!");
|
||||
|
||||
return [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `split-v-center w-100`,
|
||||
children: [
|
||||
...elesDividerHeaders,
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `mb-1 ve-flex-h-right`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnClear,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
_toDisplay_getMappedEntryVal (entryVal) {
|
||||
entryVal = super._toDisplay_getMappedEntryVal(entryVal);
|
||||
if (!this._meta.isIncludeOtherSources) entryVal = entryVal.filter(it => !it.isOtherSource);
|
||||
return entryVal;
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const displayText = this._getDisplayText(item);
|
||||
const displayTextMini = this._getDisplayTextMini(item);
|
||||
|
||||
const dispName = e_({
|
||||
tag: "span",
|
||||
html: displayText,
|
||||
});
|
||||
|
||||
const spc = e_({
|
||||
tag: "span",
|
||||
clazz: "px-2 fltr-src__spc-pill",
|
||||
text: "|",
|
||||
});
|
||||
|
||||
const dispAbbreviation = e_({
|
||||
tag: "span",
|
||||
html: displayTextMini,
|
||||
});
|
||||
|
||||
const btnPill = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr__pill",
|
||||
children: [
|
||||
dispAbbreviation,
|
||||
spc,
|
||||
dispName,
|
||||
],
|
||||
click: evt => this._getPill_handleClick({evt, item}),
|
||||
contextmenu: evt => this._getPill_handleContextmenu({evt, item}),
|
||||
});
|
||||
|
||||
this._getPill_bindHookState({btnPill, item});
|
||||
|
||||
this._addHook("meta", "pillDisplayMode", () => {
|
||||
dispAbbreviation.toggleVe(this._meta.pillDisplayMode !== 0);
|
||||
spc.toggleVe(this._meta.pillDisplayMode === 2);
|
||||
dispName.toggleVe(this._meta.pillDisplayMode !== 1);
|
||||
})();
|
||||
|
||||
item.searchText = `${Parser.sourceJsonToAbv(item.item || item).toLowerCase()} -- ${displayText.toLowerCase()}`;
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
getSources () {
|
||||
const out = {
|
||||
all: [],
|
||||
};
|
||||
this._items.forEach(it => {
|
||||
out.all.push(it.item);
|
||||
const group = this._groupFn(it);
|
||||
(out[group] ||= []).push(it.item);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
return {
|
||||
...super.getDefaultMeta(),
|
||||
...SourceFilter._DEFAULT_META,
|
||||
};
|
||||
}
|
||||
}
|
||||
SourceFilter._DEFAULT_META = {
|
||||
isIncludeOtherSources: false,
|
||||
pillDisplayMode: 0,
|
||||
};
|
||||
SourceFilter._PILL_DISPLAY_MODE_LABELS = {
|
||||
"0": "As Names",
|
||||
"1": "As Abbreviations",
|
||||
"2": "As Names Plus Abbreviations",
|
||||
};
|
||||
SourceFilter._SRD_SOURCES = null;
|
||||
SourceFilter._BASIC_RULES_SOURCES = null;
|
||||
Reference in New Issue
Block a user