mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
5310 lines
172 KiB
JavaScript
5310 lines
172 KiB
JavaScript
"use strict";
|
||
|
||
class FilterUtil {}
|
||
FilterUtil.SUB_HASH_PREFIX_LENGTH = 4;
|
||
|
||
class PageFilter {
|
||
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_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(",");
|
||
}
|
||
// endregion
|
||
}
|
||
|
||
globalThis.PageFilter = PageFilter;
|
||
|
||
class ModalFilter {
|
||
static _$getFilterColumnHeaders (btnMeta) {
|
||
return btnMeta.map((it, i) => $(`<button class="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 overflow-x-hidden overflow-y-auto h-100 min-h-0"></div>`); }
|
||
|
||
_$getColumnHeaderPreviewAll (opts) {
|
||
return $(`<button class="btn btn-default btn-xs ${opts.isBuildUi ? "col-1" : "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 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 col-0-5 ve-flex-vh-center" disabled></label>`);
|
||
else $$`<label class="btn btn-default btn-xs 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(FilterBox.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!`); }
|
||
}
|
||
|
||
globalThis.ModalFilter = ModalFilter;
|
||
|
||
class FilterBox extends ProxyBase {
|
||
static TITLE_BTN_RESET = "Reset filters. SHIFT to reset everything.";
|
||
|
||
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(FilterBox.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(FilterUtil.SUB_HASH_PREFIX_LENGTH)}${this._namespace ? `.${this._namespace}` : ""}`; }
|
||
|
||
async pGetStoredActiveSources () {
|
||
const stored = await StorageUtil.pGetForPage(this._getNamespacedStorageKey());
|
||
if (stored) {
|
||
const sourceFilterData = stored.filters[FilterBox.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(FilterBox.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 === FilterBox.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="${FilterBox.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, FilterUtil.SUB_HASH_PREFIX_LENGTH);
|
||
|
||
const urlHeader = hashKey.substring(prefixLen);
|
||
|
||
if (FilterUtil.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 (FilterUtil.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.EVNT_VALCHANGE = "valchange";
|
||
FilterBox.SOURCE_HEADER = "Source";
|
||
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,
|
||
};
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
class Filter extends FilterBase {
|
||
static _getAsFilterItems (items) {
|
||
return items ? items.map(it => it instanceof FilterItem ? it : new FilterItem({item: it})) : null;
|
||
}
|
||
|
||
static _validateItemNests (items, nests) {
|
||
if (!nests) return;
|
||
items = items.filter(it => it.nest);
|
||
const noNest = items.find(it => !nests[it.nest]);
|
||
if (noNest) throw new Error(`Filter does not have matching nest: "${noNest.item}" (call addNest first)`);
|
||
const invalid = items.find(it => !it.nest || !nests[it.nest]);
|
||
if (invalid) throw new Error(`Invalid nest: "${invalid.item}"`);
|
||
}
|
||
|
||
/** A single-item version of the above, for performance. */
|
||
static _validateItemNest (item, nests) {
|
||
if (!nests || !item.nest) return;
|
||
if (!nests[item.nest]) throw new Error(`Filter does not have matching nest: "${item.item}" (call addNest first)`);
|
||
if (!item.nest || !nests[item.nest]) throw new Error(`Invalid nest: "${item.item}"`);
|
||
}
|
||
|
||
/**
|
||
* @param opts Options object.
|
||
* @param opts.header Filter header (name)
|
||
* @param [opts.headerHelp] Filter header help text (tooltip)
|
||
* @param opts.items Array of filter items, either `FilterItem` or strings. e.g. `["DMG", "VGM"]`
|
||
* @param [opts.nests] Key-value object of `"Nest Name": {...nestMeta}`. Nests are used to group/nest filters.
|
||
* @param [opts.displayFn] Function which translates an item to a displayable form, e.g. `"MM` -> "Monster Manual"`
|
||
* @param [opts.displayFnMini] Function which translates an item to a shortened displayable form, e.g. `"UABravoCharlie` -> "UABC"`
|
||
* @param [opts.displayFnTitle] Function which translates an item to a form for displaying in a "title" tooltip
|
||
* @param [opts.selFn] Function which returns true if an item should be displayed by default; false otherwise.
|
||
* @param [opts.deselFn] Function which returns true if an item should be hidden by default; false otherwise.
|
||
* @param [opts.itemSortFn] Function which should be used to sort the `items` array if new entries are added.
|
||
* Defaults to ascending alphabetical sort.
|
||
* @param [opts.itemSortFnMini] Function which should be used to sort the `items` array when rendering mini-pills.
|
||
* @param [opts.groupFn] Function which takes an item and assigns it to a group.
|
||
* @param [opts.minimalUi] True if the filter should render with a reduced UI, false otherwise.
|
||
* @param [opts.umbrellaItems] Items which should, when set active, show everything in the filter. E.g. "All".
|
||
* @param [opts.umbrellaExcludes] Items which should ignore the state of any `umbrellaItems`
|
||
* @param [opts.isSortByDisplayItems] If items should be sorted by their display value, rather than their internal value.
|
||
* @param [opts.isMiscFilter] If this is the Misc. filter (containing "SRD" and "Basic Rules" tags).
|
||
*/
|
||
constructor (opts) {
|
||
super(opts);
|
||
this._items = Filter._getAsFilterItems(opts.items || []);
|
||
this.__itemsSet = new Set(this._items.map(it => it.item)); // Cache the items as a set for fast exists checking
|
||
this._nests = opts.nests;
|
||
this._displayFn = opts.displayFn;
|
||
this._displayFnMini = opts.displayFnMini;
|
||
this._displayFnTitle = opts.displayFnTitle;
|
||
this._selFn = opts.selFn;
|
||
this._selFnCache = null;
|
||
this._deselFn = opts.deselFn;
|
||
this._itemSortFn = opts.itemSortFn === undefined ? SortUtil.ascSort : opts.itemSortFn;
|
||
this._itemSortFnMini = opts.itemSortFnMini;
|
||
this._groupFn = opts.groupFn;
|
||
this._minimalUi = opts.minimalUi;
|
||
this._umbrellaItems = Filter._getAsFilterItems(opts.umbrellaItems);
|
||
this._umbrellaExcludes = Filter._getAsFilterItems(opts.umbrellaExcludes);
|
||
this._isSortByDisplayItems = !!opts.isSortByDisplayItems;
|
||
this._isReprintedFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "Reprinted");
|
||
this._isSrdFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "SRD");
|
||
this._isBasicRulesFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "Basic Rules");
|
||
|
||
Filter._validateItemNests(this._items, this._nests);
|
||
|
||
this._filterBox = null;
|
||
this._items.forEach(it => this._defaultItemState(it, {isForce: true}));
|
||
this.__$wrpFilter = null;
|
||
this.__wrpPills = null;
|
||
this.__wrpMiniPills = null;
|
||
this.__$wrpNestHeadInner = null;
|
||
this._updateNestSummary = null;
|
||
this.__nestsHidden = {};
|
||
this._nestsHidden = this._getProxy("nestsHidden", this.__nestsHidden);
|
||
this._isNestsDirty = false;
|
||
this._isItemsDirty = false;
|
||
this._pillGroupsMeta = {};
|
||
}
|
||
|
||
get isReprintedFilter () { return this._isReprintedFilter; }
|
||
get isSrdFilter () { return this._isSrdFilter; }
|
||
get isBasicRulesFilter () { return this._isBasicRulesFilter; }
|
||
|
||
getSaveableState () {
|
||
return {
|
||
[this.header]: {
|
||
...this.getBaseSaveableState(),
|
||
state: {...this.__state},
|
||
nestsHidden: {...this.__nestsHidden},
|
||
},
|
||
};
|
||
}
|
||
|
||
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);
|
||
Object.assign(this._nestsHidden, toLoad.nestsHidden);
|
||
}
|
||
|
||
_getStateNotDefault ({nxtState = null} = {}) {
|
||
const state = nxtState?.[this.header]?.state || this.__state;
|
||
|
||
return Object.entries(state)
|
||
.filter(([k, v]) => {
|
||
if (k.startsWith("_")) return false;
|
||
const defState = this._getDefaultState(k);
|
||
return defState !== v;
|
||
});
|
||
}
|
||
|
||
getSubHashes () {
|
||
const out = [];
|
||
|
||
const baseMeta = this.getMetaSubHashes();
|
||
if (baseMeta) out.push(...baseMeta);
|
||
|
||
const areNotDefaultState = this._getStateNotDefault();
|
||
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));
|
||
}
|
||
|
||
const areNotDefaultNestsHidden = Object.entries(this._nestsHidden).filter(([k, v]) => this._nests[k] && !(this._nests[k].isHidden === v));
|
||
if (areNotDefaultNestsHidden.length) {
|
||
// serialize nestsHidden as `key=value` pairs
|
||
const nestsHidden = areNotDefaultNestsHidden.map(([k]) => `${k.toUrlified()}=1`);
|
||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("nestsHidden", this.header), nestsHidden));
|
||
}
|
||
|
||
if (!out.length) return null;
|
||
|
||
// Always extend default state
|
||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("options", this.header), ["extend"]));
|
||
return out;
|
||
}
|
||
|
||
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(([k]) => !k.startsWith("_"))
|
||
.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
|
||
if (!areNotDefaultState.length) return null;
|
||
|
||
const ptState = Object.entries(state)
|
||
.filter(([k]) => !k.startsWith("_"))
|
||
.filter(([, v]) => v)
|
||
.map(([k, v]) => {
|
||
const item = this._items.find(item => `${item.item}` === k);
|
||
if (!item) return null; // Should never occur
|
||
return `${v === 2 ? "not " : ""}${this._displayFn ? this._displayFn(item.item, item) : item.item}`;
|
||
})
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
|
||
if (!ptState) return null;
|
||
|
||
return `${this.header}: ${ptState}`;
|
||
}
|
||
|
||
/**
|
||
* Get transient options used when setting state from URL.
|
||
* @private
|
||
*/
|
||
_getOptionsFromSubHashState (state) {
|
||
// `flopsource:thing1~thing2` => `{options: ["thing1", "thing2"]}`
|
||
const opts = {};
|
||
Object.entries(state).forEach(([k, vals]) => {
|
||
const prop = FilterBase.getProp(k);
|
||
switch (prop) {
|
||
case "options": {
|
||
vals.forEach(val => {
|
||
switch (val) {
|
||
case "extend": {
|
||
opts.isExtendDefaultState = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
return new FilterTransientOptions(opts);
|
||
}
|
||
|
||
setStateFromNextState (nxtState) {
|
||
super.setStateFromNextState(nxtState);
|
||
this._proxyAssignSimple("nestsHidden", nxtState[this.header].nestsHidden, true);
|
||
}
|
||
|
||
getNextStateFromSubhashState (state) {
|
||
const nxtState = this._getNextState_base();
|
||
|
||
if (state == null) {
|
||
this._mutNextState_reset(nxtState);
|
||
return nxtState;
|
||
}
|
||
|
||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||
const transientOptions = this._getOptionsFromSubHashState(state);
|
||
|
||
let hasState = false;
|
||
let hasNestsHidden = false;
|
||
|
||
Object.entries(state).forEach(([k, vals]) => {
|
||
const prop = FilterBase.getProp(k);
|
||
switch (prop) {
|
||
case "state": {
|
||
hasState = true;
|
||
if (transientOptions.isExtendDefaultState) {
|
||
Object.keys(nxtState[this.header].state).forEach(k => nxtState[this.header].state[k] = this._getDefaultState(k));
|
||
} else {
|
||
// This allows e.g. @filter tags to cleanly specify their sources
|
||
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);
|
||
});
|
||
break;
|
||
}
|
||
case "nestsHidden": {
|
||
hasNestsHidden = true;
|
||
Object.keys(nxtState[this.header].nestsHidden).forEach(k => {
|
||
const nestKey = Object.keys(this._nests).find(it => k.toLowerCase() === it.toLowerCase());
|
||
nxtState[this.header].nestsHidden[k] = this._nests[nestKey] && this._nests[nestKey].isHidden;
|
||
});
|
||
vals.forEach(v => {
|
||
const [nestNameLower, state] = v.split("=");
|
||
const nestName = Object.keys(nxtState[this.header].nestsHidden).find(k => k.toLowerCase() === nestNameLower);
|
||
if (nestName) nxtState[this.header].nestsHidden[nestName] = !!Number(state);
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!hasState) this._mutNextState_reset(nxtState);
|
||
if (!hasNestsHidden && this._nests) this._mutNextState_resetNestsHidden({tgt: nxtState[this.header].nestsHidden});
|
||
|
||
return nxtState;
|
||
}
|
||
|
||
setFromValues (values) {
|
||
if (values[this.header]) {
|
||
Object.keys(this._state).forEach(k => this._state[k] = 0);
|
||
Object.assign(this._state, values[this.header]);
|
||
}
|
||
}
|
||
|
||
setValue (k, v) { this._state[k] = v; }
|
||
|
||
_mutNextState_resetNestsHidden ({tgt}) {
|
||
if (!this._nests) return;
|
||
Object.entries(this._nests).forEach(([nestName, nestMeta]) => tgt[nestName] = !!nestMeta.isHidden);
|
||
}
|
||
|
||
_defaultItemState (item, {isForce = false} = {}) {
|
||
// Avoid setting state for new items if the user already has active filter state. This prevents the case where e.g.:
|
||
// - The user has cleared their source filter;
|
||
// - A new source is added to the site;
|
||
// - The new source becomes the *only* selected item in their filter.
|
||
if (!isForce && this._hasUserSavedState && !Object.values(this.__state).some(Boolean)) return this._state[item.item] = 0;
|
||
|
||
// if both a selFn and a deselFn are specified, we default to deselecting
|
||
this._state[item.item] = this._getDefaultState(item.item);
|
||
}
|
||
|
||
_getDefaultState (k) { return this._deselFn && this._deselFn(k) ? 2 : this._selFn && this._selFn(k) ? 1 : 0; }
|
||
|
||
_getDisplayText (item) {
|
||
return this._displayFn ? this._displayFn(item.item, item) : item.item;
|
||
}
|
||
|
||
_getDisplayTextMini (item) {
|
||
return this._displayFnMini
|
||
? this._displayFnMini(item.item, item)
|
||
: this._getDisplayText(item);
|
||
}
|
||
|
||
_getPill (item) {
|
||
const displayText = this._getDisplayText(item);
|
||
|
||
const btnPill = e_({
|
||
tag: "div",
|
||
clazz: "fltr__pill",
|
||
html: displayText,
|
||
click: evt => this._getPill_handleClick({evt, item}),
|
||
contextmenu: evt => this._getPill_handleContextmenu({evt, item}),
|
||
});
|
||
|
||
this._getPill_bindHookState({btnPill, item});
|
||
|
||
item.searchText = displayText.toLowerCase();
|
||
|
||
return btnPill;
|
||
}
|
||
|
||
_getPill_handleClick ({evt, item}) {
|
||
if (evt.shiftKey) {
|
||
this._doSetPillsClear();
|
||
}
|
||
|
||
if (++this._state[item.item] > 2) this._state[item.item] = 0;
|
||
}
|
||
|
||
_getPill_handleContextmenu ({evt, item}) {
|
||
evt.preventDefault();
|
||
|
||
if (evt.shiftKey) {
|
||
this._doSetPillsClear();
|
||
}
|
||
|
||
if (--this._state[item.item] < 0) this._state[item.item] = 2;
|
||
}
|
||
|
||
_getPill_bindHookState ({btnPill, item}) {
|
||
this._addHook("state", item.item, () => {
|
||
const val = FilterBox._PILL_STATES[this._state[item.item]];
|
||
btnPill.attr("state", val);
|
||
})();
|
||
}
|
||
|
||
setTempFnSel (tempFnSel) {
|
||
this._selFnCache = this._selFnCache || this._selFn;
|
||
if (tempFnSel) this._selFn = tempFnSel;
|
||
else this._selFn = this._selFnCache;
|
||
}
|
||
|
||
updateMiniPillClasses () {
|
||
this._items.filter(it => it.btnMini).forEach(it => {
|
||
const isDefaultDesel = this._deselFn && this._deselFn(it.item);
|
||
const isDefaultSel = this._selFn && this._selFn(it.item);
|
||
it.btnMini
|
||
.toggleClass("fltr__mini-pill--default-desel", isDefaultDesel)
|
||
.toggleClass("fltr__mini-pill--default-sel", isDefaultSel);
|
||
});
|
||
}
|
||
|
||
_getBtnMini (item) {
|
||
const toDisplay = this._getDisplayTextMini(item);
|
||
|
||
const btnMini = e_({
|
||
tag: "div",
|
||
clazz: `fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""} ${this._deselFn && this._deselFn(item.item) ? "fltr__mini-pill--default-desel" : ""} ${this._selFn && this._selFn(item.item) ? "fltr__mini-pill--default-sel" : ""}`,
|
||
html: toDisplay,
|
||
title: `${this._displayFnTitle ? `${this._displayFnTitle(item.item, item)} (` : ""}Filter: ${this.header}${this._displayFnTitle ? ")" : ""}`,
|
||
click: () => {
|
||
this._state[item.item] = 0;
|
||
this._filterBox.fireChangeEvent();
|
||
},
|
||
}).attr("state", FilterBox._PILL_STATES[this._state[item.item]]);
|
||
|
||
const hook = () => {
|
||
const val = FilterBox._PILL_STATES[this._state[item.item]];
|
||
btnMini.attr("state", val);
|
||
// Bind change handlers in the mini-pill render step, as the mini-pills should always be available.
|
||
if (item.pFnChange) item.pFnChange(item.item, val);
|
||
};
|
||
this._addHook("state", item.item, hook);
|
||
|
||
const hideHook = () => btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header));
|
||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||
|
||
return btnMini;
|
||
}
|
||
|
||
_doSetPillsAll () {
|
||
this._proxyAssignSimple(
|
||
"state",
|
||
Object.keys(this._state)
|
||
.mergeMap(k => ({[k]: 1})),
|
||
true,
|
||
);
|
||
}
|
||
|
||
_doSetPillsClear () {
|
||
this._proxyAssignSimple(
|
||
"state",
|
||
Object.keys(this._state)
|
||
.mergeMap(k => ({[k]: 0})),
|
||
true,
|
||
);
|
||
}
|
||
|
||
_doSetPillsNone () {
|
||
this._proxyAssignSimple(
|
||
"state",
|
||
Object.keys(this._state)
|
||
.mergeMap(k => ({[k]: 2})),
|
||
true,
|
||
);
|
||
}
|
||
|
||
_doSetPinsDefault () {
|
||
this.reset();
|
||
}
|
||
|
||
_getHeaderControls (opts) {
|
||
const btnAll = e_({
|
||
tag: "button",
|
||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--all w-100`,
|
||
click: () => this._doSetPillsAll(),
|
||
html: "All",
|
||
});
|
||
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 btnNone = e_({
|
||
tag: "button",
|
||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--none w-100`,
|
||
click: () => this._doSetPillsNone(),
|
||
html: "None",
|
||
});
|
||
const btnDefault = e_({
|
||
tag: "button",
|
||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} w-100`,
|
||
click: () => this._doSetPinsDefault(),
|
||
html: "Default",
|
||
});
|
||
|
||
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: [
|
||
btnAll,
|
||
btnClear,
|
||
btnNone,
|
||
btnDefault,
|
||
],
|
||
}),
|
||
],
|
||
});
|
||
this._getHeaderControls_addExtraStateBtns(opts, wrpStateBtnsOuter);
|
||
|
||
const wrpSummary = e_({tag: "div", clazz: "ve-flex-vh-center ve-hidden"});
|
||
|
||
const btnCombineBlue = e_({
|
||
tag: "button",
|
||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn-logic--blue fltr__h-btn-logic w-100`,
|
||
click: () => this._meta.combineBlue = Filter._getNextCombineMode(this._meta.combineBlue),
|
||
title: `Blue match mode for this filter. "AND" requires all blues to match, "OR" requires at least one blue to match, "XOR" requires exactly one blue to match.`,
|
||
});
|
||
const hookCombineBlue = () => e_({ele: btnCombineBlue, text: `${this._meta.combineBlue}`.toUpperCase()});
|
||
this._addHook("meta", "combineBlue", hookCombineBlue);
|
||
hookCombineBlue();
|
||
|
||
const btnCombineRed = e_({
|
||
tag: "button",
|
||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn-logic--red fltr__h-btn-logic w-100`,
|
||
click: () => this._meta.combineRed = Filter._getNextCombineMode(this._meta.combineRed),
|
||
title: `Red match mode for this filter. "AND" requires all reds to match, "OR" requires at least one red to match, "XOR" requires exactly one red to match.`,
|
||
});
|
||
const hookCombineRed = () => e_({ele: btnCombineRed, text: `${this._meta.combineRed}`.toUpperCase()});
|
||
this._addHook("meta", "combineRed", hookCombineRed);
|
||
hookCombineRed();
|
||
|
||
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);
|
||
|
||
// 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,
|
||
cur._totals.yes && cur._totals.no
|
||
? `<span class="fltr__summary_item_spacer"></span>`
|
||
: null,
|
||
cur._totals.no
|
||
? `<span class="fltr__summary_item fltr__summary_item--exclude" title="${cur._totals.no} hidden "excluded" tags">${cur._totals.no}</span>`
|
||
: null,
|
||
].filter(Boolean).join("");
|
||
e_({ele: wrpSummary, html: htmlSummary}).toggleVe(this._meta.isHidden);
|
||
};
|
||
this._addHook("meta", "isHidden", hookShowHide);
|
||
hookShowHide();
|
||
|
||
return e_({
|
||
tag: "div",
|
||
clazz: `ve-flex-v-center fltr__h-wrp-btns-outer`,
|
||
children: [
|
||
wrpSummary,
|
||
wrpStateBtnsOuter,
|
||
e_({tag: "span", clazz: `btn-group ml-2 ve-flex-v-center`, children: [btnCombineBlue, btnCombineRed]}),
|
||
btnShowHide,
|
||
],
|
||
});
|
||
}
|
||
|
||
_getHeaderControls_addExtraStateBtns () {
|
||
// To be optionally implemented by child classes
|
||
}
|
||
|
||
/**
|
||
* @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 = opts.$wrpMini ? e_({ele: opts.$wrpMini[0]}) : null;
|
||
|
||
const wrpControls = this._getHeaderControls(opts);
|
||
|
||
if (this._nests) {
|
||
const wrpNestHead = e_({tag: "div", clazz: "fltr__wrp-pills--sub"}).appendTo(this.__wrpPills);
|
||
this.__$wrpNestHeadInner = e_({tag: "div", clazz: "ve-flex ve-flex-wrap fltr__container-pills"}).appendTo(wrpNestHead);
|
||
|
||
const wrpNestHeadSummary = e_({tag: "div", clazz: "fltr__summary_nest"}).appendTo(wrpNestHead);
|
||
|
||
this._updateNestSummary = () => {
|
||
const stats = {high: 0, low: 0};
|
||
this._items.filter(it => this._state[it.item] && this._nestsHidden[it.nest]).forEach(it => {
|
||
const key = this._state[it.item] === 1 ? "high" : "low";
|
||
stats[key]++;
|
||
});
|
||
|
||
wrpNestHeadSummary.empty();
|
||
|
||
if (stats.high) {
|
||
e_({
|
||
tag: "span",
|
||
clazz: "fltr__summary_item fltr__summary_item--include",
|
||
text: stats.high,
|
||
title: `${stats.high} hidden "required" tag${stats.high === 1 ? "" : "s"}`,
|
||
}).appendTo(wrpNestHeadSummary);
|
||
}
|
||
|
||
if (stats.high && stats.low) e_({tag: "span", clazz: "fltr__summary_item_spacer"}).appendTo(wrpNestHeadSummary);
|
||
|
||
if (stats.low) {
|
||
e_({
|
||
tag: "span",
|
||
clazz: "fltr__summary_item fltr__summary_item--exclude",
|
||
text: stats.low,
|
||
title: `${stats.low} hidden "excluded" tag${stats.low === 1 ? "" : "s"}`,
|
||
}).appendTo(wrpNestHeadSummary);
|
||
}
|
||
};
|
||
|
||
this._doRenderNests();
|
||
}
|
||
|
||
this._doRenderPills();
|
||
|
||
const btnMobToggleControls = this._getBtnMobToggleControls(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 ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||
<div class="fltr__h-text ve-flex-h-center mobile__w-100">
|
||
${opts.isMulti ? `<span class="mr-2">\u2012</span>` : ""}
|
||
${this._getRenderedHeader()}
|
||
${btnMobToggleControls}
|
||
</div>
|
||
${wrpControls}
|
||
</div>
|
||
${this.__wrpPills}
|
||
</div>`;
|
||
|
||
this._doToggleDisplay();
|
||
|
||
return this.__$wrpFilter;
|
||
}
|
||
|
||
/**
|
||
* @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) {
|
||
if (!opts.$wrpMini) return;
|
||
|
||
this._filterBox = opts.filterBox;
|
||
this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]});
|
||
|
||
this._renderMinis_initWrpPills();
|
||
|
||
this._doRenderMiniPills();
|
||
}
|
||
|
||
_renderMinis_initWrpPills () {
|
||
this.__wrpPills = e_({tag: "div", clazz: `fltr__wrp-pills ${this._groupFn ? "fltr__wrp-subs" : "fltr__container-pills"}`});
|
||
const hook = () => this.__wrpPills.toggleVe(!this._meta.isHidden);
|
||
this._addHook("meta", "isHidden", hook);
|
||
hook();
|
||
}
|
||
|
||
getValues ({nxtState = null} = {}) {
|
||
const state = MiscUtil.copy(nxtState?.[this.header]?.state || this.__state);
|
||
const meta = nxtState?.[this.header]?.meta || this.__meta;
|
||
|
||
// remove state for any currently-absent filters
|
||
Object.keys(state).filter(k => !this._items.some(it => `${it.item}` === k)).forEach(k => delete state[k]);
|
||
const out = {...state};
|
||
|
||
// add helper data
|
||
out._isActive = Object.values(state).some(Boolean);
|
||
out._totals = {yes: 0, no: 0, ignored: 0};
|
||
Object.values(state).forEach(v => {
|
||
const totalKey = v === 0 ? "ignored" : v === 1 ? "yes" : "no";
|
||
out._totals[totalKey]++;
|
||
});
|
||
out._combineBlue = meta.combineBlue;
|
||
out._combineRed = meta.combineRed;
|
||
return {[this.header]: out};
|
||
}
|
||
|
||
_getNextState_base () {
|
||
return {
|
||
[this.header]: {
|
||
...super._getNextState_base()[this.header],
|
||
nestsHidden: MiscUtil.copyFast(this.__nestsHidden),
|
||
},
|
||
};
|
||
}
|
||
|
||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||
if (isResetAll) {
|
||
this._mutNextState_resetBase(nxtState);
|
||
this._mutNextState_resetNestsHidden({tgt: nxtState[this.header].nestsHidden});
|
||
} else {
|
||
// Always reset "AND/OR" states
|
||
Object.assign(nxtState[this.header].meta, {combineBlue: Filter._DEFAULT_META.combineBlue, combineRed: Filter._DEFAULT_META.combineRed});
|
||
}
|
||
Object.keys(nxtState[this.header].state).forEach(k => delete nxtState[this.header].state[k]);
|
||
this._items.forEach(item => nxtState[this.header].state[item.item] = this._getDefaultState(item.item));
|
||
}
|
||
|
||
_doRenderPills () {
|
||
if (this._itemSortFn) this._items.sort(this._isSortByDisplayItems && this._displayFn ? (a, b) => this._itemSortFn(this._displayFn(a.item, a), this._displayFn(b.item, b)) : this._itemSortFn);
|
||
|
||
this._items.forEach(it => {
|
||
if (!it.rendered) {
|
||
it.rendered = this._getPill(it);
|
||
if (it.nest) {
|
||
const hook = () => it.rendered.toggleVe(!this._nestsHidden[it.nest]);
|
||
this._addHook("nestsHidden", it.nest, hook);
|
||
hook();
|
||
}
|
||
}
|
||
|
||
if (this._groupFn) {
|
||
const group = this._groupFn(it);
|
||
this._doRenderPills_doRenderWrpGroup(group);
|
||
this._pillGroupsMeta[group].wrpPills.append(it.rendered);
|
||
} else it.rendered.appendTo(this.__wrpPills);
|
||
});
|
||
}
|
||
|
||
_doRenderPills_doRenderWrpGroup (group) {
|
||
const existingMeta = this._pillGroupsMeta[group];
|
||
if (existingMeta && !existingMeta.isAttached) {
|
||
existingMeta.hrDivider.appendTo(this.__wrpPills);
|
||
existingMeta.wrpPills.appendTo(this.__wrpPills);
|
||
existingMeta.isAttached = true;
|
||
}
|
||
if (existingMeta) return;
|
||
|
||
this._pillGroupsMeta[group] = {
|
||
hrDivider: this._doRenderPills_doRenderWrpGroup_getHrDivider(group).appendTo(this.__wrpPills),
|
||
wrpPills: this._doRenderPills_doRenderWrpGroup_getWrpPillsSub(group).appendTo(this.__wrpPills),
|
||
isAttached: true,
|
||
};
|
||
|
||
Object.entries(this._pillGroupsMeta)
|
||
.sort((a, b) => SortUtil.ascSortLower(a[0], b[0]))
|
||
.forEach(([groupKey, groupMeta], i) => {
|
||
groupMeta.hrDivider.appendTo(this.__wrpPills);
|
||
groupMeta.hrDivider.toggleVe(!this._isGroupDividerHidden(groupKey, i));
|
||
groupMeta.wrpPills.appendTo(this.__wrpPills);
|
||
});
|
||
|
||
if (this._nests) {
|
||
this._pillGroupsMeta[group].toggleDividerFromNestVisibility = () => {
|
||
this._pillGroupsMeta[group].hrDivider.toggleVe(!this._isGroupDividerHidden(group));
|
||
};
|
||
|
||
// bind group dividers to show/hide depending on nest visibility state
|
||
Object.keys(this._nests).forEach(nestName => {
|
||
const hook = () => this._pillGroupsMeta[group].toggleDividerFromNestVisibility();
|
||
this._addHook("nestsHidden", nestName, hook);
|
||
hook();
|
||
this._pillGroupsMeta[group].toggleDividerFromNestVisibility();
|
||
});
|
||
}
|
||
}
|
||
|
||
_isGroupDividerHidden (group, ixSortedGroups) {
|
||
if (!this._nests) {
|
||
// When not nested, always hide the first divider
|
||
if (ixSortedGroups === undefined) return `${group}` === `${Object.keys(this._pillGroupsMeta).sort((a, b) => SortUtil.ascSortLower(a, b))[0]}`;
|
||
return ixSortedGroups === 0;
|
||
}
|
||
|
||
const groupItems = this._items.filter(it => this._groupFn(it) === group);
|
||
const hiddenGroupItems = groupItems.filter(it => this._nestsHidden[it.nest]);
|
||
return groupItems.length === hiddenGroupItems.length;
|
||
}
|
||
|
||
_doRenderPills_doRenderWrpGroup_getHrDivider () { return e_({tag: "hr", clazz: `fltr__dropdown-divider--sub hr-2 mx-3`}); }
|
||
_doRenderPills_doRenderWrpGroup_getWrpPillsSub () { return e_({tag: "div", clazz: `fltr__wrp-pills--sub fltr__container-pills`}); }
|
||
|
||
_doRenderMiniPills () {
|
||
// create a list view so we can freely sort
|
||
const view = this._items.slice(0);
|
||
if (this._itemSortFnMini || this._itemSortFn) {
|
||
const fnSort = this._itemSortFnMini || this._itemSortFn;
|
||
view.sort(this._isSortByDisplayItems && this._displayFn ? (a, b) => fnSort(this._displayFn(a.item, a), this._displayFn(b.item, b)) : fnSort);
|
||
}
|
||
|
||
if (this.__wrpMiniPills) {
|
||
view.forEach(it => {
|
||
// re-append existing elements to sort them
|
||
(it.btnMini = it.btnMini || this._getBtnMini(it)).appendTo(this.__wrpMiniPills);
|
||
});
|
||
}
|
||
}
|
||
|
||
_doToggleDisplay () {
|
||
// if there are no items, hide everything
|
||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__no-items", !this._items.length);
|
||
}
|
||
|
||
_doRenderNests () {
|
||
Object.entries(this._nests)
|
||
.sort((a, b) => SortUtil.ascSort(a[0], b[0])) // array 0 (key) is the nest name
|
||
.forEach(([nestName, nestMeta]) => {
|
||
if (nestMeta._$btnNest == null) {
|
||
// this can be restored from a saved state, otherwise, initialise it
|
||
if (this._nestsHidden[nestName] == null) this._nestsHidden[nestName] = !!nestMeta.isHidden;
|
||
|
||
const $btnText = $(`<span>${nestName} [${this._nestsHidden[nestName] ? "+" : "\u2212"}]</span>`);
|
||
nestMeta._$btnNest = $$`<div class="fltr__btn_nest">${$btnText}</div>`
|
||
.click(() => this._nestsHidden[nestName] = !this._nestsHidden[nestName]);
|
||
|
||
const hook = () => {
|
||
$btnText.text(`${nestName} [${this._nestsHidden[nestName] ? "+" : "\u2212"}]`);
|
||
|
||
const stats = {high: 0, low: 0, total: 0};
|
||
this._items
|
||
.filter(it => it.nest === nestName)
|
||
.find(it => {
|
||
const key = this._state[it.item] === 1 ? "high" : this._state[it.item] ? "low" : "ignored";
|
||
stats[key]++;
|
||
stats.total++;
|
||
});
|
||
const allHigh = stats.total === stats.high;
|
||
const allLow = stats.total === stats.low;
|
||
nestMeta._$btnNest.toggleClass("fltr__btn_nest--include-all", this._nestsHidden[nestName] && allHigh)
|
||
.toggleClass("fltr__btn_nest--exclude-all", this._nestsHidden[nestName] && allLow)
|
||
.toggleClass("fltr__btn_nest--include", this._nestsHidden[nestName] && !!(!allHigh && !allLow && stats.high && !stats.low))
|
||
.toggleClass("fltr__btn_nest--exclude", this._nestsHidden[nestName] && !!(!allHigh && !allLow && !stats.high && stats.low))
|
||
.toggleClass("fltr__btn_nest--both", this._nestsHidden[nestName] && !!(!allHigh && !allLow && stats.high && stats.low));
|
||
|
||
if (this._updateNestSummary) this._updateNestSummary();
|
||
};
|
||
|
||
this._items
|
||
.filter(it => it.nest === nestName)
|
||
.find(it => {
|
||
this._addHook("state", it.item, hook);
|
||
});
|
||
|
||
this._addHook("nestsHidden", nestName, hook);
|
||
hook();
|
||
}
|
||
nestMeta._$btnNest.appendTo(this.__$wrpNestHeadInner);
|
||
});
|
||
|
||
if (this._updateNestSummary) this._updateNestSummary();
|
||
}
|
||
|
||
update () {
|
||
if (this._isNestsDirty) {
|
||
this._isNestsDirty = false;
|
||
|
||
this._doRenderNests();
|
||
}
|
||
|
||
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();
|
||
this._doToggleDisplay();
|
||
}
|
||
|
||
_getFilterItem (item) {
|
||
return item instanceof FilterItem ? item : new FilterItem({item});
|
||
}
|
||
|
||
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.__itemsSet.has(item.item || item)) {
|
||
item = this._getFilterItem(item);
|
||
Filter._validateItemNest(item, this._nests);
|
||
|
||
this._isItemsDirty = true;
|
||
this._items.push(item);
|
||
this.__itemsSet.add(item.item);
|
||
if (this._state[item.item] == null) this._defaultItemState(item);
|
||
}
|
||
}
|
||
|
||
addNest (nestName, nestMeta) {
|
||
// may need to allow this in future
|
||
// can easily be circumvented by initialising with empty nests in filter construction
|
||
if (!this._nests) throw new Error(`Filter was not nested!`);
|
||
if (!this._nests[nestName]) {
|
||
this._isNestsDirty = true;
|
||
this._nests[nestName] = nestMeta;
|
||
|
||
// bind group dividers to show/hide based on the new nest
|
||
if (this._groupFn) {
|
||
Object.keys(this._pillGroupsMeta).forEach(group => {
|
||
const hook = () => this._pillGroupsMeta[group].toggleDividerFromNestVisibility();
|
||
this._addHook("nestsHidden", nestName, hook);
|
||
hook();
|
||
this._pillGroupsMeta[group].toggleDividerFromNestVisibility();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
_toDisplay_getMappedEntryVal (entryVal) {
|
||
if (!(entryVal instanceof Array)) entryVal = [entryVal];
|
||
entryVal = entryVal.map(it => it instanceof FilterItem ? it : new FilterItem({item: it}));
|
||
return entryVal;
|
||
}
|
||
|
||
_toDisplay_getFilterState (boxState) { return boxState[this.header]; }
|
||
|
||
toDisplay (boxState, entryVal) {
|
||
const filterState = this._toDisplay_getFilterState(boxState);
|
||
if (!filterState) return true;
|
||
|
||
const totals = filterState._totals;
|
||
|
||
entryVal = this._toDisplay_getMappedEntryVal(entryVal);
|
||
|
||
const isUmbrella = () => {
|
||
if (this._umbrellaItems) {
|
||
if (!entryVal) return false;
|
||
|
||
if (this._umbrellaExcludes && this._umbrellaExcludes.some(it => filterState[it.item])) return false;
|
||
|
||
return this._umbrellaItems.some(u => entryVal.includes(u.item))
|
||
&& (this._umbrellaItems.some(u => filterState[u.item] === 0) || this._umbrellaItems.some(u => filterState[u.item] === 1));
|
||
}
|
||
};
|
||
|
||
let hide = false;
|
||
let display = false;
|
||
|
||
switch (filterState._combineBlue) {
|
||
case "or": {
|
||
// default to displaying
|
||
if (totals.yes === 0) display = true;
|
||
|
||
// if any are 1 (blue) include if they match
|
||
display = display || entryVal.some(fi => filterState[fi.item] === 1 || isUmbrella());
|
||
|
||
break;
|
||
}
|
||
case "xor": {
|
||
// default to displaying
|
||
if (totals.yes === 0) display = true;
|
||
|
||
// if any are 1 (blue) include if precisely one matches
|
||
display = display || entryVal.filter(fi => filterState[fi.item] === 1 || isUmbrella()).length === 1;
|
||
|
||
break;
|
||
}
|
||
case "and": {
|
||
const totalYes = entryVal.filter(fi => filterState[fi.item] === 1).length;
|
||
display = !totals.yes || totals.yes === totalYes;
|
||
|
||
break;
|
||
}
|
||
default: throw new Error(`Unhandled combine mode "${filterState._combineBlue}"`);
|
||
}
|
||
|
||
switch (filterState._combineRed) {
|
||
case "or": {
|
||
// if any are 2 (red) exclude if they match
|
||
hide = hide || entryVal.filter(fi => !fi.isIgnoreRed).some(fi => filterState[fi.item] === 2);
|
||
|
||
break;
|
||
}
|
||
case "xor": {
|
||
// if exactly one is 2 (red) exclude if it matches
|
||
hide = hide || entryVal.filter(fi => !fi.isIgnoreRed).filter(fi => filterState[fi.item] === 2).length === 1;
|
||
|
||
break;
|
||
}
|
||
case "and": {
|
||
const totalNo = entryVal.filter(fi => !fi.isIgnoreRed).filter(fi => filterState[fi.item] === 2).length;
|
||
hide = totals.no && totals.no === totalNo;
|
||
|
||
break;
|
||
}
|
||
default: throw new Error(`Unhandled combine mode "${filterState._combineRed}"`);
|
||
}
|
||
|
||
return display && !hide;
|
||
}
|
||
|
||
_doInvertPins () {
|
||
const cur = MiscUtil.copy(this._state);
|
||
Object.keys(this._state).forEach(k => this._state[k] = cur[k] === 1 ? 0 : 1);
|
||
}
|
||
|
||
getDefaultMeta () {
|
||
// Key order is important, as @filter tags depend on it
|
||
return {
|
||
...super.getDefaultMeta(),
|
||
...Filter._DEFAULT_META,
|
||
};
|
||
}
|
||
|
||
handleSearch (searchTerm) {
|
||
const isHeaderMatch = this.header.toLowerCase().includes(searchTerm);
|
||
|
||
if (isHeaderMatch) {
|
||
this._items.forEach(it => {
|
||
if (!it.rendered) return;
|
||
it.rendered.toggleClass("fltr__hidden--search", false);
|
||
});
|
||
|
||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false);
|
||
|
||
return true;
|
||
}
|
||
|
||
let visibleCount = 0;
|
||
this._items.forEach(it => {
|
||
if (!it.rendered) return;
|
||
const isVisible = it.searchText.includes(searchTerm);
|
||
it.rendered.toggleClass("fltr__hidden--search", !isVisible);
|
||
if (isVisible) visibleCount++;
|
||
});
|
||
|
||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", visibleCount === 0);
|
||
|
||
return visibleCount !== 0;
|
||
}
|
||
|
||
static _getNextCombineMode (combineMode) {
|
||
let ix = Filter._COMBINE_MODES.indexOf(combineMode);
|
||
if (ix === -1) ix = (Filter._COMBINE_MODES.length - 1);
|
||
if (++ix === Filter._COMBINE_MODES.length) ix = 0;
|
||
return Filter._COMBINE_MODES[ix];
|
||
}
|
||
|
||
_doTeardown () {
|
||
this._items.forEach(it => {
|
||
if (it.rendered) it.rendered.detach();
|
||
if (it.btnMini) it.btnMini.detach();
|
||
});
|
||
|
||
Object.values(this._nests || {})
|
||
.filter(nestMeta => nestMeta._$btnNest)
|
||
.forEach(nestMeta => nestMeta._$btnNest.detach());
|
||
|
||
Object.values(this._pillGroupsMeta || {})
|
||
.forEach(it => {
|
||
it.hrDivider.detach();
|
||
it.wrpPills.detach();
|
||
it.isAttached = false;
|
||
});
|
||
}
|
||
}
|
||
Filter._DEFAULT_META = {
|
||
combineBlue: "or",
|
||
combineRed: "or",
|
||
};
|
||
Filter._COMBINE_MODES = ["or", "and", "xor"];
|
||
|
||
class FilterTransientOptions {
|
||
/**
|
||
* @param opts Options object.
|
||
* @param [opts.isExtendDefaultState]
|
||
*/
|
||
constructor (opts) {
|
||
this.isExtendDefaultState = opts.isExtendDefaultState;
|
||
}
|
||
}
|
||
|
||
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: "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 = "";
|
||
});
|
||
}
|
||
}
|
||
|
||
globalThis.SearchableFilter = SearchableFilter;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
const isBrewSource = BrewUtil2.hasSourceJson(item);
|
||
const isNonStandardSource = !isBrewSource && (SourceUtil.isNonstandardSource(item) || PrereleaseUtil.hasSourceJson(item));
|
||
return `<span ${isBrewSource ? `title="(Homebrew)"` : isNonStandardSource ? `title="(UA/Etc.)"` : ""} class="glyphicon ${isBrewSource ? `glyphicon-glass` : isNonStandardSource ? `glyphicon-file` : `glyphicon-book`}"></span> ${Parser.sourceJsonToAbv(item)}`;
|
||
}
|
||
|
||
constructor (opts) {
|
||
opts = opts || {};
|
||
|
||
opts.header = opts.header === undefined ? FilterBox.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.selFn = opts.selFn === undefined ? PageFilter.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 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);
|
||
}
|
||
|
||
_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_getHrDivider (group) {
|
||
switch (group) {
|
||
case SourceUtil.FILTER_GROUP_NON_STANDARD: return this._doRenderPills_doRenderWrpGroup_getHrDivider_groupNonStandard(group);
|
||
case SourceUtil.FILTER_GROUP_HOMEBREW: return this._doRenderPills_doRenderWrpGroup_getHrDivider_groupBrew(group);
|
||
default: return super._doRenderPills_doRenderWrpGroup_getHrDivider(group);
|
||
}
|
||
}
|
||
|
||
_doRenderPills_doRenderWrpGroup_getHrDivider_groupNonStandard (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.isNonstandardSource(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.isNonstandardSource(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,
|
||
],
|
||
});
|
||
|
||
return e_({
|
||
tag: "div",
|
||
clazz: `ve-flex-col w-100`,
|
||
children: [
|
||
super._doRenderPills_doRenderWrpGroup_getHrDivider(),
|
||
e_({
|
||
tag: "div",
|
||
clazz: `mb-1 ve-flex-h-right`,
|
||
children: [
|
||
grpBtnsActive,
|
||
grpBtnsInactive,
|
||
],
|
||
}),
|
||
wrpWrpSlider,
|
||
],
|
||
});
|
||
}
|
||
|
||
_doRenderPills_doRenderWrpGroup_getHrDivider_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);
|
||
},
|
||
});
|
||
|
||
return e_({
|
||
tag: "div",
|
||
clazz: `ve-flex-col w-100`,
|
||
children: [
|
||
super._doRenderPills_doRenderWrpGroup_getHrDivider(),
|
||
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;
|
||
|
||
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 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}`;
|
||
}
|
||
}
|
||
|
||
globalThis.AbilityScoreFilter = AbilityScoreFilter;
|
||
|
||
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});
|
||
}
|
||
};
|
||
|
||
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,
|
||
};
|
||
|
||
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 = {};
|
||
|
||
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);
|
||
}
|
||
}
|
||
MultiFilter._DETAULT_STATE = {
|
||
mode: "and",
|
||
};
|
||
|
||
// validate subhash prefixes
|
||
(() => {
|
||
const boxPrefixes = Object.values(FilterBox._SUB_HASH_PREFIXES).filter(it => it.length !== FilterUtil.SUB_HASH_PREFIX_LENGTH);
|
||
const filterPrefixes = Object.values(FilterBase._SUB_HASH_PREFIXES).filter(it => it.length !== FilterUtil.SUB_HASH_PREFIX_LENGTH);
|
||
const allPrefixes = boxPrefixes.concat(filterPrefixes);
|
||
if (allPrefixes.length) throw new Error(`Invalid prefixes! ${allPrefixes.map(it => `"${it}"`).join(", ")} ${allPrefixes.length === 1 ? `is` : `was`} not of length ${FilterUtil.SUB_HASH_PREFIX_LENGTH}`);
|
||
})();
|
||
FilterUtil.SUB_HASH_PREFIXES = new Set([...Object.values(FilterBox._SUB_HASH_PREFIXES), ...Object.values(FilterBase._SUB_HASH_PREFIXES)]);
|
||
|
||
globalThis.FilterUtil = FilterUtil;
|
||
globalThis.PageFilter = PageFilter;
|
||
globalThis.FilterBox = FilterBox;
|
||
globalThis.FilterItem = FilterItem;
|
||
globalThis.FilterBase = FilterBase;
|
||
globalThis.Filter = Filter;
|
||
globalThis.SourceFilter = SourceFilter;
|
||
globalThis.RangeFilter = RangeFilter;
|
||
globalThis.OptionsFilter = OptionsFilter;
|
||
globalThis.MultiFilter = MultiFilter;
|