Skip to content

Commit 4562381

Browse files
authored
feat: Graph support for nested Object field values (#3326)
1 parent ca5790d commit 4562381

4 files changed

Lines changed: 363 additions & 41 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ test_logs
2020

2121
# AI tools
2222
.claude
23+
.superpowers

src/dashboard/Data/Browser/GraphDialog.react.js

Lines changed: 165 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Option from 'components/Dropdown/Option.react';
1818
import Toggle from 'components/Toggle/Toggle.react';
1919
import TextInput from 'components/TextInput/TextInput.react';
2020
import styles from 'components/Modal/Modal.scss';
21+
import stringCompare from 'lib/stringCompare';
2122
import { validateFormula } from 'lib/FormulaEvaluator';
2223

2324
const CHART_TYPES = [
@@ -154,6 +155,7 @@ export default class GraphDialog extends React.Component {
154155
maxDataPoints: initialConfig.maxDataPoints || 1000,
155156
maxDataPointsInput: null,
156157
showDeleteConfirmation: false,
158+
objectPathInput: null,
157159
};
158160
}
159161

@@ -240,29 +242,55 @@ export default class GraphDialog extends React.Component {
240242
return Object.entries(this.props.columns)
241243
.filter(([key, col]) => key !== 'objectId' && (!types || types.includes(col.type)))
242244
.map(([key]) => key)
243-
.sort((a, b) => a.localeCompare(b));
245+
.sort(stringCompare);
244246
}
245247

246248
getAllColumns() {
247249
return this.getColumnsByType();
248250
}
249251

250252
getNumericColumns() {
251-
return this.getColumnsByType(['Number']);
253+
return this.getColumnsByType(['Number', 'Object']);
252254
}
253255

254256
getNumericAndPointerColumns() {
255-
return this.getColumnsByType(['Number', 'Pointer']);
257+
return this.getColumnsByType(['Number', 'Pointer', 'Object']);
256258
}
257259

258260
getStringColumns() {
259261
return this.getColumnsByType(['String']);
260262
}
261263

262264
getStringAndPointerColumns() {
263-
return this.getColumnsByType(['String', 'Pointer']);
265+
return this.getColumnsByType(['String', 'Pointer', 'Object']);
264266
}
265267

268+
isObjectColumn(col) {
269+
return this.props.columns && this.props.columns[col] && this.props.columns[col].type === 'Object';
270+
}
271+
272+
isObjectDotPath(fieldValue) {
273+
if (!fieldValue || !this.props.columns) {
274+
return false;
275+
}
276+
const dotIndex = fieldValue.indexOf('.');
277+
if (dotIndex <= 0) {
278+
return false;
279+
}
280+
const topLevel = fieldValue.substring(0, dotIndex);
281+
return this.props.columns[topLevel] && this.props.columns[topLevel].type === 'Object';
282+
}
283+
284+
getObjectDotPathOptions(selectedFields, columnList) {
285+
return (selectedFields || []).filter(f => this.isObjectDotPath(f) && !columnList.includes(f));
286+
}
287+
288+
confirmObjectPath = () => {
289+
const { field, path, onConfirm } = this.state.objectPathInput;
290+
onConfirm(`${field}.${path}`);
291+
this.setState({ objectPathInput: null });
292+
};
293+
266294
getNumericAndCalculatedFields(currentIndex = -1) {
267295
const numericColumns = this.getNumericColumns();
268296
// Include series fields and titles
@@ -496,13 +524,25 @@ export default class GraphDialog extends React.Component {
496524
<div>
497525
<MultiSelect
498526
value={s.fields || []}
499-
onChange={fields => this.updateSeries(index, 'fields', fields)}
527+
onChange={fields => {
528+
const added = fields.find(f => !(s.fields || []).includes(f));
529+
if (added && this.isObjectColumn(added)) {
530+
this.setState({ objectPathInput: { field: added, onConfirm: fullPath => this.updateSeries(index, 'fields', [...(s.fields || []), fullPath].sort(stringCompare)) } });
531+
return;
532+
}
533+
this.updateSeries(index, 'fields', fields);
534+
}}
500535
placeHolder="Select field(s)"
501536
formatSelection={selection => selection.length === 1 ? selection[0] : `${selection.length} fields`}
502537
>
503538
{numericAndPointerColumns.map(col => (
504539
<MultiSelectOption key={col} value={col}>
505-
{col}
540+
{this.isObjectColumn(col) ? `${col} [object]` : col}
541+
</MultiSelectOption>
542+
))}
543+
{this.getObjectDotPathOptions(s.fields, numericAndPointerColumns).map(f => (
544+
<MultiSelectOption key={f} value={f}>
545+
{f}
506546
</MultiSelectOption>
507547
))}
508548
</MultiSelect>
@@ -762,16 +802,25 @@ export default class GraphDialog extends React.Component {
762802
<Dropdown
763803
value={calc.fields && calc.fields[0] ? calc.fields[0] : ''}
764804
onChange={numerator => {
805+
if (this.isObjectColumn(numerator)) {
806+
this.setState({ objectPathInput: { field: numerator, onConfirm: fullPath => this.updateCalculatedValue(index, 'fields', [fullPath, calc.fields && calc.fields[1] ? calc.fields[1] : '']) } });
807+
return;
808+
}
765809
const newFields = [numerator, calc.fields && calc.fields[1] ? calc.fields[1] : ''];
766810
this.updateCalculatedValue(index, 'fields', newFields);
767811
}}
768812
placeHolder="Select field"
769813
>
770814
{numericAndCalculatedFields.map(col => (
771815
<Option key={col} value={col}>
772-
{col}
816+
{this.isObjectColumn(col) ? `${col} [object]` : col}
773817
</Option>
774818
))}
819+
{calc.fields && calc.fields[0] && this.isObjectDotPath(calc.fields[0]) && !numericAndCalculatedFields.includes(calc.fields[0]) && (
820+
<Option key={calc.fields[0]} value={calc.fields[0]}>
821+
{calc.fields[0]}
822+
</Option>
823+
)}
775824
</Dropdown>
776825
</div>
777826
</div>
@@ -783,16 +832,25 @@ export default class GraphDialog extends React.Component {
783832
<Dropdown
784833
value={calc.fields && calc.fields[1] ? calc.fields[1] : ''}
785834
onChange={denominator => {
835+
if (this.isObjectColumn(denominator)) {
836+
this.setState({ objectPathInput: { field: denominator, onConfirm: fullPath => this.updateCalculatedValue(index, 'fields', [calc.fields && calc.fields[0] ? calc.fields[0] : '', fullPath]) } });
837+
return;
838+
}
786839
const newFields = [calc.fields && calc.fields[0] ? calc.fields[0] : '', denominator];
787840
this.updateCalculatedValue(index, 'fields', newFields);
788841
}}
789842
placeHolder="Select field"
790843
>
791844
{numericAndCalculatedFields.map(col => (
792845
<Option key={col} value={col}>
793-
{col}
846+
{this.isObjectColumn(col) ? `${col} [object]` : col}
794847
</Option>
795848
))}
849+
{calc.fields && calc.fields[1] && this.isObjectDotPath(calc.fields[1]) && !numericAndCalculatedFields.includes(calc.fields[1]) && (
850+
<Option key={calc.fields[1]} value={calc.fields[1]}>
851+
{calc.fields[1]}
852+
</Option>
853+
)}
796854
</Dropdown>
797855
</div>
798856
</div>
@@ -805,13 +863,25 @@ export default class GraphDialog extends React.Component {
805863
<div>
806864
<MultiSelect
807865
value={calc.fields}
808-
onChange={fields => this.updateCalculatedValue(index, 'fields', fields)}
866+
onChange={fields => {
867+
const added = fields.find(f => !calc.fields.includes(f));
868+
if (added && this.isObjectColumn(added)) {
869+
this.setState({ objectPathInput: { field: added, onConfirm: fullPath => this.updateCalculatedValue(index, 'fields', [...calc.fields, fullPath].sort(stringCompare)) } });
870+
return;
871+
}
872+
this.updateCalculatedValue(index, 'fields', fields);
873+
}}
809874
placeHolder="Select field(s)"
810875
formatSelection={selection => selection.length === 1 ? selection[0] : `${selection.length} fields`}
811876
>
812877
{numericAndCalculatedFields.map(col => (
813878
<MultiSelectOption key={col} value={col}>
814-
{col}
879+
{this.isObjectColumn(col) ? `${col} [object]` : col}
880+
</MultiSelectOption>
881+
))}
882+
{this.getObjectDotPathOptions(calc.fields, numericAndCalculatedFields).map(f => (
883+
<MultiSelectOption key={f} value={f}>
884+
{f}
815885
</MultiSelectOption>
816886
))}
817887
</MultiSelect>
@@ -990,29 +1060,50 @@ export default class GraphDialog extends React.Component {
9901060
<Field label={<Label text="X-Axis" />} input={
9911061
<Dropdown
9921062
value={this.state.xColumn}
993-
onChange={xColumn => this.setState({ xColumn })}
1063+
onChange={xColumn => {
1064+
if (this.isObjectColumn(xColumn)) {
1065+
this.setState({ objectPathInput: { field: xColumn, onConfirm: fullPath => this.setState({ xColumn: fullPath }) } });
1066+
return;
1067+
}
1068+
this.setState({ xColumn });
1069+
}}
9941070
placeHolder="Select field"
9951071
>
9961072
{(chartType === 'scatter' ? numericColumns : allColumns).map(col => (
9971073
<Option key={col} value={col}>
998-
{col}
1074+
{this.isObjectColumn(col) ? `${col} [object]` : col}
9991075
</Option>
10001076
))}
1077+
{this.state.xColumn && this.isObjectDotPath(this.state.xColumn) && !(chartType === 'scatter' ? numericColumns : allColumns).includes(this.state.xColumn) && (
1078+
<Option key={this.state.xColumn} value={this.state.xColumn}>
1079+
{this.state.xColumn}
1080+
</Option>
1081+
)}
10011082
</Dropdown>
10021083
} />
1003-
10041084
{chartType === 'scatter' && (
10051085
<Field label={<Label text="Y-Axis" />} input={
10061086
<Dropdown
10071087
value={this.state.yColumn}
1008-
onChange={yColumn => this.setState({ yColumn })}
1088+
onChange={yColumn => {
1089+
if (this.isObjectColumn(yColumn)) {
1090+
this.setState({ objectPathInput: { field: yColumn, onConfirm: fullPath => this.setState({ yColumn: fullPath }) } });
1091+
return;
1092+
}
1093+
this.setState({ yColumn });
1094+
}}
10091095
placeHolder="Select field"
10101096
>
10111097
{numericColumns.map(col => (
10121098
<Option key={col} value={col}>
1013-
{col}
1099+
{this.isObjectColumn(col) ? `${col} [object]` : col}
10141100
</Option>
10151101
))}
1102+
{this.state.yColumn && this.isObjectDotPath(this.state.yColumn) && !numericColumns.includes(this.state.yColumn) && (
1103+
<Option key={this.state.yColumn} value={this.state.yColumn}>
1104+
{this.state.yColumn}
1105+
</Option>
1106+
)}
10161107
</Dropdown>
10171108
} />
10181109
)}
@@ -1037,13 +1128,25 @@ export default class GraphDialog extends React.Component {
10371128
<Field label={<Label text="Group By" description="Optional"/>} input={
10381129
<MultiSelect
10391130
value={this.state.groupByColumn}
1040-
onChange={groupByColumn => this.setState({ groupByColumn })}
1131+
onChange={groupByColumn => {
1132+
const added = groupByColumn.find(f => !this.state.groupByColumn.includes(f));
1133+
if (added && this.isObjectColumn(added)) {
1134+
this.setState({ objectPathInput: { field: added, onConfirm: fullPath => this.setState({ groupByColumn: [...this.state.groupByColumn, fullPath].sort(stringCompare) }) } });
1135+
return;
1136+
}
1137+
this.setState({ groupByColumn });
1138+
}}
10411139
placeHolder="Select field(s)"
10421140
formatSelection={selection => selection.length === 1 ? selection[0] : `${selection.length} fields`}
10431141
>
10441142
{stringAndPointerColumns.map(col => (
10451143
<MultiSelectOption key={col} value={col}>
1046-
{col}
1144+
{this.isObjectColumn(col) ? `${col} [object]` : col}
1145+
</MultiSelectOption>
1146+
))}
1147+
{this.getObjectDotPathOptions(this.state.groupByColumn, stringAndPointerColumns).map(f => (
1148+
<MultiSelectOption key={f} value={f}>
1149+
{f}
10471150
</MultiSelectOption>
10481151
))}
10491152
</MultiSelect>
@@ -1207,26 +1310,51 @@ export default class GraphDialog extends React.Component {
12071310
);
12081311

12091312
return (
1210-
<Modal
1211-
type={Modal.Types.INFO}
1212-
icon="analytics-outline"
1213-
iconSize={40}
1214-
title={isEditing ? 'Edit Graph' : 'Create Graph'}
1215-
subtitle={isEditing ? 'Modify your data visualization settings' : 'Configure your data visualization'}
1216-
customFooter={customFooter}
1217-
>
1218-
<div style={{
1219-
maxHeight: 'calc(100vh - 260px)',
1220-
overflowY: 'auto',
1221-
overflowX: 'hidden',
1222-
border: 'none'
1223-
}}>
1224-
{this.renderTitleSection()}
1225-
{this.renderChartTypeSection()}
1226-
{this.renderColumnSelectionSection()}
1227-
{this.renderOptionsSection()}
1228-
</div>
1229-
</Modal>
1313+
<>
1314+
<Modal
1315+
type={Modal.Types.INFO}
1316+
icon="analytics-outline"
1317+
iconSize={40}
1318+
title={isEditing ? 'Edit Graph' : 'Create Graph'}
1319+
subtitle={isEditing ? 'Modify your data visualization settings' : 'Configure your data visualization'}
1320+
customFooter={customFooter}
1321+
>
1322+
<div style={{
1323+
maxHeight: 'calc(100vh - 260px)',
1324+
overflowY: 'auto',
1325+
overflowX: 'hidden',
1326+
border: 'none'
1327+
}}>
1328+
{this.renderTitleSection()}
1329+
{this.renderChartTypeSection()}
1330+
{this.renderColumnSelectionSection()}
1331+
{this.renderOptionsSection()}
1332+
</div>
1333+
</Modal>
1334+
{this.state.objectPathInput && (
1335+
<Modal
1336+
type={Modal.Types.INFO}
1337+
title="Enter Object Path"
1338+
subtitle={`Specify the nested key path for "${this.state.objectPathInput.field}"`}
1339+
confirmText="Add"
1340+
cancelText="Cancel"
1341+
onConfirm={this.confirmObjectPath}
1342+
onCancel={() => this.setState({ objectPathInput: null })}
1343+
disabled={!this.state.objectPathInput.path || !this.state.objectPathInput.path.trim()}
1344+
>
1345+
<Field label={<Label text="Path" />} input={
1346+
<div style={{ display: 'flex', alignItems: 'center' }}>
1347+
<span style={{ paddingLeft: '10px', color: '#666', whiteSpace: 'nowrap' }}>{this.state.objectPathInput.field}.</span>
1348+
<TextInput
1349+
value={this.state.objectPathInput.path || ''}
1350+
onChange={path => this.setState({ objectPathInput: { ...this.state.objectPathInput, path } })}
1351+
placeholder="e.g. views.count"
1352+
/>
1353+
</div>
1354+
} />
1355+
</Modal>
1356+
)}
1357+
</>
12301358
);
12311359
}
12321360
}

0 commit comments

Comments
 (0)