Skip to content

Commit ed56989

Browse files
committed
Bug fixes and UX improvements for 2.0.3
1 parent 431ecee commit ed56989

File tree

11 files changed

+88
-36
lines changed

11 files changed

+88
-36
lines changed

release-notes.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
## NeoDash 2.0 (stable)
1+
## NeoDash 2.0.3
2+
UX improvements + bug fixes.
3+
- Parameter selection report:
4+
- fixed bug to allow for selecting properties from nodes with >5 distinct properties.
5+
- Added support for nodes and properties with spaces in their name.
6+
- Sharing:
7+
- Removed persisted URL in share links to avoid getting stuck on shared dashboards
8+
- Table:
9+
- Added option to specify relative column sizes
10+
- Graph:
11+
- Changed node styling to use the last (most specific label) for applying customizations
12+
- Fixed error where incorrect properties were extracted from graphs with multi-labeled nodes
13+
- Fixed node display to hide "undefined" when a non-existing property is selected for that node.
14+
15+
## NeoDash 2.0.0, 2.0.1 & 2.0.2
216

317
**New & Improved Dashboard Editor**
418
- Added new Cypher editor with syntax highlighting / live syntax validation.

src/application/ApplicationThunks.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ export const setDatabaseFromNeo4jDesktopIntegrationThunk = () => (dispatch: any,
9797

9898
export const handleSharedDashboardsThunk = () => (dispatch: any, getState: any) => {
9999
try {
100-
dispatch(resetShareDetails());
101100
const queryString = window.location.search;
102101
const urlParams = new URLSearchParams(queryString);
103102
if (urlParams.get("share") !== null) {
@@ -113,10 +112,13 @@ export const handleSharedDashboardsThunk = () => (dispatch: any, getState: any)
113112
const url = connection.split("@")[1].split(":")[1];
114113
const port = connection.split("@")[1].split(":")[2];
115114
dispatch(setShareDetailsFromUrl(type, id, standalone, protocol, url, port, database, username, password));
115+
window.history.pushState({}, document.title, "/" );
116116
} else {
117117
dispatch(setShareDetailsFromUrl(type, id, undefined, undefined, undefined, undefined, undefined, undefined, undefined));
118+
window.history.pushState({}, document.title, "/" );
118119
}
119-
120+
}else{
121+
// dispatch(resetShareDetails());
120122
}
121123

122124
} catch (e) {

src/card/settings/custom/CardSettingsContentPropertySelect.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ const NeoCardSettingsContentPropertySelect = ({ type, database, query, onQueryUp
2727
const [propertyValue, setPropertyValue] = React.useState(property);
2828
const [parameterName, setParameterName] = React.useState("");
2929
const [propertyRecords, setPropertyRecords] = React.useState([]);
30-
30+
3131
// Reverse engineer the label, property, ID from the generated query.
3232
const approxParam = query.split("\n")[0].split("$")[1];
33-
const id = (approxParam && approxParam.split("_").length > 3) ? approxParam.split("_")[approxParam.split("_").length-1] : "";
33+
const id = (approxParam && approxParam.split("_").length > 3) && !isNaN(parseInt(approxParam.split("_")[approxParam.split("_").length-1])) ? approxParam.split("_")[approxParam.split("_").length-1] : "";
3434
const [idValue, setIdValue] = React.useState(id);
3535
if(!parameterName && labelValue && propertyValue){
36-
setParameterName("neodash_" + (labelValue + "_" + propertyValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase());
36+
setParameterName("neodash_" + (labelValue + "_" + propertyValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase().replaceAll(" ","_").replaceAll("-", "_"));
3737
}
3838
// Define query callback to allow reports to get extra data on interactions.
3939
const queryCallback = useCallback(
@@ -43,7 +43,7 @@ const NeoCardSettingsContentPropertySelect = ({ type, database, query, onQueryUp
4343
(result => setRecords(result)),
4444
() => { return }, false,
4545
false, false,
46-
[], [], [], null);
46+
[], [], [], [], null);
4747
},
4848
[],
4949
);
@@ -67,14 +67,15 @@ const NeoCardSettingsContentPropertySelect = ({ type, database, query, onQueryUp
6767
value={labelValue}
6868
onChange={(event, newValue) => {
6969
setLabelValue(newValue);
70-
70+
setPropertyValue(undefined);
71+
setParameterName("");
7172
if (newValue && propertyValue) {
72-
const new_parameter_name = "neodash_" + (newValue + "_" + propertyValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase();
73-
setParameterName(new_parameter_name);
73+
const new_parameter_name = "neodash_" + (newValue + "_" + propertyValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase().replaceAll(" ","_").replaceAll("-", "_");
74+
// setParameterName(new_parameter_name);
7475
const newQuery = "// $" + new_parameter_name + "\nMATCH (n:`" + newValue + "`) \nWHERE toLower(toString(n.`" + propertyValue + "`)) CONTAINS toLower($input) \nRETURN DISTINCT n.`" + propertyValue + "` as value LIMIT 5";
7576
onQueryUpdate(newQuery);
7677
} else {
77-
setParameterName(null);
78+
setParameterName("");
7879
}
7980
}}
8081
renderInput={(params) => <TextField {...params} placeholder="Start typing..." InputLabelProps={{ shrink: true }} label={"Node Label"} />}
@@ -87,14 +88,14 @@ const NeoCardSettingsContentPropertySelect = ({ type, database, query, onQueryUp
8788
inputValue={propertyInputText}
8889
onInputChange={(event, value) => {
8990
setPropertyInputText(value);
90-
queryCallback("CALL db.schema.nodeTypeProperties() YIELD nodeLabels, propertyName WITH * WHERE $label IN nodeLabels RETURN DISTINCT propertyName LIMIT 5", { label: labelValue }, setPropertyRecords);
91+
queryCallback("CALL db.schema.nodeTypeProperties() YIELD nodeLabels, propertyName WITH * WHERE $label IN nodeLabels AND toLower(propertyName) CONTAINS toLower($input) RETURN DISTINCT propertyName LIMIT 5", { label: labelValue, input: value }, setPropertyRecords);
9192
}}
9293
value={propertyValue}
9394
onChange={(event, newValue) => {
9495
setPropertyValue(newValue);
9596

9697
if (newValue && labelValue) {
97-
const new_parameter_name = "neodash_" + (labelValue + "_" + newValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase();
98+
const new_parameter_name = "neodash_" + (labelValue + "_" + newValue + (idValue == "" || idValue.startsWith("_") ? idValue : "_" + idValue)).toLowerCase().replaceAll(" ","_").replaceAll("-", "_");
9899
setParameterName(new_parameter_name);
99100
const newQuery = "// $" + new_parameter_name + "\nMATCH (n:`" + labelValue + "`) \nWHERE toLower(toString(n.`" + newValue + "`)) CONTAINS toLower($input) \nRETURN DISTINCT n.`" + newValue + "` as value LIMIT 5";
100101
onQueryUpdate(newQuery);
@@ -111,7 +112,7 @@ const NeoCardSettingsContentPropertySelect = ({ type, database, query, onQueryUp
111112
const newValue = value ? "_" + value : "";
112113
setIdValue(value);
113114
if (propertyValue && labelValue) {
114-
const new_parameter_name = "neodash_" + (labelValue + "_" + propertyValue + newValue).toLowerCase();
115+
const new_parameter_name = "neodash_" + (labelValue + "_" + propertyValue + newValue).toLowerCase().replaceAll(" ","_").replaceAll("-", "_");
115116
setParameterName(new_parameter_name);
116117
const newQuery = "// $" + new_parameter_name + "\nMATCH (n:`" + labelValue + "`) \nWHERE toLower(toString(n.`" + propertyValue + "`)) CONTAINS toLower($input) \nRETURN DISTINCT n.`" + propertyValue + "` as value LIMIT 5";
117118
onQueryUpdate(newQuery);

src/card/view/CardViewFooter.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const NeoCardViewFooter = ({ fields, settings, selection, type, showOptionalSele
5454
// Creates the selection for all other types of components
5555
if (selectableFields[selectable].type == SELECTION_TYPES.LIST ||
5656
selectableFields[selectable].type == SELECTION_TYPES.NUMBER ||
57+
selectableFields[selectable].type == SELECTION_TYPES.NUMBER_OR_DATETIME ||
5758
selectableFields[selectable].type == SELECTION_TYPES.TEXT) {
5859
if (selectionIsMandatory || showOptionalSelections) {
5960

src/chart/GraphChart.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const NeoGraphChart = (props: ChartProps) => {
7272
labels: value.labels,
7373
size: value.properties[nodeSizeProp] ? value.properties[nodeSizeProp] : defaultNodeSize,
7474
properties: value.properties,
75-
firstLabel: value.labels[0]
75+
lastLabel: value.labels[value.labels.length - 1]
7676
};
7777
} else if (valueIsRelationship(value)) {
7878
if (links[value.start.low + "," + value.end.low] == undefined) {
@@ -147,7 +147,7 @@ const NeoGraphChart = (props: ChartProps) => {
147147
const nodeLabelsList = Object.keys(nodeLabels);
148148
const nodesList = Object.values(nodes).map(node => {
149149
const assignedColor = node.properties[nodeColorProp] ? node.properties[nodeColorProp] :
150-
categoricalColorSchemes[nodeColorScheme][nodeLabelsList.indexOf(node.firstLabel) % totalColors];
150+
categoricalColorSchemes[nodeColorScheme][nodeLabelsList.indexOf(node.lastLabel) % totalColors];
151151
return update(node, { color: assignedColor ? assignedColor : defaultNodeColor });
152152
});
153153

@@ -163,7 +163,7 @@ const NeoGraphChart = (props: ChartProps) => {
163163
}
164164

165165
const renderNodeLabel = (node) => {
166-
const selectedProp = props.selection[node.firstLabel];
166+
const selectedProp = props.selection[node.lastLabel];
167167
if (selectedProp == "(id)") {
168168
return node.id;
169169
}
@@ -173,7 +173,7 @@ const NeoGraphChart = (props: ChartProps) => {
173173
if (selectedProp == "(no label)") {
174174
return "";
175175
}
176-
return node.properties[selectedProp]
176+
return node.properties[selectedProp] ? node.properties[selectedProp] : "";
177177
}
178178

179179
const handleExpand = useCallback(node => {
@@ -205,7 +205,7 @@ const NeoGraphChart = (props: ChartProps) => {
205205
onNodeRightClick={handleExpand}
206206
nodeCanvasObjectMode={() => "after"}
207207
nodeCanvasObject={(node, ctx, globalScale) => {
208-
const label = (props.selection && props.selection[node.firstLabel]) ? renderNodeLabel(node) : "";
208+
const label = (props.selection && props.selection[node.lastLabel]) ? renderNodeLabel(node) : "";
209209
const fontSize = nodeLabelFontSize;
210210
ctx.font = `${fontSize}px Sans-Serif`;
211211
ctx.fillStyle = nodeLabelColor;

src/chart/TableChart.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function RenderTableValue(value, key = 0) {
5252
return JSON.stringify(value);
5353
}
5454
const str = value.toString();
55-
if (str.startsWith("http") || str.startsWith("https")){
55+
if (str.startsWith("http") || str.startsWith("https")) {
5656
return <a target="_blank" href={str}>{str}</a>;
5757
}
5858
return str;
@@ -62,14 +62,23 @@ const NeoTableChart = (props: ChartProps) => {
6262
return <>No data, re-run the report.</>
6363
}
6464

65-
const columns = props.records[0].keys.map(key => {
65+
var columnWidths = null;
66+
try {
67+
columnWidths = props.settings && props.settings.columnWidths && JSON.parse(props.settings.columnWidths);
68+
} catch (e) {
69+
// do nothing
70+
} finally {
71+
// do nothing
72+
}
73+
74+
const columns = props.records[0].keys.map((key, i) => {
6675
return {
6776
field: key,
6877
headerName: key,
6978
headerClassName: 'table-small-header',
7079
renderCell: (c) => RenderTableValue(c.value),
7180
disableColumnSelector: true,
72-
flex: 1,
81+
flex: columnWidths ? columnWidths[i] % columnWidths.length : 1,
7382
disableClickEventBubbling: true
7483
}
7584
})

src/config/ReportConfig.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import NeoTableChart from '../chart/TableChart';
1414

1515
export enum SELECTION_TYPES {
1616
NUMBER,
17+
NUMBER_OR_DATETIME,
1718
LIST,
1819
TEXT,
1920
DICTIONARY,
@@ -39,7 +40,14 @@ export const REPORT_TYPES = {
3940
helperText: "A table will contain all returned data.",
4041
component: NeoTableChart,
4142
maxRecords: 1000,
42-
settings: {}
43+
44+
settings: {
45+
"columnWidths": {
46+
label: "Relative Column Sizes",
47+
type: SELECTION_TYPES.TEXT,
48+
default: "[1, 1, 1, ...]"
49+
}
50+
}
4351
},
4452
"graph": {
4553
label: "Graph",

src/dashboard/DashboardHeaderPageButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const NeoPageButton = ({ title, index, disabled = false, selected = false
4545
e.stopPropagation();
4646
}
4747
}}
48-
readonly={disabled}
48+
readOnly={disabled}
4949
inputProps={{ style: { textTransform: 'none', cursor: 'pointer', fontWeight: 'normal' } }}
5050
style={{ height: "36px", width: "185px", paddingLeft: "10px", color: selected ? 'black' : '#888', textAlign: "center", textTransform: "uppercase" }}
5151
placeholder="Page name..."

src/report/CypherQueryRunner.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export enum QueryStatus {
3131
*/
3232
export async function runCypherQuery(driver,
3333
database = undefined,
34-
query,
34+
query = "",
3535
parameters = {},
3636
selection = {},
3737
fields = [],
@@ -43,6 +43,7 @@ export async function runCypherQuery(driver,
4343
useRecordMapper = false,
4444
useNodePropsAsFields = false,
4545
numericFields = [],
46+
numericOrDatetimeFields = [],
4647
textFields = [],
4748
optionalFields = [],
4849
defaultKeyField = ""
@@ -99,7 +100,7 @@ export async function runCypherQuery(driver,
99100

100101
// Set the records for the visualization, if an explicit field name mapping is provided.
101102
if (useRecordMapper) {
102-
records = mapRecords(records, selection, textFields, numericFields, optionalFields, defaultKeyField)
103+
records = mapRecords(records, selection, textFields, numericFields, numericOrDatetimeFields, optionalFields, defaultKeyField)
103104
}
104105
if (records == null) {
105106
setStatus(QueryStatus.NO_DRAWABLE_DATA)

src/report/RecordProcessing.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import _ from 'lodash';
2+
import { DateTime } from 'neo4j-driver';
23

34
const OPTIONAL_FIELD_UNAVAILABLE_IDENTIFIER = "(none)";
45

@@ -9,19 +10,20 @@ const OPTIONAL_FIELD_UNAVAILABLE_IDENTIFIER = "(none)";
910
* @param selection : a dictionary of record field name mappings.
1011
* @returns records : the same set of records, but with cleaned up and renamed records that the visualization needs.
1112
*/
12-
export function mapRecords(records: any, selection: any, textFields: any, numericFields: any, optionalFields: any, defaultKeyField: str) {
13+
export function mapRecords(records: any, selection: any, textFields: any, numericFields: any, numericOrDatetimeFields: any,
14+
optionalFields: any, defaultKeyField: string) {
1315

1416
// if: We have null records, or, an empty result set, or, no specified selection, we return the original record set.
1517
if (!records || records.length == 0 || Object.keys(selection).length == 0) {
1618
return records;
1719
}
1820

1921
// Use the first row + the selection dict to create a mapping from the actual --> expected fields.
20-
const fieldLookup = createMappedFieldLookup(records[0], selection, optionalFields)
22+
const fieldLookup = createMappedFieldLookup(records[0], selection, optionalFields, numericOrDatetimeFields)
2123
const keys = Object.keys(fieldLookup)
2224
const defaultKey = selection[defaultKeyField] ? selection[defaultKeyField] : "";
2325
const mappedRecords = records
24-
.map(r => mapSingleRecord(r, fieldLookup, keys, defaultKey, textFields, numericFields, optionalFields))
26+
.map(r => mapSingleRecord(r, fieldLookup, keys, defaultKey, textFields, numericFields, numericOrDatetimeFields, optionalFields))
2527
.filter(r => r != null);
2628

2729
// Check if we have non-zero records for all of the numeric fields, if not, we can't visualize anything.
@@ -50,7 +52,7 @@ export function mapRecords(records: any, selection: any, textFields: any, numeri
5052
* Output:
5153
* - (fieldlookup={x=0, y1=1, y2=2}
5254
*/
53-
export function createMappedFieldLookup(record: any, selection: any, optionalFieldNames: any) {
55+
export function createMappedFieldLookup(record: any, selection: any, optionalFieldNames: any, numericOrDateTimeFieldNames) {
5456
const newFieldLookup = {}
5557
Object.keys(selection).forEach(expectedFieldName => {
5658
const actualFieldName = selection[expectedFieldName];
@@ -87,7 +89,9 @@ export function createMappedFieldLookup(record: any, selection: any, optionalFie
8789
* @param defaultKey : if the record is missing a 'key' field, a default value for the field.
8890
* @returns the mapped record.
8991
*/
90-
export function mapSingleRecord(record, fieldLookup, keys, defaultKey, textFieldNames, numericFieldNames, optionalFieldNames) {
92+
export function mapSingleRecord(record, fieldLookup, keys, defaultKey,
93+
textFieldNames, numericFieldNames, numericOrDatetimeFieldNames, optionalFieldNames) {
94+
9195
record._fieldLookup = fieldLookup;
9296
record.keys = keys;
9397

@@ -103,6 +107,15 @@ export function mapSingleRecord(record, fieldLookup, keys, defaultKey, textField
103107
if (numericFieldNames.some(numericFieldName => (isNaN(record._fields[record._fieldLookup[numericFieldName]])))) {
104108
return null;
105109
}
110+
111+
numericOrDatetimeFieldNames.forEach(numericOrDatetimeFieldName => {
112+
const value = record._fields[record._fieldLookup[numericOrDatetimeFieldName]];
113+
const className = value.__proto__.constructor.name;
114+
if (className == "DateTime"){
115+
record._fields[record._fieldLookup[numericOrDatetimeFieldName]] =new Date(value.toString());
116+
117+
}
118+
})
106119

107120
textFieldNames.forEach(textFieldName => {
108121
record._fields[record._fieldLookup[textFieldName]] =
@@ -189,15 +202,17 @@ export function extractNodePropertiesFromRecords(records: any) {
189202
return fields.length > 0 ? fields : [];
190203
}
191204

205+
192206
export function saveNodePropertiesToDictionary(field, fieldsDict) {
207+
// TODO - instead of doing this discovery ad-hoc, we could also use CALL db.schema.nodeTypeProperties().
193208
if (field == undefined) {
194209
return
195210
}
196211
if (valueIsArray(field)) {
197212
field.forEach((v, i) => saveNodePropertiesToDictionary(v, fieldsDict));
198-
} else if(valueIsNode(field)) {
213+
} else if (valueIsNode(field)) {
199214
field.labels.forEach(l => {
200-
fieldsDict[l] = (fieldsDict[l]) ? _.merge(fieldsDict[l], Object.keys(field.properties)) : Object.keys(field.properties)
215+
fieldsDict[l] = (fieldsDict[l]) ? [...new Set(fieldsDict[l].concat(Object.keys(field.properties)))] : Object.keys(field.properties)
201216
});
202217
} else if (valueIsPath(field)) {
203218
field.segments.forEach((segment, i) => {

0 commit comments

Comments
 (0)