Skip to content

Commit f2037ee

Browse files
Add data quality indicators to table Viz (#11307)
1 parent 8b5cd9b commit f2037ee

File tree

4 files changed

+147
-21
lines changed

4 files changed

+147
-21
lines changed

app/gui/src/project-view/components/shared/AgGridTableView.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ const _props = defineProps<{
8484
suppressMoveWhenColumnDragging?: boolean
8585
textFormatOption?: TextFormatOptions
8686
processDataFromClipboard?: (params: ProcessDataFromClipboardParams<TData>) => string[][] | null
87+
pinnedTopRowData?: TData[]
88+
pinnedRowHeightMultiplier?: number
8789
}>()
8890
const emit = defineEmits<{
8991
cellEditingStarted: [event: CellEditingStartedEvent]
@@ -106,6 +108,10 @@ function onGridReady(event: GridReadyEvent<TData>) {
106108
}
107109
108110
function getRowHeight(params: RowHeightParams): number {
111+
if (params.node.rowPinned === 'top') {
112+
return DEFAULT_ROW_HEIGHT * (_props.pinnedRowHeightMultiplier ?? 2)
113+
}
114+
109115
if (_props.textFormatOption === 'off') {
110116
return DEFAULT_ROW_HEIGHT
111117
}
@@ -268,6 +274,7 @@ const { AgGridVue } = await import('ag-grid-vue3')
268274
:suppressDragLeaveHidesColumns="suppressDragLeaveHidesColumns"
269275
:suppressMoveWhenColumnDragging="suppressMoveWhenColumnDragging"
270276
:processDataFromClipboard="processDataFromClipboard"
277+
:pinnedTopRowData="pinnedTopRowData"
271278
@gridReady="onGridReady"
272279
@firstDataRendered="updateColumnWidths"
273280
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"

app/gui/src/project-view/components/visualizations/TableVisualization.vue

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
CellClickedEvent,
1111
ColDef,
1212
ICellRendererParams,
13+
ITooltipParams,
1314
SortChangedEvent,
1415
} from 'ag-grid-enterprise'
1516
import { computed, onMounted, ref, shallowRef, watchEffect, type Ref } from 'vue'
@@ -79,6 +80,12 @@ interface UnknownTable {
7980
get_child_node_action: string
8081
get_child_node_link_name: string
8182
link_value_type: string
83+
data_quality_pairs?: DataQualityPairs
84+
}
85+
86+
interface DataQualityPairs {
87+
number_of_nothing: number[]
88+
number_of_whitespace: number[]
8289
}
8390
8491
export type TextFormatOptions = 'full' | 'partial' | 'off'
@@ -118,7 +125,9 @@ const defaultColDef: Ref<ColDef> = ref({
118125
filter: true,
119126
resizable: true,
120127
minWidth: 25,
121-
cellRenderer: cellRenderer,
128+
cellRenderer: (params: ICellRendererParams) => {
129+
return params.node.rowPinned === 'top' ? customCellRenderer(params) : cellRenderer(params)
130+
},
122131
cellClass: cellClass,
123132
contextMenuItems: [commonContextMenuActions.copy, 'copyWithHeaders', 'separator', 'export'],
124133
} satisfies ColDef)
@@ -142,6 +151,47 @@ const selectableRowLimits = computed(() => {
142151
return defaults
143152
})
144153
154+
const pinnedTopRowData = computed(() => {
155+
if (typeof props.data === 'object' && 'data_quality_pairs' in props.data) {
156+
const data_ = props.data
157+
const headers = data_.header
158+
const numberOfNothing = data_.data_quality_pairs!.number_of_nothing
159+
const numberOfWhitespace = data_.data_quality_pairs!.number_of_whitespace
160+
const total = data_.all_rows_count as number
161+
if (headers?.length) {
162+
const pairs: Record<string, string> = headers.reduce(
163+
(obj: any, key: string, index: number) => {
164+
obj[key] = {
165+
numberOfNothing: numberOfNothing[index],
166+
numberOfWhitespace: numberOfWhitespace[index],
167+
total,
168+
}
169+
return obj
170+
},
171+
{},
172+
)
173+
return [{ [INDEX_FIELD_NAME]: 'Data Quality', ...pairs }]
174+
}
175+
return [
176+
{
177+
[INDEX_FIELD_NAME]: 'Data Quality',
178+
Value: {
179+
numberOfNothing: numberOfNothing[0] ?? null,
180+
numberOfWhitespace: numberOfWhitespace[0] ?? null,
181+
total,
182+
},
183+
},
184+
]
185+
}
186+
return []
187+
})
188+
189+
const pinnedRowHeight = computed(() => {
190+
const valueTypes =
191+
typeof props.data === 'object' && 'value_type' in props.data ? props.data.value_type : []
192+
return valueTypes.some((t) => t.constructor === 'Char' || t.constructor === 'Mixed') ? 2 : 1
193+
})
194+
145195
const newNodeSelectorValues = computed(() => {
146196
let tooltipValue
147197
let headerName
@@ -284,6 +334,39 @@ function cellClass(params: CellClassParams) {
284334
return null
285335
}
286336
337+
const createVisual = (value: number) => {
338+
let color
339+
if (value < 33) {
340+
color = 'green'
341+
} else if (value < 66) {
342+
color = 'orange'
343+
} else {
344+
color = 'red'
345+
}
346+
return `
347+
<div style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${color}; margin-left: 5px;"></div>
348+
`
349+
}
350+
351+
const customCellRenderer = (params: ICellRendererParams) => {
352+
if (params.node.rowPinned === 'top') {
353+
const nothingPerecent = (params.value.numberOfNothing / params.value.total) * 100
354+
const wsPerecent = (params.value.numberOfWhitespace / params.value.total) * 100
355+
const nothingVisibility = params.value.numberOfNothing === null ? 'hidden' : 'visible'
356+
const whitespaceVisibility = params.value.numberOfWhitespace === null ? 'hidden' : 'visible'
357+
358+
return `<div>
359+
<div style="visibility:${nothingVisibility};">
360+
Nulls/Nothing: ${nothingPerecent.toFixed(2)}% ${createVisual(nothingPerecent)}
361+
</div>
362+
<div style="visibility:${whitespaceVisibility};">
363+
Trailing/Leading Whitespace: ${wsPerecent.toFixed(2)}% ${createVisual(wsPerecent)}
364+
</div>
365+
</div>`
366+
}
367+
return null
368+
}
369+
287370
function cellRenderer(params: ICellRendererParams) {
288371
// Convert's the value into a display string.
289372
if (params.value === null) return '<span style="color:grey; font-style: italic;">Nothing</span>'
@@ -404,10 +487,14 @@ function toLinkField(fieldName: string, getChildAction?: string, castValueTypes?
404487
newNodeSelectorValues.value.headerName ? newNodeSelectorValues.value.headerName : fieldName,
405488
field: fieldName,
406489
onCellDoubleClicked: (params) => createNode(params, fieldName, getChildAction, castValueTypes),
407-
tooltipValueGetter: () => {
408-
return `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`
409-
},
410-
cellRenderer: (params: any) => `<div class='link'> ${params.value} </div>`,
490+
tooltipValueGetter: (params: ITooltipParams) =>
491+
params.node?.rowPinned === 'top' ?
492+
null
493+
: `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`,
494+
cellRenderer: (params: ICellRendererParams) =>
495+
params.node.rowPinned === 'top' ?
496+
`<div> ${params.value}</div>`
497+
: `<div class='link'> ${params.value} </div>`,
411498
}
412499
}
413500
@@ -639,6 +726,8 @@ config.setToolbar(
639726
:rowData="rowData"
640727
:defaultColDef="defaultColDef"
641728
:textFormatOption="textFormatterSelected"
729+
:pinnedTopRowData="pinnedTopRowData"
730+
:pinnedRowHeightMultiplier="pinnedRowHeight"
642731
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
643732
/>
644733
</Suspense>

distribution/lib/Standard/Visualization/0.0.0-dev/src/Table/Visualization.enso

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ from Standard.Base import all
22
import Standard.Base.Data.Vector.Builder
33

44
import Standard.Table.Row.Row
5-
from Standard.Table import Column, Excel_Workbook, Table
5+
from Standard.Table import Column, Excel_Workbook, Table, Value_Type
66

77
import Standard.Database.DB_Column.DB_Column
88
import Standard.Database.DB_Table.DB_Table
@@ -34,12 +34,12 @@ prepare_visualization y max_rows=1000 =
3434
_ : Table ->
3535
dataframe = x.take max_rows
3636
all_rows_count = x.row_count
37-
make_json_for_table dataframe all_rows_count True
37+
make_json_for_table dataframe all_rows_count True False
3838
_ : DB_Column -> prepare_visualization x.to_table max_rows
3939
_ : DB_Table ->
4040
dataframe = x.read (..First max_rows)
4141
all_rows_count = x.row_count
42-
make_json_for_table dataframe all_rows_count True
42+
make_json_for_table dataframe all_rows_count True True
4343
_ : Function ->
4444
pairs = [['_display_text_', '[Function '+x.to_text+']']]
4545
value = JS_Object.from_pairs pairs
@@ -59,6 +59,14 @@ prepare_visualization y max_rows=1000 =
5959
Column Limit
6060
max_columns = 250
6161

62+
##PRIVATE
63+
whitespace_count : Column -> Integer | Nothing
64+
whitespace_count col =
65+
find_whitespace col =
66+
filtered = col.to_vector.filter (c-> c.is_a Text && (c.first.is_whitespace || c.last.is_whitespace))
67+
filtered.length
68+
if (col.value_type == Value_Type.Mixed || col.value_type.is_text) then find_whitespace col else Nothing
69+
6270
## PRIVATE
6371
Render Vector to JSON
6472
make_json_for_vector : Vector -> Integer -> JS_Object
@@ -172,8 +180,8 @@ make_json_for_xml_element xml_element max_items type:Text="XML_Element" =
172180
to display.
173181
- all_rows_count: the number of all rows in the underlying data, useful if
174182
only a fragment is displayed.
175-
make_json_for_table : Table -> Integer -> Boolean -> JS_Object
176-
make_json_for_table dataframe all_rows_count include_index_col =
183+
make_json_for_table : Table -> Integer -> Boolean -> Boolean -> JS_Object
184+
make_json_for_table dataframe all_rows_count include_index_col is_db_table =
177185
get_vector c = Warning.set (c.to_vector.map v-> make_json_for_value v) []
178186
columns = dataframe.columns
179187
header = ["header", columns.map .name]
@@ -182,7 +190,10 @@ make_json_for_table dataframe all_rows_count include_index_col =
182190
all_rows = ["all_rows_count", all_rows_count]
183191
has_index_col = ["has_index_col", include_index_col]
184192
links = ["get_child_node_action", "get_row"]
185-
pairs = [header, value_type, data, all_rows, has_index_col, links, ["type", "Table"]]
193+
number_of_nothing = if is_db_table then Nothing else columns.map c-> c.count_nothing
194+
number_of_whitespace= if is_db_table then Nothing else columns.map c-> whitespace_count c
195+
data_quality_pairs = JS_Object.from_pairs [["number_of_nothing", number_of_nothing], ["number_of_whitespace", number_of_whitespace]]
196+
pairs = [header, value_type, data, all_rows, has_index_col, links, ["data_quality_pairs", data_quality_pairs] ,["type", "Table"]]
186197
JS_Object.from_pairs pairs
187198

188199
## PRIVATE

test/Visualization_Tests/src/Table_Spec.enso

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ type Data
2121

2222
t self = self.data.at 0
2323
t2 self = self.data.at 1
24+
t3_with_nulls self = self.data.at 2
25+
t4_with_space self = self.data.at 3
2426

2527
setup = Data.Value <|
2628
connection = Database.connect (SQLite.In_Memory)
2729
in_mem = Table.new [["A", ['a', 'a', 'a']], ["B", [2, 2, 3]], ["C", [3, 5, 6]]]
2830
t = in_mem.select_into_database_table connection "T" primary_key=Nothing temporary=True
2931
t2 = Table.new [["A", [1, 2, 3]], ["B", [4, 5, 6]], ["C", [7, 8, 9]]]
30-
[t, t2]
32+
t3_with_nulls = Table.new [["A", [1, Nothing, 3]], ["B", [4, Nothing, Nothing]], ["C", [7, Nothing, Nothing]]]
33+
t4_with_space = Table.new [["A", ['hello', ' leading space', 'trailing space ']], ["B", ['a', 'b', 'c']], ["C", [7, 8, 9]]]
34+
[t, t2, t3_with_nulls, t4_with_space]
3135

3236

3337
type Foo
@@ -43,48 +47,51 @@ type Foo_Link
4347
to_js_object self = JS_Object.from_pairs [["x", self.x], ["links", ["a", "b", "c"]]]
4448

4549
add_specs suite_builder =
46-
make_json header data all_rows value_type has_index_col get_child_node =
50+
make_json header data all_rows value_type has_index_col get_child_node number_of_nothing number_of_whitespace =
4751
p_header = ["header", header]
4852
p_data = ["data", data]
4953
p_all_rows = ["all_rows_count", all_rows]
5054
p_value_type = ["value_type", value_type]
5155
p_has_index_col = ["has_index_col", has_index_col]
5256
p_get_child_node = ["get_child_node_action", get_child_node]
53-
pairs = [p_header, p_value_type, p_data, p_all_rows, p_has_index_col, p_get_child_node, ["type", "Table"]]
57+
p_number_of_nothing = ["number_of_nothing", number_of_nothing]
58+
p_number_of_whitespace = ["number_of_whitespace", number_of_whitespace]
59+
data_quality_pairs = JS_Object.from_pairs [p_number_of_nothing, p_number_of_whitespace]
60+
pairs = [p_header, p_value_type, p_data, p_all_rows, p_has_index_col, p_get_child_node, ["data_quality_pairs", data_quality_pairs], ["type", "Table"]]
5461
JS_Object.from_pairs pairs . to_text
55-
62+
5663
suite_builder.group "Table Visualization" group_builder->
5764
data = Data.setup
5865

5966
group_builder.specify "should visualize database tables" <|
6067
vis = Visualization.prepare_visualization data.t 1
6168
value_type_int = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Integer"], ["display_text", "Integer (64 bits)"], ["bits", 64]]
6269
value_type_char = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Char"], ["display_text", "Char (variable length, max_size=unlimited)"], ["size", Nothing], ["variable_length", True]]
63-
json = make_json header=["A", "B", "C"] data=[['a'], [2], [3]] all_rows=3 value_type=[value_type_char, value_type_int, value_type_int] has_index_col=True get_child_node="get_row"
70+
json = make_json header=["A", "B", "C"] data=[['a'], [2], [3]] all_rows=3 value_type=[value_type_char, value_type_int, value_type_int] has_index_col=True get_child_node="get_row" number_of_nothing=Nothing number_of_whitespace=Nothing
6471
vis . should_equal json
6572

6673
group_builder.specify "should visualize database columns" <|
6774
vis = Visualization.prepare_visualization (data.t.at "A") 2
6875
value_type_char = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Char"], ["display_text", "Char (variable length, max_size=unlimited)"], ["size", Nothing], ["variable_length", True]]
6976
value_type_float = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Float"], ["display_text", "Float (64 bits)"], ["bits", 64]]
70-
json = make_json header=["A"] data=[['a', 'a']] all_rows=3 value_type=[value_type_char] has_index_col=True get_child_node="get_row"
77+
json = make_json header=["A"] data=[['a', 'a']] all_rows=3 value_type=[value_type_char] has_index_col=True get_child_node="get_row" number_of_nothing=Nothing number_of_whitespace=Nothing
7178
vis . should_equal json
7279

7380
g = data.t.aggregate ["A", "B"] [Aggregate_Column.Average "C"] . at "Average C"
7481
vis2 = Visualization.prepare_visualization g 1
75-
json2 = make_json header=["Average C"] data=[[4.0]] all_rows=2 value_type=[value_type_float] has_index_col=True get_child_node="get_row"
82+
json2 = make_json header=["Average C"] data=[[4.0]] all_rows=2 value_type=[value_type_float] has_index_col=True get_child_node="get_row" number_of_nothing=Nothing number_of_whitespace=Nothing
7683
vis2 . should_equal json2
7784

7885
group_builder.specify "should visualize dataframe tables" <|
7986
vis = Visualization.prepare_visualization data.t2 1
8087
value_type_int = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Integer"], ["display_text", "Integer (64 bits)"], ["bits", 64]]
81-
json = make_json header=["A", "B", "C"] data=[[1], [4], [7]] all_rows=3 value_type=[value_type_int, value_type_int, value_type_int] has_index_col=True get_child_node="get_row"
88+
json = make_json header=["A", "B", "C"] data=[[1], [4], [7]] all_rows=3 value_type=[value_type_int, value_type_int, value_type_int] has_index_col=True get_child_node="get_row" number_of_nothing=[0,0,0] number_of_whitespace=[Nothing, Nothing, Nothing]
8289
vis . should_equal json
8390

8491
group_builder.specify "should visualize dataframe columns" <|
8592
vis = Visualization.prepare_visualization (data.t2.at "A") 2
8693
value_type_int = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Integer"], ["display_text", "Integer (64 bits)"], ["bits", 64]]
87-
json = make_json header=["A"] data=[[1, 2]] all_rows=3 value_type=[value_type_int] has_index_col=True get_child_node="get_row"
94+
json = make_json header=["A"] data=[[1, 2]] all_rows=3 value_type=[value_type_int] has_index_col=True get_child_node="get_row" number_of_nothing=[0] number_of_whitespace=[Nothing]
8895
vis . should_equal json
8996

9097
group_builder.specify "should handle Vectors" <|
@@ -124,8 +131,20 @@ add_specs suite_builder =
124131
Visualization.prepare_visualization Value_Type.Char . should_equal (make_json Value_Type.Char)
125132
Visualization.prepare_visualization Value_Type.Unsupported_Data_Type . should_equal (make_json Value_Type.Unsupported_Data_Type)
126133

134+
group_builder.specify "should indicate number of Nothing/Nulls" <|
135+
vis = Visualization.prepare_visualization data.t3_with_nulls 3
136+
value_type_int = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Integer"], ["display_text", "Integer (64 bits)"], ["bits", 64]]
137+
json = make_json header=["A", "B", "C"] data=[[1,Nothing,3],[4,Nothing,Nothing],[7,Nothing,Nothing]] all_rows=3 value_type=[value_type_int, value_type_int, value_type_int] has_index_col=True get_child_node="get_row" number_of_nothing=[1, 2, 2] number_of_whitespace=[Nothing, Nothing, Nothing]
138+
vis . should_equal json
139+
140+
group_builder.specify "should indicate number of leading/trailing whitespace" <|
141+
vis = Visualization.prepare_visualization data.t4_with_space 3
142+
value_type_char = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Char"], ["display_text", "Char (variable length, max_size=unlimited)"], ["size", Nothing], ["variable_length", True]]
143+
value_type_int = JS_Object.from_pairs [["type", "Value_Type"], ["constructor", "Integer"], ["display_text", "Integer (64 bits)"], ["bits", 64]]
144+
json = make_json header=["A", "B", "C"] data=[['hello', ' leading space', 'trailing space '],['a', 'b', 'c'],[7, 8, 9]] all_rows=3 value_type=[value_type_char, value_type_char, value_type_int] has_index_col=True get_child_node="get_row" number_of_nothing=[0, 0, 0] number_of_whitespace=[2, 0, Nothing]
145+
vis . should_equal json
146+
127147
main filter=Nothing =
128148
suite = Test.build suite_builder->
129149
add_specs suite_builder
130150
suite.run_with_filter filter
131-

0 commit comments

Comments
 (0)