aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/FilterPanel.tsx
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2023-04-17 09:37:16 -0400
committerbobzel <zzzman@gmail.com>2023-04-17 09:37:16 -0400
commit6a9e80de419af14bece7a48e55edc1543d69f20f (patch)
tree71ae1b819bc4f7fdb699ae90c035eb86275c5006 /src/client/views/FilterPanel.tsx
parent0a38e3f91f4f85f07fdbb7575ceb678032dcdfe9 (diff)
parent8127616d06b4db2b29de0b13068810fd19e77b5e (diff)
Merge branch 'master' into james-server-stats
Diffstat (limited to 'src/client/views/FilterPanel.tsx')
-rw-r--r--src/client/views/FilterPanel.tsx232
1 files changed, 232 insertions, 0 deletions
diff --git a/src/client/views/FilterPanel.tsx b/src/client/views/FilterPanel.tsx
new file mode 100644
index 000000000..d35494f26
--- /dev/null
+++ b/src/client/views/FilterPanel.tsx
@@ -0,0 +1,232 @@
+import React = require('react');
+import { action, computed, observable, ObservableMap } from 'mobx';
+import { observer } from 'mobx-react';
+import Select from 'react-select';
+import { Doc, DocListCast, Field, StrListCast } from '../../fields/Doc';
+import { RichTextField } from '../../fields/RichTextField';
+import { StrCast } from '../../fields/Types';
+import { DocumentManager } from '../util/DocumentManager';
+import { UserOptions } from '../util/GroupManager';
+import './FilterPanel.scss';
+import { FieldView } from './nodes/FieldView';
+import { SearchBox } from './search/SearchBox';
+
+interface filterProps {
+ rootDoc: Doc;
+}
+@observer
+export class FilterPanel extends React.Component<filterProps> {
+ public static LayoutString(fieldKey: string) {
+ return FieldView.LayoutString(FilterPanel, fieldKey);
+ }
+
+ /**
+ * @returns the relevant doc according to the value of FilterBox._filterScope i.e. either the Current Dashboard or the Current Collection
+ */
+ @computed get targetDoc() {
+ return this.props.rootDoc;
+ }
+ @computed get targetDocChildKey() {
+ const targetView = DocumentManager.Instance.getFirstDocumentView(this.targetDoc);
+ return targetView?.ComponentView?.annotationKey ?? targetView?.ComponentView?.fieldKey ?? 'data';
+ }
+ @computed get targetDocChildren() {
+ return DocListCast(this.targetDoc?.[this.targetDocChildKey] || Doc.ActiveDashboard?.data);
+ }
+
+ @computed get allDocs() {
+ const allDocs = new Set<Doc>();
+ const targetDoc = this.targetDoc;
+ if (targetDoc) {
+ SearchBox.foreachRecursiveDoc([this.targetDoc], (depth, doc) => allDocs.add(doc));
+ }
+ return Array.from(allDocs);
+ }
+
+ @computed get _allFacets() {
+ // trace();
+ const noviceReqFields = ['author', 'tags', 'text', 'type'];
+ const noviceLayoutFields: string[] = []; //["_curPage"];
+ const noviceFields = [...noviceReqFields, ...noviceLayoutFields];
+
+ const keys = new Set<string>(noviceFields);
+ this.allDocs.forEach(doc => SearchBox.documentKeys(doc).filter(key => keys.add(key)));
+ return Array.from(keys.keys())
+ .filter(key => key[0])
+ .filter(key => key[0] === '#' || key.indexOf('lastModified') !== -1 || (key[0] === key[0].toUpperCase() && !key.startsWith('_')) || noviceFields.includes(key) || !Doc.noviceMode)
+ .sort();
+ }
+
+ /**
+ * The current attributes selected to filter based on
+ */
+ @computed get activeFilters() {
+ return StrListCast(this.targetDoc?._docFilters);
+ }
+
+ /**
+ * @returns a string array of the current attributes
+ */
+ @computed get currentFacets() {
+ return this.activeFilters.map(filter => filter.split(':')[0]);
+ }
+
+ gatherFieldValues(childDocs: Doc[], facetKey: string) {
+ const valueSet = new Set<string>();
+ let rtFields = 0;
+ let subDocs = childDocs;
+ if (subDocs.length > 0) {
+ let newarray: Doc[] = [];
+ while (subDocs.length > 0) {
+ newarray = [];
+ subDocs.forEach(t => {
+ const facetVal = t[facetKey];
+ if (facetVal instanceof RichTextField || typeof facetVal === 'string') rtFields++;
+ facetVal !== undefined && valueSet.add(Field.toString(facetVal as Field));
+ (facetVal === true || facetVal == false) && valueSet.add(Field.toString(!facetVal));
+ const fieldKey = Doc.LayoutFieldKey(t);
+ const annos = !Field.toString(Doc.LayoutField(t) as Field).includes('CollectionView');
+ DocListCast(t[annos ? fieldKey + '-annotations' : fieldKey]).forEach(newdoc => newarray.push(newdoc));
+ });
+ subDocs = newarray;
+ }
+ }
+ // }
+ // });
+ return { strings: Array.from(valueSet.keys()), rtFields };
+ }
+
+ public removeFilter = (filterName: string) => {
+ Doc.setDocFilter(this.targetDoc, filterName, undefined, 'remove');
+ Doc.setDocRangeFilter(this.targetDoc, filterName, undefined);
+ };
+
+ @observable _chosenFacets = new ObservableMap<string, 'text' | 'checkbox' | 'slider' | 'range'>();
+ @computed get activeFacets() {
+ const facets = new Map<string, 'text' | 'checkbox' | 'slider' | 'range'>(this._chosenFacets);
+ StrListCast(this.targetDoc?._docFilters).map(filter => facets.set(filter.split(':')[0], filter.split(':')[2] === 'match' ? 'text' : 'checkbox'));
+ setTimeout(() => StrListCast(this.targetDoc?._docFilters).map(action(filter => this._chosenFacets.set(filter.split(':')[0], filter.split(':')[2] === 'match' ? 'text' : 'checkbox'))));
+ return facets;
+ }
+ /**
+ * Responds to clicking the check box in the flyout menu
+ */
+ @action
+ facetClick = (facetHeader: string) => {
+ if (!this.targetDoc) return;
+ const allCollectionDocs = this.targetDocChildren;
+ const facetValues = this.gatherFieldValues(this.targetDocChildren, facetHeader);
+
+ let nonNumbers = 0;
+ let minVal = Number.MAX_VALUE,
+ maxVal = -Number.MAX_VALUE;
+ facetValues.strings.map(val => {
+ const num = val ? Number(val) : Number.NaN;
+ if (Number.isNaN(num)) {
+ val && nonNumbers++;
+ } else {
+ minVal = Math.min(num, minVal);
+ maxVal = Math.max(num, maxVal);
+ }
+ });
+ if (facetHeader === 'text' || (facetValues.rtFields / allCollectionDocs.length > 0.1 && facetValues.rtFields > 20)) {
+ this._chosenFacets.set(facetHeader, 'text');
+ } else if (facetHeader !== 'tags' && nonNumbers / facetValues.strings.length < 0.1) {
+ } else {
+ this._chosenFacets.set(facetHeader, 'checkbox');
+ }
+ };
+
+ facetValues = (facetHeader: string) => {
+ const allCollectionDocs = new Set<Doc>();
+ SearchBox.foreachRecursiveDoc(this.targetDocChildren, (depth: number, doc: Doc) => allCollectionDocs.add(doc));
+ const set = new Set<string>();
+ if (facetHeader === 'tags')
+ allCollectionDocs.forEach(child =>
+ Field.toString(child[facetHeader] as Field)
+ .split(':')
+ .forEach(key => set.add(key))
+ );
+ else
+ allCollectionDocs.forEach(child => {
+ const fieldVal = child[facetHeader] as Field;
+ set.add(Field.toString(fieldVal));
+ (fieldVal === true || fieldVal === false) && set.add((!fieldVal).toString());
+ });
+ const facetValues = Array.from(set).filter(v => v);
+
+ let nonNumbers = 0;
+
+ facetValues.map(val => Number.isNaN(Number(val)) && nonNumbers++);
+ const facetValueDocSet = (nonNumbers / facetValues.length > 0.1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2))).map(facetValue => {
+ return facetValue;
+ });
+ return facetValueDocSet;
+ };
+
+ render() {
+ const options = this._allFacets.filter(facet => this.currentFacets.indexOf(facet) === -1).map(facet => ({ value: facet, label: facet }));
+ return (
+ <div className="filterBox-treeView" style={{ position: 'relative', width: '100%' }}>
+ <div className="filterBox-select-bool">
+ <select className="filterBox-selection" onChange={action(e => this.targetDoc && (this.targetDoc._filterBoolean = (e.target as any).value))} defaultValue={StrCast(this.targetDoc?.filterBoolean)}>
+ {['AND', 'OR'].map(bool => (
+ <option value={bool} key={bool}>
+ {bool}
+ </option>
+ ))}
+ </select>
+ <div className="filterBox-select-text">filters together</div>
+ </div>
+
+ <div className="filterBox-select">
+ <Select placeholder="Add a filter..." options={options} isMulti={false} onChange={val => this.facetClick((val as UserOptions).value)} onKeyDown={e => e.stopPropagation()} value={null} closeMenuOnSelect={true} />
+ </div>
+
+ <div className="filterBox-tree" key="tree" style={{ overflow: 'auto' }}>
+ {Array.from(this.activeFacets.keys()).map(facetHeader => (
+ <div>
+ {facetHeader}
+ {this.displayFacetValueFilterUIs(this.activeFacets.get(facetHeader), facetHeader)}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ private displayFacetValueFilterUIs(type: string | undefined, facetHeader: string): React.ReactNode {
+ switch (type) {
+ case 'text':
+ return (
+ <input
+ placeholder={
+ StrListCast(this.targetDoc._docFilters)
+ .find(filter => filter.split(':')[0] === facetHeader)
+ ?.split(':')[1] ?? '-empty-'
+ }
+ onKeyDown={e => e.key === 'Enter' && Doc.setDocFilter(this.targetDoc, facetHeader, e.currentTarget.value, !e.currentTarget.value ? 'remove' : 'match')}
+ />
+ );
+ case 'checkbox':
+ return this.facetValues(facetHeader).map(fval => {
+ const facetValue = fval;
+ return (
+ <div>
+ <input
+ style={{ width: 20, marginLeft: 20 }}
+ checked={
+ StrListCast(this.targetDoc._docFilters)
+ .find(filter => filter.split(':')[0] === facetHeader && filter.split(':')[1] == facetValue)
+ ?.split(':')[2] === 'check'
+ }
+ type={type}
+ onChange={e => Doc.setDocFilter(this.targetDoc, facetHeader, fval, e.target.checked ? 'check' : 'remove')}
+ />
+ {facetValue}
+ </div>
+ );
+ });
+ }
+ }
+}