import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { action, computed, makeObservable, observable, ObservableMap } from 'mobx'; import { observer, useLocalObservable } from 'mobx-react'; import * as React from 'react'; import { useEffect, useRef } from 'react'; import { Handles, Rail, Slider, Ticks, Tracks } from 'react-compound-slider'; import { AiOutlineMinusSquare, AiOutlinePlusSquare } from 'react-icons/ai'; import { CiCircleRemove } from 'react-icons/ci'; import { Doc, DocListCast, Field, FieldType, LinkedTo, StrListCast } from '../../fields/Doc'; import { Id } from '../../fields/FieldSymbols'; import { List } from '../../fields/List'; import { RichTextField } from '../../fields/RichTextField'; import { StrCast } from '../../fields/Types'; import { SearchUtil } from '../util/SearchUtil'; import { SnappingManager } from '../util/SnappingManager'; import { undoable } from '../util/UndoManager'; import { FieldsDropdown } from './FieldsDropdown'; import './FilterPanel.scss'; import { DocumentView } from './nodes/DocumentView'; import { Handle, Tick, TooltipRail, Track } from './nodes/SliderBox-components'; import { ObservableReactComponent } from './ObservableReactComponent'; interface HotKeyButtonProps { hotKey: Doc; selected?: Doc; } /** * Renders the buttons that correspond to each icon tag in the properties view. Allows users to change the icon, * title, and delete. */ const HotKeyIconButton: React.FC = observer(({ hotKey /*, selected */ }) => { const state = useLocalObservable(() => ({ isActive: false, isEditing: false, myHotKey: hotKey, toggleActive() { this.isActive = !this.isActive; }, deactivate() { this.isActive = false; }, startEditing() { this.isEditing = true; }, stopEditing() { this.isEditing = false; }, setHotKey(newHotKey: string) { this.myHotKey.title = newHotKey; }, })); // prettier-ignore const panelRef = useRef(null); const inputRef = useRef(null); const handleClick = () => state.toggleActive(); /** * Updates the list of hotkeys based on the users input. replaces the old title with the new one and then assigns this new * hotkey with the current icon */ const updateFromInput = undoable(() => { hotKey.title = StrCast(state.myHotKey.title); hotKey.toolTip = `Click to toggle the ${StrCast(hotKey.title)}'s group's visibility`; }, ''); /** * Deselects if the user clicks outside the button * @param event */ const handleClickOutside = (event: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(event.target as Node)) { state.deactivate(); if (state.isEditing) { state.stopEditing(); updateFromInput(); } } }; useEffect(() => { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const iconOpts = ['star', 'heart', 'bolt', 'satellite', 'palette', 'robot', 'lightbulb', 'highlighter', 'book', 'chalkboard'] as IconProp[]; /** * Panel of icons the user can choose from to represent their tag */ const iconPanel = iconOpts.map(icon => ( )); /** * Actually renders the buttons */ return (
{ e.stopPropagation(); state.startEditing(); setTimeout(() => inputRef.current?.focus(), 0); }}>
Click to customize this hotkey's icon
}> {state.isActive &&
{iconPanel}
}
{state.isEditing ? ( state.setHotKey(e.target.value)} onBlur={() => { state.stopEditing(); updateFromInput(); }} onKeyDown={e => { if (e.key === 'Enter') { state.stopEditing(); updateFromInput(); } }} className="hotkey-title-input" /> ) : (

{StrCast(hotKey.title).toUpperCase()}

)} ); }); interface filterProps { Document: Doc; addHotKey: (hotKey: string) => void; } @observer export class FilterPanel extends ObservableReactComponent { // eslint-disable-next-line no-use-before-define public static Instance: FilterPanel; constructor(props: filterProps) { super(props); makeObservable(this); FilterPanel.Instance = this; } @observable _selectedFacetHeaders = new Set(); /** * @returns the relevant doc according to the value of FilterBox._filterScope i.e. either the Current Dashboard or the Current Collection */ get Document() { return this._props.Document; } @computed get targetDocChildKey() { const targetView = DocumentView.getFirstDocumentView(this.Document); return targetView?.ComponentView?.annotationKey || (targetView?.ComponentView?.fieldKey ?? 'data'); } @computed get targetDocChildren() { return [...DocListCast(this.Document?.[this.targetDocChildKey] || Doc.ActiveDashboard?.data), ...DocListCast(this.Document[Doc.LayoutDataKey(this.Document) + '_sidebar'])]; } @computed get rangeFilters() { return StrListCast(this.Document?._childFiltersByRanges).filter((filter, i) => !(i % 3)); } /** * activeFilters( ) -- all filters that currently have a filter set on them in this document (ranges, and others) * ["#tags::bob::check", "tags::joe::check", "width", "height"] */ @computed get activeFilters() { return StrListCast(this.Document?._childFilters).concat(this.rangeFilters); } @computed get mapActiveFiltersToFacets() { const filters = new Map(); // this.targetDoc.docFilters this.activeFilters.map(filter => filters.set(filter.split(Doc.FilterSep)[1], filter.split(Doc.FilterSep)[0])); return filters; } // // activeFacetHeaders() - just the facet names, not the rest of the filter // // this wants to return all the filter facets that have an existing filter set on them in order to show them in the rendered panel // this set may overlap the selectedFilters // if the components reloads, these will still exist and be shown // // ["#tags", "width", "height"] // @computed get activeFacetHeaders() { const activeHeaders = [] as string[]; this.activeFilters.map(filter => activeHeaders.push(filter.split(Doc.FilterSep)[0])); return activeHeaders; } static gatherFieldValues(childDocs: Doc[], facetKey: string, childFilters: string[]) { const valueSet = new Set(childFilters.map(filter => filter.split(Doc.FilterSep)[1])); let rtFields = 0; let subDocs = childDocs; const gatheredDocs = [] as Doc[]; if (subDocs.length > 0) { let newarray: Doc[] = []; while (subDocs.length > 0) { newarray = []; // eslint-disable-next-line no-loop-func subDocs.forEach(t => { gatheredDocs.push(t); const facetVal = t[facetKey]; if (facetVal instanceof RichTextField || typeof facetVal === 'string') rtFields++; facetVal !== undefined && valueSet.add(Field.toString(facetVal as FieldType)); (facetVal === true || facetVal === false) && valueSet.add(Field.toString(!facetVal)); const fieldKey = Doc.LayoutDataKey(t); const annos = !Field.toString(Doc.LayoutField(t) as FieldType).includes('CollectionView'); DocListCast(t[annos ? fieldKey + '_annotations' : fieldKey]).forEach(newdoc => newarray.push(newdoc)); annos && DocListCast(t[fieldKey + '_sidebar']).forEach(newdoc => newarray.push(newdoc)); }); subDocs = newarray.filter(d => !gatheredDocs.includes(d)); } } // } // }); return { strings: Array.from(valueSet.keys()), rtFields }; } public removeFilter = (filterName: string) => { Doc.setDocFilter(this.Document, filterName, undefined, 'remove'); Doc.setDocRangeFilter(this.Document, filterName, undefined); }; // @observable _chosenFacets = new ObservableMap(); @observable _chosenFacetsCollapse = new ObservableMap(); @observable _collapseReturnKeys = [] as string[]; // this computed function gets the active filters and maps them to their headers // // activeRenderedFacetInfos() // returns renderInfo for all user selected filters and for all existing filters set on the document // Map("tags" => {"checkbox"}, // "width" => {"range", domain:[1978,1992]}) // @computed get activeRenderedFacetInfos() { return new Set( Array.from(new Set(Array.from(this._selectedFacetHeaders).concat(this.activeFacetHeaders))).map(facetHeader => { const facetValues = facetHeader.startsWith('#') ? { strings: [] } : FilterPanel.gatherFieldValues(this.targetDocChildren, facetHeader, StrListCast(this.Document.childFilters)); let nonNumbers = 0; let minVal = Number.MAX_VALUE; let maxVal = -Number.MAX_VALUE; facetValues.strings.forEach(val => { const num = val ? Number(val) : Number.NaN; if (isNaN(num)) { val && nonNumbers++; } else { minVal = Math.min(num, minVal); maxVal = Math.max(num, maxVal); } }); if (facetHeader === 'text') { return { facetHeader, renderType: 'text' }; } if (facetHeader.startsWith('#')) { return { facetHeader, renderType: 'togglebox' }; } if (facetHeader !== 'tags' && !facetHeader.startsWith('#') && nonNumbers / facetValues.strings.length < 0.1) { const extendedMinVal = minVal - Math.min(1, Math.floor(Math.abs(maxVal - minVal) * 0.1)); const extendedMaxVal = Math.max(minVal + 1, maxVal + Math.min(1, Math.ceil(Math.abs(maxVal - minVal) * 0.05))); const ranged: number[] | undefined = Doc.readDocRangeFilter(this.Document, facetHeader); // not the filter range, but the zooomed in range on the filter return { facetHeader, renderType: 'range', domain: [extendedMinVal, extendedMaxVal], range: ranged || [extendedMinVal, extendedMaxVal] }; } return { facetHeader, renderType: 'checkbox' }; }) ); } /** * user clicks on a filter facet because they want to see it. * this adds this chosen filter to a set of user selected filters called: selectedFilters * if this component reloads, then these filters will go away since they haven't been written to any Doc anywhere * * // this._selectedFacets.add(facetHeader); .. add to Set() not array */ @action facetClick = (facetHeader: string) => this._selectedFacetHeaders.add(facetHeader); @action sortingCurrentFacetValues = (facetHeader: string) => { this._collapseReturnKeys.splice(0); Array.from(this.activeRenderedFacetInfos.keys()).forEach(renderInfo => { if (renderInfo.renderType === 'range' && renderInfo.facetHeader === facetHeader && renderInfo.range) { this._collapseReturnKeys.push(...renderInfo.range.map(number => number.toFixed(2))); } }); this.facetValues(facetHeader).forEach(key => { if (this.mapActiveFiltersToFacets.get(key)) { this._collapseReturnKeys.push(key); } }); return
{this._collapseReturnKeys.join(', ')}
; }; facetValues = (facetHeader: string) => { const allCollectionDocs = new Set(); SearchUtil.foreachRecursiveDoc(this.targetDocChildren, (depth: number, doc: Doc) => allCollectionDocs.add(doc)); const set = new Set([...StrListCast(this.Document.childFilters).map(filter => filter.split(Doc.FilterSep)[1]), Doc.FilterNone, Doc.FilterAny]); allCollectionDocs.forEach(child => { const fieldVal = child[facetHeader] as FieldType; const fieldStrList = StrListCast(child[facetHeader]).filter(h => h); if (fieldStrList.length) fieldStrList.forEach(key => set.add(key)); else if (!(fieldVal instanceof List)) { // currently we have no good way of filtering based on a field that is a list 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 => isNaN(Number(val)) && nonNumbers++); return nonNumbers / facetValues.length > 0.1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2)); }; /** * Renders the newly formed hotkey icon buttons * @returns the buttons to be rendered */ hotKeyButtons = () => { const selected = DocumentView.SelectedDocs().lastElement(); const hotKeys = Doc.MyFilterHotKeys; // Selecting a button should make it so that the icon on the top filter panel becomes said icon const buttons = hotKeys.map(hotKey => ( Click to customize this hotkey's icon}> )); return buttons; }; // @observable iconPanelMap: Map = new Map(); render() { return (
{/* THE FOLLOWING CODE SHOULD BE DEVELOPER FOR BOOLEAN EXPRESSION (AND / OR) */} {/*
{' '} */}
{Array.from(this.activeRenderedFacetInfos.keys()).map( // iterate over activeFacetRenderInfos ==> renderInfo which you can renderInfo.facetHeader renderInfo => (
{renderInfo.facetHeader.charAt(0).toUpperCase() + renderInfo.facetHeader.slice(1)}
{ const collapseBoolValue = this._chosenFacetsCollapse.get(renderInfo.facetHeader); this._chosenFacetsCollapse.set(renderInfo.facetHeader, !collapseBoolValue); })}> {this._chosenFacetsCollapse.get(renderInfo.facetHeader) ? : }
{ if (renderInfo.facetHeader === 'text') { Doc.setDocFilter(this.Document, renderInfo.facetHeader, 'match', 'remove'); } else { this.facetValues(renderInfo.facetHeader).forEach((key: string) => { if (this.mapActiveFiltersToFacets.get(key)) { Doc.setDocFilter(this.Document, renderInfo.facetHeader, key, 'remove'); } }); } this._selectedFacetHeaders.delete(renderInfo.facetHeader); this._chosenFacetsCollapse.delete(renderInfo.facetHeader); if (renderInfo.domain) { Doc.setDocRangeFilter(this.Document, renderInfo.facetHeader, renderInfo.domain, 'remove'); } })}> {' '}
{this._chosenFacetsCollapse.get(renderInfo.facetHeader) ? this.sortingCurrentFacetValues(renderInfo.facetHeader) : this.displayFacetValueFilterUIs(renderInfo.renderType, renderInfo.facetHeader, renderInfo.domain, renderInfo.range)} {/* */}
) )}
{this.hotKeyButtons()}
); } private displayFacetValueFilterUIs(type: string | undefined, facetHeader: string, renderInfoDomain?: number[] | undefined, renderInfoRange?: number[]): React.ReactNode { switch (type) { case 'text': return ( filter.split(Doc.FilterSep)[0] === facetHeader) ?.split(Doc.FilterSep)[1] } style={{ color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} onBlur={undoable(e => Doc.setDocFilter(this.Document, facetHeader, e.currentTarget.value, !e.currentTarget.value ? 'remove' : 'match'), 'set text filter')} onKeyDown={e => e.key === 'Enter' && undoable(() => Doc.setDocFilter(this.Document, facetHeader, e.currentTarget.value, !e.currentTarget.value ? 'remove' : 'match'), 'set text filter')()} /> ); case 'checkbox': return this.facetValues(facetHeader).map(fval => { const facetValue = fval; return (
filter.split(Doc.FilterSep)[0] === facetHeader && filter.split(Doc.FilterSep)[1] === facetValue) ?.split(Doc.FilterSep)[2] ?? '' )} type={type} onChange={undoable(e => Doc.setDocFilter(this.Document, facetHeader, fval, e.target.checked ? 'check' : 'remove'), 'set filter')} /> {facetValue}
); }); case 'togglebox': return (
filter.split(Doc.FilterSep)[0] === 'tags' && filter.split(Doc.FilterSep)[1] === facetHeader) ?.split(Doc.FilterSep)[2] ?? '' )} type={'checkbox'} onChange={undoable(e => Doc.setDocFilter(this.Document, 'tags', facetHeader, e.target.checked ? 'check' : 'remove'), 'set filter')} /> -set-
); case 'range': { const domain = renderInfoDomain; if (domain) { return (
Doc.setDocRangeFilter(this.Document, facetHeader, values)} values={renderInfoRange!}> {railProps => } {({ handles, activeHandleID, getHandleProps }) => (
{handles.map(handle => ( // const value = i === 0 ? defaultValues[0] : defaultValues[1];
))}
)}
{({ tracks, getTrackProps }) => (
{tracks.map(({ id, source, target }) => ( ))}
)}
{({ ticks }) => (
{ticks.map(tick => ( val.toString()} /> ))}
)}
); } } break; default: // case 'range' // return domain is number[] for min and max // onChange = { ... Doc.setDocRangeFilter(this.targetDoc, facetHeader, [extendedMinVal, extendedMaxVal] ) } // // OR // return
// // domain is number[] for min and max // //