From b06f3dfca553c55f8df65b912cef8fb9edb89f68 Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 18:12:31 -0500 Subject: [PATCH 1/8] constants - introduce columnTypes, columnTypeInfo --- src/constants/columns.js | 49 +++++++++++++++++++++++++++++++++ src/util/string-manipulation.js | 1 + 2 files changed, 50 insertions(+) create mode 100644 src/constants/columns.js diff --git a/src/constants/columns.js b/src/constants/columns.js new file mode 100644 index 000000000..deee49c65 --- /dev/null +++ b/src/constants/columns.js @@ -0,0 +1,49 @@ +import { Icons } from '@eqworks/lumen-labs' +import { priceStringToNumeric } from '../util/numeric' +import { isString } from '../util/string-manipulation' + + +export const columnTypes = { + NUMERIC: 'Numeric', + DATE: 'Date', + STRING: 'String', + PRICE: 'Price', +} + +export const columnTypeInfo = { + // 'primitives': + [columnTypes.NUMERIC]: { + Icon: Icons.Hash, + validate: (v, name) => { + const res = !isNaN(v) + return name === undefined + ? res + : res && !name.endsWith('_id') + }, + }, + [columnTypes.STRING]: { + Icon: Icons.Edit, + validate: isString, + }, + // subtypes: + [columnTypes.PRICE]: { + parentTypes: [ + columnTypes.NUMERIC, + ], + Icon: Icons.Dollar, + validate: (v) => isString(v) && !isNaN(priceStringToNumeric(v)), + }, + [columnTypes.DATE]: { + parentTypes: [ + columnTypes.STRING, + ], + Icon: Icons.Table, // looks like a calendar + validate: (v) => { + if (!isString(v)) { + return false + } + const sample = new Date(v) + return sample instanceof Date && !isNaN(sample) + }, + }, +} diff --git a/src/util/string-manipulation.js b/src/util/string-manipulation.js index 771c0f899..6079b7a94 100644 --- a/src/util/string-manipulation.js +++ b/src/util/string-manipulation.js @@ -1,2 +1,3 @@ export const cleanUp = s => s.replace(/_/g, ' ').replace(/./, v => v.toUpperCase()) export const getLongestString = arr => arr.reduce((a, b) => (a.length > b.length ? a : b)) +export const isString = v => typeof v === 'string' || v instanceof String From 070cd74cc5ad546d4f96fd2c69a55b3ce941c591 Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 18:12:42 -0500 Subject: [PATCH 2/8] util - introduce columnInference --- src/util/columns.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/util/columns.js diff --git a/src/util/columns.js b/src/util/columns.js new file mode 100644 index 000000000..ccee46edf --- /dev/null +++ b/src/util/columns.js @@ -0,0 +1,29 @@ +import { columnTypes, columnTypeInfo } from '../constants/columns' + + +export const columnInference = (columnData = [], columnName) => { + const [v] = columnData // TODO infer based on multiple samples + let checked = new Set() + let matches = [] + Object.entries(columnTypeInfo) + .sort(([, a], [, b]) => Boolean(b.parentTypes?.length) - Boolean(a.parentTypes?.length)) + .forEach(([k, { validate, parentTypes = [] }]) => { + if (!(k in checked)) { + checked.add(k) + if (validate(v, columnName)) { + matches.push(k) + parentTypes.forEach(t => { + matches.push(t) + checked.add(t) + }) + } + } + }) + const category = matches.shift() || columnTypes.STRING + return { + category, + parentCategories: matches, + isNumeric: category === columnTypes.NUMERIC || matches.includes(columnTypes.NUMERIC), + Icon: columnTypeInfo[category].Icon, + } +} From 882803b94c9db9f105fcd1ba1325120e7fd08e8a Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 18:16:35 -0500 Subject: [PATCH 3/8] refactor - use new column constants and utils --- src/controls/shared/domain-controls.js | 4 ++-- src/hooks/use-transformed-data.js | 7 ++++--- src/store/model.js | 22 +++++++++------------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/controls/shared/domain-controls.js b/src/controls/shared/domain-controls.js index af804d451..779e1b81f 100644 --- a/src/controls/shared/domain-controls.js +++ b/src/controls/shared/domain-controls.js @@ -48,11 +48,11 @@ const DomainControls = () => { }, [group, groupingOptional, update]) const renderCategory = () => { - const { category } = columnsAnalysis[group ? groupKey : indexKey] || {} + const { category, isNumeric } = columnsAnalysis[group ? groupKey : indexKey] || {} return (category && {category} diff --git a/src/hooks/use-transformed-data.js b/src/hooks/use-transformed-data.js index cde0739a9..1efc4c6eb 100644 --- a/src/hooks/use-transformed-data.js +++ b/src/hooks/use-transformed-data.js @@ -6,6 +6,7 @@ import { COORD_KEYS, MAP_LAYER_GEO_KEYS, GEO_KEY_TYPES } from '../constants/map' import types from '../constants/types' import { priceStringToNumeric, roundToTwoDecimals } from '../util/numeric' import { dateAggregations, dateSort } from '../constants/time' +import { columnTypes } from '../constants/columns' const useTransformedData = () => { @@ -36,7 +37,7 @@ const useTransformedData = () => { // convert prices to numeric values const normalizedPriceData = useMemo(() => { - const priceColumns = columns.map(({ name }) => name).filter(c => columnsAnalysis[c]?.category === 'String' && c.includes('price')) + const priceColumns = columns.map(({ name }) => name).filter(c => columnsAnalysis[c]?.category === columnTypes.PRICE) return priceColumns.length ? rows.map(r => Object.entries(r) @@ -190,9 +191,9 @@ const useTransformedData = () => { // add coordinates for map widget data if (MAP_LAYER_GEO_KEYS.scatterplot.includes(mapGroupKey)) { const lat = columns.find(({ name, category }) => - COORD_KEYS.latitude.includes(name) && category === 'Numeric')?.name + COORD_KEYS.latitude.includes(name) && category === columnTypes.NUMERIC)?.name const lon = columns.find(({ name, category }) => - COORD_KEYS.longitude.includes(name) && category === 'Numeric')?.name + COORD_KEYS.longitude.includes(name) && category === columnTypes.NUMERIC)?.name return aggregatedData.map((d) => { if (lat && lon && MAP_LAYER_GEO_KEYS.scatterplot.includes(mapGroupKey)) { if (d[lat] && d[lon]) { diff --git a/src/store/model.js b/src/store/model.js index 0d2beea0f..d56a65996 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -12,6 +12,8 @@ import { getKeyFormatFunction } from '../util/data-format-functions' import { deepMerge } from './util' import { dateAggregations } from '../constants/time' import { priceStringToNumeric } from '../util/numeric' +import { columnTypes } from '../constants/columns' +import { columnInference } from '../util/columns' const MAX_UNDO_STEPS = 10 @@ -161,19 +163,13 @@ export default { columns, rows ) => ( - columns.reduce((acc, { name, category }) => { + columns.reduce((acc, { name }) => { const data = rows.map(r => r[name]) - const sampleDate = category === 'Date' ? new Date(data[0]) : null - const isValidPrice = category === 'String' && !isNaN(priceStringToNumeric(data[0])) - const isNumeric = (category === 'Numeric' || isValidPrice) && !name.endsWith('_id') - acc[name] = { - category, - isValidDate: sampleDate instanceof Date && !isNaN(sampleDate), - isValidPrice, - isNumeric, - } - if (isNumeric) { - const numericData = isValidPrice ? data.map(priceStringToNumeric) : data + acc[name] = columnInference(data, name) + if (acc[name].isNumeric) { + const numericData = acc.category === columnTypes.PRICE + ? data.map(priceStringToNumeric) + : data acc[name].min = Math.min.apply(null, numericData) acc[name].max = Math.max.apply(null, numericData) } @@ -256,7 +252,7 @@ export default { (state) => state.columnsAnalysis, ], (domain, columnsAnalysis) => ( - columnsAnalysis[domain.value]?.isValidDate + columnsAnalysis[domain.value]?.category === columnTypes.DATE ) ), From 91ee68768c14569265796a244ff843701769b07d Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 19:29:59 -0500 Subject: [PATCH 4/8] CustomSelect - accommodate icons --- src/components/custom-select.js | 38 ++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/components/custom-select.js b/src/components/custom-select.js index c164d599f..d1b83c9bb 100644 --- a/src/components/custom-select.js +++ b/src/components/custom-select.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { createElement } from 'react' import PropTypes from 'prop-types' import { DropdownSelect, Icons } from '@eqworks/lumen-labs' @@ -14,8 +14,9 @@ export const DROPDOWN_SELECT_CLASSES = { } const { root, ...baseClasses } = DROPDOWN_SELECT_CLASSES -const CustomSelect = ({ classes, onClear, fullWidth, ...props }) => ( - ( + ( overflow='vertical' endIcon={} onDelete={onClear} + multiSelect={multiSelect} + data={ + icons + ? [{ + items: data.map((d, i) => ({ + title: d, + startIcon: createElement(icons[i], { size: 'sm' }), + })), + }] + : data + } + value={ + icons + ? multiSelect + ? (value || []).map(title => ({ title })) || [] + : { title: value } + : value + } + onSelect={ + icons + ? v => ( + onSelect( + multiSelect + ? Object.values(v).map(({ title }) => title).filter(Boolean) + : v.title + ) + ) + : onSelect + } {...props} /> ) @@ -37,6 +67,7 @@ CustomSelect.propTypes = { onSelect: PropTypes.func.isRequired, onClear: PropTypes.func, fullWidth: PropTypes.bool, + icons: PropTypes.arrayOf(PropTypes.elementType), } CustomSelect.defaultProps = { classes: {}, @@ -45,6 +76,7 @@ CustomSelect.defaultProps = { value: '', onClear: () => { }, fullWidth: false, + icons: null, } export default CustomSelect From ce9a14e671d532ab1c1936d856357510ccceca9e Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 22:46:22 -0500 Subject: [PATCH 5/8] PluralLinkedSelect - accept valueIcons --- src/components/linked-select.js | 4 ++++ src/components/plural-linked-select.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/components/linked-select.js b/src/components/linked-select.js index 61b0399b2..76b643182 100644 --- a/src/components/linked-select.js +++ b/src/components/linked-select.js @@ -33,6 +33,7 @@ const LinkedSelect = ({ disableSubMessage, customRender, customRenderSub, + icons, }) => { const [choice, setChoice] = useState(init) const [subChoice, setSubChoice] = useState(subInit) @@ -77,6 +78,7 @@ const LinkedSelect = ({ onSelect={setChoice} onClear={() => setChoice('')} placeholder={placeholders[0]} + {...(icons && { icons })} /> } @@ -132,6 +134,7 @@ LinkedSelect.propTypes = { disableSubMessage: PropTypes.string, customRender: PropTypes.func, customRenderSub: PropTypes.func, + icons: PropTypes.array, } LinkedSelect.defaultProps = { @@ -145,6 +148,7 @@ LinkedSelect.defaultProps = { disableSubMessage: '', customRender: null, customRenderSub: null, + icons: null, } export default LinkedSelect diff --git a/src/components/plural-linked-select.js b/src/components/plural-linked-select.js index 5dae7208b..4773d426e 100644 --- a/src/components/plural-linked-select.js +++ b/src/components/plural-linked-select.js @@ -44,6 +44,7 @@ const PluralLinkedSelect = ({ headerIcons, titles, values, + valueIcons, primaryKey, secondaryKey, data, @@ -65,6 +66,7 @@ const PluralLinkedSelect = ({ className={`${i > 0 ? 'mt-2' : ''}`} callback={([_k, _v]) => callback(i, { [primaryKey]: _k, [secondaryKey]: _v })} data={data.filter(d => primary === d || !values.map(v => v[primaryKey]).includes(d))} + {...(valueIcons && { icons: valueIcons })} init={primary} subData={subData} subInit={values[i]?.[secondaryKey]} @@ -139,6 +141,7 @@ PluralLinkedSelect.propTypes = { callback: PropTypes.func.isRequired, deleteCallback: PropTypes.func, values: PropTypes.arrayOf(PropTypes.object).isRequired, + valueIcons: PropTypes.array, disableSubs: PropTypes.bool, disableSubMessage: PropTypes.string, addMessage: PropTypes.string, @@ -151,6 +154,7 @@ PluralLinkedSelect.defaultProps = { titles: [], headerIcons: [], deleteCallback: () => {}, + valueIcons: null, disableSubs: false, disableSubMessage: '', addMessage: 'Add', From 7feb8e705a9dd86cf10ddeb263cf8b191516158a Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 22:52:53 -0500 Subject: [PATCH 6/8] ui - show column inference icons throughout controls --- src/controls/editor-mode/filters.js | 15 ++++++----- src/controls/shared/domain-controls.js | 25 ++++++++----------- src/controls/shared/map-domain-controls.js | 2 ++ .../map-value-controls/map-linked-select.js | 3 +++ src/controls/shared/value-controls.js | 22 ++++++++-------- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/controls/editor-mode/filters.js b/src/controls/editor-mode/filters.js index 957a71360..882be954e 100644 --- a/src/controls/editor-mode/filters.js +++ b/src/controls/editor-mode/filters.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { DateRange, Icons } from '@eqworks/lumen-labs' @@ -20,11 +20,16 @@ const Filters = () => { const groups = useStoreState((state) => state.groups) const groupFilter = useStoreState((state) => state.groupFilter) const filters = useStoreState((state) => state.filters) - const columns = useStoreState((state) => state.columns) const columnsAnalysis = useStoreState((state) => state.columnsAnalysis) const domain = useStoreState((state) => state.domain) const domainIsDate = useStoreState((state) => state.domainIsDate) + const filterData = useMemo(() => ( + Object.fromEntries(Object.entries(columnsAnalysis) + .filter(([, { min, max, isNumeric }]) => isNumeric && min !== max) + .map(([c, { Icon }]) => [c, { Icon }])) + ), [columnsAnalysis]) + return ( resetValue({ filters, groupFilter })} @@ -68,10 +73,8 @@ const Filters = () => { values={filters} primaryKey='key' secondaryKey='filter' - data={columns.map(({ name }) => name).filter(c => { - const { min, max, isNumeric } = columnsAnalysis[c] || {} - return isNumeric && min !== max - })} + valueIcons={Object.values(filterData).map(({ Icon }) => Icon)} + data={Object.keys(filterData)} subData={[]} callback={(i, { key }) => { if (i === filters.length) { diff --git a/src/controls/shared/domain-controls.js b/src/controls/shared/domain-controls.js index 779e1b81f..f966739d2 100644 --- a/src/controls/shared/domain-controls.js +++ b/src/controls/shared/domain-controls.js @@ -14,7 +14,6 @@ const DomainControls = () => { const update = useStoreActions(actions => actions.update) const userUpdate = useStoreActions(actions => actions.userUpdate) const resetValue = useStoreActions(actions => actions.resetValue) - const columns = useStoreState((state) => state.columns) const type = useStoreState((state) => state.type) const group = useStoreState((state) => state.group) const groupKey = useStoreState((state) => state.groupKey) @@ -28,18 +27,15 @@ const DomainControls = () => { // local state const groupingOptional = useMemo(() => typeInfo[type]?.groupingOptional, [type]) - const eligibleGroupKeyValues = useMemo(() => ( - columns.map(({ name }) => name) - .filter(c => !columnsAnalysis[c]?.isNumeric) - ), [columns, columnsAnalysis]) - const eligibleDomainValues = useMemo(() => ( - columns.map(({ name }) => name) - .filter(c => - (groupingOptional || eligibleGroupKeyValues.includes(c)) - && !(valueKeys.map(({ key }) => key).includes(c)) - ) - ), [columns, eligibleGroupKeyValues, groupingOptional, valueKeys]) + Object.fromEntries( + Object.entries(columnsAnalysis) + .filter(([c, { isNumeric }]) => + (groupingOptional || !isNumeric) + && !(valueKeys.map(({ key }) => key).includes(c))) + .map(([c, { Icon }]) => [c, { Icon }]) + ) + ), [columnsAnalysis, groupingOptional, valueKeys]) useEffect(() => { if (!group && !groupingOptional) { @@ -67,10 +63,11 @@ const DomainControls = () => { renderRow('Column', Icon)} value={domain.value} onSelect={val => { - const willGroup = eligibleGroupKeyValues.includes(val) && !groupingOptional + const willGroup = !columnsAnalysis[val]?.isNumeric && !groupingOptional userUpdate({ group: willGroup, ...( diff --git a/src/controls/shared/map-domain-controls.js b/src/controls/shared/map-domain-controls.js index 18cae1328..4e7d6b21c 100644 --- a/src/controls/shared/map-domain-controls.js +++ b/src/controls/shared/map-domain-controls.js @@ -5,6 +5,7 @@ import CustomSelect from '../../components/custom-select' import WidgetControlCard from '../shared/components/widget-control-card' import { renderRow, renderSection } from './util' import { MAP_LAYER_VALUE_VIS, MAP_LAYER_GEO_KEYS } from '../../constants/map' +import { Icons } from '@eqworks/lumen-labs' const MapDomainControls = () => { @@ -41,6 +42,7 @@ const MapDomainControls = () => { Icons.AddPin)} value={domain.value} onSelect={val => { // update groupKey with mapGroupKey value to have it available if we switch to a chart widget type diff --git a/src/controls/shared/map-value-controls/map-linked-select.js b/src/controls/shared/map-value-controls/map-linked-select.js index 82906f31f..3dc8c4eaa 100644 --- a/src/controls/shared/map-value-controls/map-linked-select.js +++ b/src/controls/shared/map-value-controls/map-linked-select.js @@ -5,6 +5,7 @@ import { Tooltip, Icons, getTailwindConfigColor, makeStyles } from '@eqworks/lum import PluralLinkedSelect from '../../../components/plural-linked-select' import types from '../../../constants/type-info' +import { useStoreState } from '../../../store' const classes = makeStyles({ @@ -38,6 +39,7 @@ const MapLinkedSelect = ({ disableSubMessage, callback, }) => { + const columnsAnalysis = useStoreState((state) => state.columnsAnalysis) return ( categories.map((mapVis, i) => { const match = values.findIndex(v => v.mapVis === mapVis) @@ -69,6 +71,7 @@ const MapLinkedSelect = ({ ]} titles={titles} values={values[match] ? [values[match]] : []} + {...(values[match] && { valueIcons: [columnsAnalysis[values[match][PRIMARY_KEY]]?.Icon] })} callback={(_, v) => callback( match, { diff --git a/src/controls/shared/value-controls.js b/src/controls/shared/value-controls.js index 3f960d9c2..3b7529241 100644 --- a/src/controls/shared/value-controls.js +++ b/src/controls/shared/value-controls.js @@ -5,11 +5,11 @@ import { Icons } from '@eqworks/lumen-labs' import modes from '../../constants/modes' import aggFunctions from '../../util/agg-functions' import { useStoreState, useStoreActions } from '../../store' -import CustomSelect from '../../components/custom-select' import PluralLinkedSelect from '../../components/plural-linked-select' import WidgetControlCard from '../shared/components/widget-control-card' import { renderRow, renderSection } from './util' import MutedBarrier from './muted-barrier' +import CustomSelect from '../../components/custom-select' const ValueControls = () => { @@ -19,7 +19,6 @@ const ValueControls = () => { // common state const type = useStoreState((state) => state.type) - const columns = useStoreState((state) => state.columns) const group = useStoreState((state) => state.group) const domain = useStoreState((state) => state.domain) const valueKeys = useStoreState((state) => state.valueKeys) @@ -27,12 +26,11 @@ const ValueControls = () => { const dataSourceLoading = useStoreState((state) => state.ui.dataSourceLoading) const columnsAnalysis = useStoreState((state) => state.columnsAnalysis) - const eligibleColumns = useMemo(() => columns - .map(({ name }) => name) - .filter(c => ( - c !== domain.value - && columnsAnalysis[c]?.isNumeric - )), [columns, columnsAnalysis, domain.value]) + const eligibleColumns = useMemo(() => + Object.fromEntries( + Object.entries(columnsAnalysis) + .filter(([c, { isNumeric }]) => c !== domain.value && isNumeric) + ), [columnsAnalysis, domain.value]) // UI state const mode = useStoreState((state) => state.ui.mode) @@ -46,9 +44,10 @@ const ValueControls = () => { staticQuantity={mode === modes.QL ? 3 : undefined} titles={['Column', 'Operation']} values={valueKeys} + valueIcons={Object.values(eligibleColumns).map(({ Icon }) => Icon)} primaryKey='key' secondaryKey='agg' - data={eligibleColumns} + data={Object.keys(eligibleColumns)} subData={Object.keys(aggFunctions)} disableSubs={!dataHasVariance} disableSubMessage="doesn't require aggregation." @@ -71,7 +70,7 @@ const ValueControls = () => { return ( { fullWidth multiSelect value={valueKeys.map(({ key }) => key)} - data={eligibleColumns} + data={Object.keys(eligibleColumns)} onSelect={(val) => userUpdate({ valueKeys: val.map(v => ({ key: v })) })} + icons={Object.values(eligibleColumns).map(({ Icon }) => Icon)} /> ) ) From 22ab1df29816b4f75d3be93a62a749a992335304 Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Tue, 15 Feb 2022 08:45:01 -0500 Subject: [PATCH 7/8] data - generalize data normalization step --- src/constants/columns.js | 1 + src/hooks/use-transformed-data.js | 30 ++++++++++++++---------------- src/store/model.js | 5 +---- src/util/columns.js | 4 +++- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/constants/columns.js b/src/constants/columns.js index deee49c65..c035ca4c3 100644 --- a/src/constants/columns.js +++ b/src/constants/columns.js @@ -32,6 +32,7 @@ export const columnTypeInfo = { ], Icon: Icons.Dollar, validate: (v) => isString(v) && !isNaN(priceStringToNumeric(v)), + normalize: (c) => c.map(priceStringToNumeric), }, [columnTypes.DATE]: { parentTypes: [ diff --git a/src/hooks/use-transformed-data.js b/src/hooks/use-transformed-data.js index 1efc4c6eb..fad5ce621 100644 --- a/src/hooks/use-transformed-data.js +++ b/src/hooks/use-transformed-data.js @@ -4,7 +4,7 @@ import { useStoreActions, useStoreState } from '../store' import aggFunctions from '../util/agg-functions' import { COORD_KEYS, MAP_LAYER_GEO_KEYS, GEO_KEY_TYPES } from '../constants/map' import types from '../constants/types' -import { priceStringToNumeric, roundToTwoDecimals } from '../util/numeric' +import { roundToTwoDecimals } from '../util/numeric' import { dateAggregations, dateSort } from '../constants/time' import { columnTypes } from '../constants/columns' @@ -35,24 +35,22 @@ const useTransformedData = () => { const finalGroupKey = useMemo(() => type === types.MAP ? mapGroupKey : groupKey, [type, mapGroupKey, groupKey]) - // convert prices to numeric values - const normalizedPriceData = useMemo(() => { - const priceColumns = columns.map(({ name }) => name).filter(c => columnsAnalysis[c]?.category === columnTypes.PRICE) - return priceColumns.length - ? rows.map(r => - Object.entries(r) - .reduce((acc, [k, v]) => { - if (priceColumns.includes(k)) { - acc[k] = priceStringToNumeric(v) || v - } - return acc - }, { ...r })) + // normalize data using columnsAnalysis (ex. price to numeric) + const normalizedData = useMemo(() => { + const normalizedColumns = Object.entries(columnsAnalysis).filter(([, { normalized }]) => normalized) + return normalizedColumns.length + ? normalizedColumns.reduce((acc, [c, { normalized }]) => { + acc.forEach((r, i) => { + r[c] = normalized[i] + }) + return acc + }, [...rows]) : rows - }, [columns, columnsAnalysis, rows]) + }, [columnsAnalysis, rows]) // truncate the data when the filters change const truncatedData = useMemo(() => ( - normalizedPriceData.filter(obj => { + normalizedData.filter(obj => { for (const { key, filter: [min, max] } of filters.filter(({ filter }) => Boolean(filter))) { if (obj[key] < min || obj[key] > max) { return false @@ -60,7 +58,7 @@ const useTransformedData = () => { } return true }) - ), [normalizedPriceData, filters]) + ), [normalizedData, filters]) const newGroupKey = useMemo(() => ( groupFSAByPC diff --git a/src/store/model.js b/src/store/model.js index d56a65996..95ecaaea3 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -11,7 +11,6 @@ import { MAP_GEO_KEYS, GEO_KEY_TYPES } from '../constants/map' import { getKeyFormatFunction } from '../util/data-format-functions' import { deepMerge } from './util' import { dateAggregations } from '../constants/time' -import { priceStringToNumeric } from '../util/numeric' import { columnTypes } from '../constants/columns' import { columnInference } from '../util/columns' @@ -167,9 +166,7 @@ export default { const data = rows.map(r => r[name]) acc[name] = columnInference(data, name) if (acc[name].isNumeric) { - const numericData = acc.category === columnTypes.PRICE - ? data.map(priceStringToNumeric) - : data + const numericData = acc[name].normalized || data acc[name].min = Math.min.apply(null, numericData) acc[name].max = Math.max.apply(null, numericData) } diff --git a/src/util/columns.js b/src/util/columns.js index ccee46edf..52d5541db 100644 --- a/src/util/columns.js +++ b/src/util/columns.js @@ -20,10 +20,12 @@ export const columnInference = (columnData = [], columnName) => { } }) const category = matches.shift() || columnTypes.STRING + const { Icon, normalize } = columnTypeInfo[category] return { category, + Icon, parentCategories: matches, isNumeric: category === columnTypes.NUMERIC || matches.includes(columnTypes.NUMERIC), - Icon: columnTypeInfo[category].Icon, + ...(normalize && { normalized: normalize(columnData) }), } } From bec4fb9d2f3a2c715b41cf7a640e5e83d26b1e9b Mon Sep 17 00:00:00 2001 From: Kyle Grimsrud-Manz Date: Mon, 14 Feb 2022 15:06:15 -0500 Subject: [PATCH 8/8] data - introduce sorting by values --- src/controls/shared/value-controls.js | 52 ++++++++++++++++++++------- src/hooks/use-transformed-data.js | 9 +++-- src/store/model.js | 1 + 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/controls/shared/value-controls.js b/src/controls/shared/value-controls.js index 3b7529241..b182d0d4a 100644 --- a/src/controls/shared/value-controls.js +++ b/src/controls/shared/value-controls.js @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo } from 'react' import { Icons } from '@eqworks/lumen-labs' @@ -14,6 +14,7 @@ import CustomSelect from '../../components/custom-select' const ValueControls = () => { // common actions + const update = useStoreActions(actions => actions.update) const userUpdate = useStoreActions(actions => actions.userUpdate) const resetValue = useStoreActions(actions => actions.resetValue) @@ -25,6 +26,8 @@ const ValueControls = () => { const dataHasVariance = useStoreState((state) => state.dataHasVariance) const dataSourceLoading = useStoreState((state) => state.ui.dataSourceLoading) const columnsAnalysis = useStoreState((state) => state.columnsAnalysis) + const sortBy = useStoreState((state) => state.sortBy) + const renderableValueKeys = useStoreState((state) => state.renderableValueKeys) const eligibleColumns = useMemo(() => Object.fromEntries( @@ -34,6 +37,14 @@ const ValueControls = () => { // UI state const mode = useStoreState((state) => state.ui.mode) + const allowSortBy = useMemo(() => !group && !columnsAnalysis[domain.value]?.isNumeric && renderableValueKeys.length > 1, [columnsAnalysis, domain.value, group, renderableValueKeys.length]) + + // set a default sortBy value if appropriate + useEffect(() => { + if (!sortBy) { + update({ sortBy: renderableValueKeys[0]?.key }) + } + }, [allowSortBy, renderableValueKeys, sortBy, update]) const renderGroupedValueKeysSelect = { > { renderSection(null, - group - ? renderGroupedValueKeysSelect - : renderRow('Columns', - key)} - data={Object.keys(eligibleColumns)} - onSelect={(val) => userUpdate({ valueKeys: val.map(v => ({ key: v })) })} - icons={Object.values(eligibleColumns).map(({ Icon }) => Icon)} - /> - ) + <> + { + group + ? renderGroupedValueKeysSelect + : renderRow('Columns', + key)} + data={Object.keys(eligibleColumns)} + onSelect={(val) => userUpdate({ valueKeys: val.map(v => ({ key: v })) })} + icons={Object.values(eligibleColumns).map(({ Icon }) => Icon)} + /> + ) + } + { + allowSortBy && + renderRow('Sort by', + key)} + onSelect={sortBy => userUpdate({ sortBy })} + /> + ) + } + ) } diff --git a/src/hooks/use-transformed-data.js b/src/hooks/use-transformed-data.js index fad5ce621..16d868cee 100644 --- a/src/hooks/use-transformed-data.js +++ b/src/hooks/use-transformed-data.js @@ -32,6 +32,7 @@ const useTransformedData = () => { const columnsAnalysis = useStoreState((state) => state.columnsAnalysis) const domainIsDate = useStoreState((state) => state.domainIsDate) const dateAggregation = useStoreState((state) => state.dateAggregation) + const sortBy = useStoreState((state) => state.sortBy) const finalGroupKey = useMemo(() => type === types.MAP ? mapGroupKey : groupKey, [type, mapGroupKey, groupKey]) @@ -218,7 +219,11 @@ const useTransformedData = () => { if (group) return null const sortFn = domainIsDate ? (a, b) => dateSort(a[formattedColumnNames[indexKey]], b[formattedColumnNames[indexKey]]) - : (a, b) => (a[formattedColumnNames[indexKey]] - b[formattedColumnNames[indexKey]]) + : (a, b) => ( + !columnsAnalysis[indexKey]?.isNumeric && sortBy + ? a[formattedColumnNames[sortBy]] - b[formattedColumnNames[sortBy]] + : a[formattedColumnNames[indexKey]] - b[formattedColumnNames[indexKey]] + ) return ( truncatedData .map(d => Object.fromEntries( @@ -229,7 +234,7 @@ const useTransformedData = () => { )) .sort(sortFn) ) - }, [domainIsDate, formattedColumnNames, group, indexKey, truncatedData]) + }, [columnsAnalysis, domainIsDate, formattedColumnNames, group, indexKey, sortBy, truncatedData]) // memoize the final data processing according to whether grouping is enabled const finalData = useMemo(() => { diff --git a/src/store/model.js b/src/store/model.js index 95ecaaea3..5e0dd7295 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -32,6 +32,7 @@ const stateDefaults = [ { key: 'valueKeys', defaultValue: [], resettable: true }, { key: 'mapValueKeys', defaultValue: [], resettable: true }, { key: 'uniqueOptions', defaultValue: {}, resettable: true }, + { key: 'sortBy', defaultValue: null, resettable: true }, { key: 'genericOptions', defaultValue: { showWidgetTitle: false,