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 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', diff --git a/src/constants/columns.js b/src/constants/columns.js new file mode 100644 index 000000000..c035ca4c3 --- /dev/null +++ b/src/constants/columns.js @@ -0,0 +1,50 @@ +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)), + normalize: (c) => c.map(priceStringToNumeric), + }, + [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/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 af804d451..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) { @@ -48,11 +44,11 @@ const DomainControls = () => { }, [group, groupingOptional, update]) const renderCategory = () => { - const { category } = columnsAnalysis[group ? groupKey : indexKey] || {} + const { category, isNumeric } = columnsAnalysis[group ? groupKey : indexKey] || {} return (category && {category} @@ -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..b182d0d4a 100644 --- a/src/controls/shared/value-controls.js +++ b/src/controls/shared/value-controls.js @@ -1,41 +1,50 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo } from 'react' 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 = () => { // common actions + const update = useStoreActions(actions => actions.update) const userUpdate = useStoreActions(actions => actions.userUpdate) const resetValue = useStoreActions(actions => actions.resetValue) // 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) 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(() => 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) + 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 = { 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 +81,7 @@ const ValueControls = () => { return ( { > { renderSection(null, - group - ? renderGroupedValueKeysSelect - : renderRow('Columns', - key)} - data={eligibleColumns} - onSelect={(val) => userUpdate({ valueKeys: val.map(v => ({ key: v })) })} - /> - ) + <> + { + 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 cde0739a9..16d868cee 100644 --- a/src/hooks/use-transformed-data.js +++ b/src/hooks/use-transformed-data.js @@ -4,8 +4,9 @@ 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' const useTransformedData = () => { @@ -31,27 +32,26 @@ 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]) - // convert prices to numeric values - const normalizedPriceData = useMemo(() => { - const priceColumns = columns.map(({ name }) => name).filter(c => columnsAnalysis[c]?.category === 'String' && c.includes('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 @@ -59,7 +59,7 @@ const useTransformedData = () => { } return true }) - ), [normalizedPriceData, filters]) + ), [normalizedData, filters]) const newGroupKey = useMemo(() => ( groupFSAByPC @@ -190,9 +190,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]) { @@ -219,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( @@ -230,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 0d2beea0f..5e0dd7295 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -11,7 +11,8 @@ 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' const MAX_UNDO_STEPS = 10 @@ -31,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, @@ -161,19 +163,11 @@ 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[name].normalized || data acc[name].min = Math.min.apply(null, numericData) acc[name].max = Math.max.apply(null, numericData) } @@ -256,7 +250,7 @@ export default { (state) => state.columnsAnalysis, ], (domain, columnsAnalysis) => ( - columnsAnalysis[domain.value]?.isValidDate + columnsAnalysis[domain.value]?.category === columnTypes.DATE ) ), diff --git a/src/util/columns.js b/src/util/columns.js new file mode 100644 index 000000000..52d5541db --- /dev/null +++ b/src/util/columns.js @@ -0,0 +1,31 @@ +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 + const { Icon, normalize } = columnTypeInfo[category] + return { + category, + Icon, + parentCategories: matches, + isNumeric: category === columnTypes.NUMERIC || matches.includes(columnTypes.NUMERIC), + ...(normalize && { normalized: normalize(columnData) }), + } +} 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