react-mapfilter
Version:
These components are designed for viewing data in Mapeo. They share a common interface:
339 lines (311 loc) • 10.5 kB
JavaScript
// @flow
import React, { useMemo, useEffect } from 'react'
import { makeStyles } from '@material-ui/core/styles'
import List from '@material-ui/core/List'
import { defineMessages, useIntl } from 'react-intl'
import isEqual from 'lodash/isEqual'
import omit from 'lodash/omit'
import DateFnsUtils from '@date-io/date-fns' // choose your lib
import { MuiPickersUtilsProvider } from '@material-ui/pickers'
import { createMemoizedStats } from '../lib/data_analysis/index'
import DiscreteFilter from './DiscreteFilter'
import DateFilter from './DateFilter'
import getStats from '../stats'
import FormattedFieldname from '../internal/FormattedFieldname'
import type {
Statistics,
Filter,
Field,
SelectOptions,
FieldStatistic
} from '../types'
import type { Observation, Preset } from 'mapeo-schema'
type Props = {
filter: Filter | null,
onChangeFilter: (filter: Filter | null) => void,
observations?: Observation[],
fields?: Field[],
presets?: Preset[]
}
const m = defineMessages({
// Button text to change which fields are shown and filterable in the filter pane
editFilters: 'Edit Filters…',
// Label for filter by date observation was created
created: 'Date of observation',
// Label for filter by date observation was modified (e.g. edited by a user)
modified: 'Modified',
// Label for filter by category (e.g. the preset)
preset: 'Category'
})
const memoizedStats = createMemoizedStats()
// Stats just for the created and modified fields. The other stat instance we
// use is for memoized stats of observation tags which does not include these
// top-level props
const getTimestampStats = (observations: Observation[]): Statistics => {
return memoizedStats(
observations.map(o => ({
$created: o.created_at,
$modified: o.timestamp
}))
)
}
const FilterPanel = ({
filter,
onChangeFilter,
observations = [],
fields = [],
presets = []
}: Props) => {
const cx = useStyles()
const { formatMessage: t } = useIntl()
let filterByField: { [fieldId: string]: Filter | null } = {}
let filterError
try {
filterByField = parseFilter(filter)
} catch (e) {
console.log(filter)
console.warn(e)
filterError = false
}
const timestampStats = useMemo(() => getTimestampStats(observations), [
observations
])
const stats = useMemo(() => getStats(observations), [observations])
const handleChangeFilter = fieldId => filter => {
const newFilterByField =
filter == null
? omit(filterByField, fieldId)
: {
...filterByField,
[fieldId]: filter
}
const newFilter = compileFilter(newFilterByField)
onChangeFilter(newFilter)
}
// Filters are shown for: date the observation was created, date modified
// (edited), the category (preset), and any fields defined in the preset which
// are select_one, date or date_time. Currently we don't show presets for
// free-form text fields (which could have too many values, or too many ways
// of spelling things to makes sense).
const filterFields = useMemo(() => {
const $createdId = JSON.stringify(['$created'])
const $presetId = JSON.stringify(['$preset'])
const filterFields: { [fieldId: string]: Field } = {
[$createdId]: {
id: $createdId,
key: ['$created'],
label: t(m.created),
type: 'datetime',
min_value:
timestampStats[$createdId] && timestampStats[$createdId].datetime.min,
max_value:
timestampStats[$createdId] && timestampStats[$createdId].datetime.max
}
}
const presetOptions = presets
// .filter(
// preset =>
// stats['categoryId'] &&
// stats['categoryId'].string.values.has(preset.id)
// )
.sort(presetCompare)
.map(preset => ({
value: preset.id,
label: preset.name
}))
filterFields[$presetId] = {
id: $presetId,
key: ['$preset'],
label: t(m.preset),
type: 'select_one',
options: presetOptions
}
// Enable filtering by any select_one, date or date_time field that is
// defined in the preset, but add in options (for select_one) and min/max
// (for dates) from the actual data, since the data could include values
// outside the range defined in the preset
fields.forEach(field => {
const fieldId = JSON.stringify(field.key)
const fieldStats = stats[fieldId]
if (field.type === 'select_one') {
// $FlowFixMe
filterFields[fieldId] = {
...field,
options: combineOptionsWithStats(field.options, fieldStats)
}
} else if (field.type === 'date' || field.type === 'datetime') {
// $FlowFixMe
filterFields[fieldId] = {
...field,
min_value: fieldStats && fieldStats[field.type].min,
max_value: fieldStats && fieldStats[field.type].max
}
}
})
return filterFields
}, [t, timestampStats, presets, fields, stats])
useEffect(() => {
if (!filterError) return
onChangeFilter(null)
}, [filterError, onChangeFilter])
if (filterError) return null
return (
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<List className={cx.list}>
{Object.keys(filterFields)
.map(id => {
const field = filterFields[id]
if (!field) return
const fieldId = JSON.stringify(field.key)
switch (field.type) {
case 'select_one':
return (
<DiscreteFilter
key={field.id}
fieldKey={field.key}
label={<FormattedFieldname field={field} />}
filter={filterByField[fieldId]}
options={field.options}
onChangeFilter={handleChangeFilter(fieldId)}
/>
)
case 'date':
case 'datetime':
return (
<DateFilter
key={field.id}
fieldKey={field.key}
label={<FormattedFieldname field={field} />}
filter={filterByField[fieldId]}
min={field.min_value || '2001-01-01'}
max={field.max_value || new Date().toISOString()}
onChangeFilter={handleChangeFilter(fieldId)}
/>
)
}
})
.filter(Boolean)}
</List>
</MuiPickersUtilsProvider>
)
}
export default FilterPanel
const comparisonOps = ['<=', '>=']
const membershipOps = ['in', '!in']
// Parse a filter and return filter expressions by field id
function parseFilter(
filter: Filter | null
): { [fieldId: string]: Filter | null } {
const filterByField = {}
if (filter == null) return filterByField
if (!isValidFilter(filter)) throw new Error('Unsupported filter expression')
filter.slice(1).forEach(subFilter => {
if (!Array.isArray(subFilter)) return
if (subFilter[0] === 'all') {
const fieldId = JSON.stringify(subFilter[1][1])
filterByField[fieldId] = subFilter
} else {
const fieldId = JSON.stringify(subFilter[1])
filterByField[fieldId] = subFilter
}
})
return filterByField
}
function compileFilter(filterByField: {
[fieldId: string]: Filter
}): Filter | null {
const filter = ['all']
Object.keys(filterByField).forEach(fieldId =>
filter.push(filterByField[fieldId])
)
if (filter.length === 1) return null
// $FlowFixMe
return filter
}
// Currently we only support a very specific filter structure
function isValidFilter(filter): boolean {
if (!Array.isArray(filter)) return false
if (filter[0] !== 'all') return false
return filter.slice(1).every(subFilter => {
if (!Array.isArray(subFilter)) return false
if (subFilter[0] === 'all') {
if (subFilter.length < 2) return false
let key
return subFilter.slice(1).every(subFilter => {
if (!Array.isArray(subFilter)) return false
if (!comparisonOps.includes(subFilter[0])) return false
if (subFilter.length !== 3) return false
if (key && !isEqual(key, subFilter[1])) return false
key = subFilter[1]
return true
})
}
if (!membershipOps.includes(subFilter[0])) return false
if (subFilter.length < 3) return false
return true
})
}
// Takes a list of options for a select_one field defined in a preset, and adds
// in any values that exist in the data that are not already listed in the field
// definition. Mapeo Desktop (currently) allows you to add new option to a
// select_one field which aren't defined in the preset, and this allows you to
// filter by these new values
function combineOptionsWithStats(
fieldOptions: SelectOptions,
fieldStats: FieldStatistic
): SelectOptions {
if (!fieldStats) return fieldOptions
const optionsWithStats = [...fieldOptions]
Object.keys(fieldStats).forEach(valueType => {
if (!fieldStats[valueType] || !fieldStats[valueType].values) return
const values = fieldStats[valueType].values
if (!(values instanceof Map)) return
for (const value of values.keys()) {
if (
optionsWithStats.find(
o =>
o === value ||
(typeof o === 'object' && o !== null && o.value === value)
)
)
continue
optionsWithStats.push(value)
}
})
return optionsWithStats
}
const useStyles = makeStyles(theme => ({
list: {
paddingTop: 0,
paddingBottom: 0,
overflowY: 'scroll'
},
settingsItem: {
paddingTop: 8,
paddingBottom: 8
},
listIcon: {
minWidth: 40
}
}))
// Sort presets by sort property and then by name, then filter only point presets
function presetCompare(a, b) {
if (typeof a.sort !== 'undefined' && typeof b.sort !== 'undefined') {
// If sort value is the same, then sort by name
if (a.sort === b.sort) return compareStrings(a.name, b.name)
// Lower sort numbers come before higher numbers
else return a.sort - b.sort
} else if (typeof a.sort !== 'undefined') {
// If a has a sort field but b doesn't, a comes first
return -1
} else if (typeof b.sort !== 'undefined') {
// if b has a sort field but a doesn't, b comes first
return 1
} else {
// if neither have sort defined, compare by name
return compareStrings(a.name, b.name)
}
}
function compareStrings(a = '', b = '') {
return a.toLowerCase().localeCompare(b.toLowerCase())
}