datajunction-ui 0.0.93 → 0.0.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/NodeComponents.jsx +4 -0
  3. package/src/app/components/Tab.jsx +11 -16
  4. package/src/app/components/__tests__/Tab.test.jsx +4 -2
  5. package/src/app/hooks/useWorkspaceData.js +226 -0
  6. package/src/app/index.tsx +17 -1
  7. package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +38 -107
  8. package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +31 -6
  9. package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +5 -0
  10. package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +86 -100
  11. package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +7 -11
  12. package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +79 -11
  13. package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +22 -0
  14. package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +57 -0
  15. package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +60 -18
  16. package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +156 -162
  17. package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +17 -18
  18. package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +179 -0
  19. package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +169 -49
  20. package/src/app/pages/MyWorkspacePage/index.jsx +41 -73
  21. package/src/app/pages/NodePage/NodeDataFlowTab.jsx +464 -0
  22. package/src/app/pages/NodePage/NodeDependenciesTab.jsx +1 -1
  23. package/src/app/pages/NodePage/NodeDimensionsTab.jsx +362 -0
  24. package/src/app/pages/NodePage/NodeLineageTab.jsx +1 -0
  25. package/src/app/pages/NodePage/NodesWithDimension.jsx +3 -3
  26. package/src/app/pages/NodePage/__tests__/NodeDataFlowTab.test.jsx +428 -0
  27. package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +18 -1
  28. package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +412 -0
  29. package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +28 -3
  30. package/src/app/pages/NodePage/__tests__/NodeWithDimension.test.jsx +2 -2
  31. package/src/app/pages/NodePage/index.jsx +15 -8
  32. package/src/app/pages/QueryPlannerPage/ResultsView.jsx +420 -86
  33. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +32 -1
  34. package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +322 -0
  35. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +431 -2
  36. package/src/app/pages/QueryPlannerPage/index.jsx +31 -5
  37. package/src/app/pages/QueryPlannerPage/styles.css +211 -2
  38. package/src/app/pages/Root/__tests__/index.test.jsx +2 -3
  39. package/src/app/pages/Root/index.tsx +1 -1
  40. package/src/app/services/DJService.js +133 -23
  41. package/src/app/services/__tests__/DJService.test.jsx +600 -11
  42. package/src/styles/index.css +32 -0
@@ -857,6 +857,13 @@
857
857
  gap: 2px;
858
858
  }
859
859
 
860
+ .dimension-full-name {
861
+ font-size: 10px;
862
+ color: var(--planner-text-muted);
863
+ font-family: var(--font-display);
864
+ word-break: break-all;
865
+ }
866
+
860
867
  .dimension-path {
861
868
  font-size: 10px;
862
869
  color: var(--planner-text-dim);
@@ -3557,6 +3564,55 @@ a.action-btn {
3557
3564
  cursor: not-allowed;
3558
3565
  }
3559
3566
 
3567
+ /* =================================
3568
+ Engine Selection
3569
+ ================================= */
3570
+
3571
+ .engine-section {
3572
+ display: flex;
3573
+ align-items: center;
3574
+ gap: 8px;
3575
+ padding: 10px 12px;
3576
+ border-top: 1px solid var(--planner-border);
3577
+ background: var(--planner-bg);
3578
+ flex-shrink: 0;
3579
+ }
3580
+
3581
+ .engine-label {
3582
+ font-size: 12px;
3583
+ font-weight: 500;
3584
+ color: var(--text-secondary);
3585
+ white-space: nowrap;
3586
+ }
3587
+
3588
+ .engine-pills {
3589
+ display: flex;
3590
+ gap: 4px;
3591
+ }
3592
+
3593
+ .engine-pill {
3594
+ padding: 4px 10px;
3595
+ border: 1px solid var(--planner-border);
3596
+ border-radius: 12px;
3597
+ background: transparent;
3598
+ font-size: 12px;
3599
+ color: var(--text-secondary);
3600
+ cursor: pointer;
3601
+ transition: all 0.12s ease;
3602
+ }
3603
+
3604
+ .engine-pill:hover {
3605
+ border-color: #059669;
3606
+ color: #059669;
3607
+ }
3608
+
3609
+ .engine-pill.active {
3610
+ background: #059669;
3611
+ border-color: #059669;
3612
+ color: white;
3613
+ font-weight: 500;
3614
+ }
3615
+
3560
3616
  /* =================================
3561
3617
  Run Query Section
3562
3618
  ================================= */
@@ -3950,8 +4006,9 @@ a.action-btn {
3950
4006
  .results-table th {
3951
4007
  position: sticky;
3952
4008
  top: 0;
4009
+ z-index: 1;
3953
4010
  padding: 10px 12px;
3954
- background: var(--planner-bg);
4011
+ background: #f8fafc;
3955
4012
  color: var(--planner-text);
3956
4013
  font-weight: 600;
3957
4014
  text-align: left;
@@ -3962,7 +4019,7 @@ a.action-btn {
3962
4019
  }
3963
4020
 
3964
4021
  .results-table th:hover {
3965
- background: var(--planner-bg-hover, #f1f5f9);
4022
+ background: #f1f5f9;
3966
4023
  }
3967
4024
 
3968
4025
  .results-table th.sorted {
@@ -4051,3 +4108,155 @@ a.action-btn {
4051
4108
  .filters-list .filter-chip {
4052
4109
  padding: 4px 10px;
4053
4110
  }
4111
+
4112
+ /* =================================
4113
+ Results View - Tab bar & Chart
4114
+ ================================= */
4115
+
4116
+ .results-tabs-bar {
4117
+ display: flex;
4118
+ align-items: center;
4119
+ gap: 12px;
4120
+ padding: 8px 16px 0;
4121
+ border-bottom: 1px solid var(--planner-border);
4122
+ background: var(--planner-surface);
4123
+ flex-shrink: 0;
4124
+ }
4125
+
4126
+ .results-tabs {
4127
+ display: flex;
4128
+ gap: 2px;
4129
+ }
4130
+
4131
+ .results-tab {
4132
+ padding: 6px 14px;
4133
+ font-size: 12px;
4134
+ font-weight: 500;
4135
+ font-family: var(--font-body);
4136
+ background: none;
4137
+ border: none;
4138
+ border-bottom: 2px solid transparent;
4139
+ color: var(--planner-text-muted);
4140
+ cursor: pointer;
4141
+ transition: color 0.15s, border-color 0.15s;
4142
+ margin-bottom: -1px;
4143
+ }
4144
+
4145
+ .results-tab:hover:not(.disabled) {
4146
+ color: var(--planner-text);
4147
+ }
4148
+
4149
+ .results-tab.active {
4150
+ color: var(--accent-primary);
4151
+ border-bottom-color: var(--accent-primary);
4152
+ }
4153
+
4154
+ .results-tab.disabled {
4155
+ opacity: 0.4;
4156
+ cursor: not-allowed;
4157
+ }
4158
+
4159
+ .results-tabs-meta {
4160
+ display: flex;
4161
+ align-items: center;
4162
+ gap: 8px;
4163
+ margin-left: auto;
4164
+ padding-bottom: 6px;
4165
+ }
4166
+
4167
+ /* Chart wrapper - fills remaining height */
4168
+ .results-chart-wrapper {
4169
+ flex: 1;
4170
+ overflow: hidden;
4171
+ min-height: 0;
4172
+ padding: 16px 8px 8px;
4173
+ display: flex;
4174
+ align-items: stretch;
4175
+ }
4176
+
4177
+ /* KPI cards */
4178
+ .kpi-cards {
4179
+ display: flex;
4180
+ flex-wrap: wrap;
4181
+ gap: 16px;
4182
+ align-items: flex-start;
4183
+ padding: 8px;
4184
+ }
4185
+
4186
+ .kpi-card {
4187
+ background: var(--planner-surface);
4188
+ border: 1px solid var(--planner-border);
4189
+ border-radius: var(--radius-lg);
4190
+ padding: 20px 28px;
4191
+ min-width: 160px;
4192
+ text-align: center;
4193
+ }
4194
+
4195
+ .kpi-label {
4196
+ font-size: 11px;
4197
+ font-weight: 600;
4198
+ color: var(--planner-text-muted);
4199
+ text-transform: uppercase;
4200
+ letter-spacing: 0.04em;
4201
+ margin-bottom: 8px;
4202
+ }
4203
+
4204
+ .kpi-value {
4205
+ font-size: 28px;
4206
+ font-weight: 700;
4207
+ color: var(--planner-text);
4208
+ font-family: var(--font-display);
4209
+ line-height: 1.1;
4210
+ }
4211
+
4212
+ .kpi-type {
4213
+ font-size: 10px;
4214
+ color: var(--planner-text-dim);
4215
+ margin-top: 6px;
4216
+ }
4217
+
4218
+ .chart-no-data {
4219
+ display: flex;
4220
+ align-items: center;
4221
+ justify-content: center;
4222
+ width: 100%;
4223
+ color: var(--planner-text-muted);
4224
+ font-size: 13px;
4225
+ }
4226
+
4227
+ /* =================================
4228
+ Small Multiples
4229
+ ================================= */
4230
+
4231
+ .small-multiples {
4232
+ display: flex;
4233
+ flex-direction: column;
4234
+ gap: 0;
4235
+ width: 100%;
4236
+ height: 100%;
4237
+ overflow-y: auto;
4238
+ }
4239
+
4240
+ .small-multiple {
4241
+ flex-shrink: 0;
4242
+ height: 220px;
4243
+ padding: 0 0 8px 0;
4244
+ border-bottom: 1px solid var(--planner-border);
4245
+ }
4246
+
4247
+ .small-multiple:last-child {
4248
+ border-bottom: none;
4249
+ }
4250
+
4251
+ .small-multiple-label {
4252
+ font-size: 11px;
4253
+ font-weight: 600;
4254
+ color: var(--planner-text-muted);
4255
+ padding: 8px 0 0 8px;
4256
+ text-transform: uppercase;
4257
+ letter-spacing: 0.04em;
4258
+ }
4259
+
4260
+ .small-multiple-chart {
4261
+ height: 188px;
4262
+ }
@@ -41,9 +41,8 @@ describe('<Root />', () => {
41
41
  expect(document.title).toEqual('DataJunction');
42
42
  });
43
43
 
44
- // Check navigation links exist
45
- expect(screen.getByText('Explore')).toBeInTheDocument();
46
- expect(screen.getByText('Query Planner')).toBeInTheDocument();
44
+ // Check navigation links exist (two "Explore" links: catalog browser + planner)
45
+ expect(screen.getAllByText('Explore')).toHaveLength(2);
47
46
  });
48
47
 
49
48
  it('renders Docs dropdown', async () => {
@@ -61,7 +61,7 @@ export function Root() {
61
61
  </span>
62
62
  <span className="menu-link">
63
63
  <span className="menu-title">
64
- <a href="/planner">Query Planner</a>
64
+ <a href="/planner">Explore</a>
65
65
  </span>
66
66
  </span>
67
67
  <span className="menu-link">
@@ -13,6 +13,9 @@ const DJ_GQL = process.env.REACT_APP_DJ_GQL
13
13
  // Export the base URL for components that need direct access
14
14
  export const getDJUrl = () => DJ_URL;
15
15
 
16
+ const QUERY_END_STATES = ['FINISHED', 'CANCELED', 'FAILED'];
17
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
+
16
19
  export const DataJunctionAPI = {
17
20
  listNodesForLanding: async function (
18
21
  namespace,
@@ -865,6 +868,11 @@ export const DataJunctionAPI = {
865
868
  upstreamNodes(nodeNames: $nodeNames) {
866
869
  name
867
870
  type
871
+ current {
872
+ parents {
873
+ name
874
+ }
875
+ }
868
876
  }
869
877
  }
870
878
  `;
@@ -886,6 +894,11 @@ export const DataJunctionAPI = {
886
894
  downstreamNodes(nodeNames: $nodeNames) {
887
895
  name
888
896
  type
897
+ current {
898
+ parents {
899
+ name
900
+ }
901
+ }
889
902
  }
890
903
  }
891
904
  `;
@@ -900,6 +913,39 @@ export const DataJunctionAPI = {
900
913
  return results.data?.downstreamNodes || [];
901
914
  },
902
915
 
916
+ // Batch-fetch cubes by name, returning each cube's name and its metric node names.
917
+ // Used to build the Sankey data flow graph without N individual cube fetches.
918
+ findCubesWithMetrics: async function (cubeNames) {
919
+ if (!cubeNames || cubeNames.length === 0) return [];
920
+ const query = `
921
+ query FindCubesWithMetrics($names: [String!]) {
922
+ findNodes(names: $names, nodeTypes: [CUBE]) {
923
+ name
924
+ current {
925
+ displayName
926
+ cubeMetrics {
927
+ name
928
+ }
929
+ }
930
+ }
931
+ }
932
+ `;
933
+ const results = await (
934
+ await fetch(DJ_GQL, {
935
+ method: 'POST',
936
+ headers: { 'Content-Type': 'application/json' },
937
+ credentials: 'include',
938
+ body: JSON.stringify({ query, variables: { names: cubeNames } }),
939
+ })
940
+ ).json();
941
+ return (results.data?.findNodes || []).map(n => ({
942
+ name: n.name,
943
+ display_name: n.current?.displayName || n.name,
944
+ type: 'cube',
945
+ parents: (n.current?.cubeMetrics || []).map(m => ({ name: m.name })),
946
+ }));
947
+ },
948
+
903
949
  node_dag: async function (name) {
904
950
  return await (
905
951
  await fetch(`${DJ_URL}/nodes/${name}/dag/`, {
@@ -1097,9 +1143,10 @@ export const DataJunctionAPI = {
1097
1143
  ).json();
1098
1144
  },
1099
1145
 
1100
- nodesWithDimension: async function (name) {
1146
+ nodesWithDimension: async function (name, nodeType = null) {
1147
+ const params = nodeType ? `?node_type=${nodeType}` : '';
1101
1148
  return await (
1102
- await fetch(`${DJ_URL}/dimensions/${name}/nodes/`, {
1149
+ await fetch(`${DJ_URL}/dimensions/${name}/nodes/${params}`, {
1103
1150
  credentials: 'include',
1104
1151
  })
1105
1152
  ).json();
@@ -1173,33 +1220,35 @@ export const DataJunctionAPI = {
1173
1220
  metricsV3: async function (
1174
1221
  metricSelection,
1175
1222
  dimensionSelection,
1176
- filters = '',
1223
+ filters = [],
1177
1224
  useMaterialized = true,
1225
+ dialect = null,
1178
1226
  ) {
1179
1227
  const params = new URLSearchParams();
1180
1228
  metricSelection.forEach(metric => params.append('metrics', metric));
1181
1229
  dimensionSelection.forEach(dimension =>
1182
1230
  params.append('dimensions', dimension),
1183
1231
  );
1184
- if (filters) {
1185
- params.append('filters', filters);
1186
- }
1187
- if (useMaterialized) {
1188
- params.append('use_materialized', 'true');
1189
- params.append('dialect', 'druid');
1190
- } else {
1191
- params.append('use_materialized', 'false');
1192
- params.append('dialect', 'spark');
1232
+ if (filters && filters.length > 0) {
1233
+ filters.forEach(f => params.append('filters', f));
1193
1234
  }
1235
+ const resolvedDialect = dialect || (useMaterialized ? 'druid' : 'trino');
1236
+ params.append('use_materialized', useMaterialized ? 'true' : 'false');
1237
+ params.append('dialect', resolvedDialect);
1194
1238
  return await (
1195
1239
  await fetch(`${DJ_URL}/sql/metrics/v3/?${params}`, {
1196
1240
  credentials: 'include',
1197
- params: params,
1198
1241
  })
1199
1242
  ).json();
1200
1243
  },
1201
1244
 
1202
- data: async function (metricSelection, dimensionSelection, filters = []) {
1245
+ data: async function (
1246
+ metricSelection,
1247
+ dimensionSelection,
1248
+ filters = [],
1249
+ dialect = null,
1250
+ onProgress = null,
1251
+ ) {
1203
1252
  const params = new URLSearchParams();
1204
1253
  metricSelection.map(metric => params.append('metrics', metric));
1205
1254
  dimensionSelection.map(dimension => params.append('dimensions', dimension));
@@ -1207,18 +1256,56 @@ export const DataJunctionAPI = {
1207
1256
  filters.forEach(f => params.append('filters', f));
1208
1257
  }
1209
1258
  params.append('limit', '10000');
1210
- const response = await fetch(`${DJ_URL}/data/?${params}`, {
1259
+ params.append('async_', 'true');
1260
+ params.append('dialect', dialect || 'trino');
1261
+
1262
+ let pollInterval = 1000;
1263
+
1264
+ // Submit the query once
1265
+ const submitResponse = await fetch(`${DJ_URL}/data/?${params}`, {
1211
1266
  credentials: 'include',
1212
1267
  });
1213
- if (!response.ok) {
1214
- const errorData = await response.json().catch(() => ({}));
1268
+ if (!submitResponse.ok) {
1269
+ const errorData = await submitResponse.json().catch(() => ({}));
1215
1270
  throw new Error(
1216
1271
  errorData.message ||
1217
1272
  errorData.detail ||
1218
- `Query failed: ${response.status}`,
1273
+ `Query failed: ${submitResponse.status}`,
1219
1274
  );
1220
1275
  }
1221
- return await response.json();
1276
+ let results = await submitResponse.json();
1277
+
1278
+ // Report links from the first response so they're visible during polling
1279
+ if (onProgress && results.links?.length > 0) {
1280
+ onProgress({ links: results.links });
1281
+ }
1282
+
1283
+ // Poll by query ID using GET /data/query/{id} to avoid re-submitting
1284
+ while (!QUERY_END_STATES.includes(results.state)) {
1285
+ await sleep(pollInterval);
1286
+ pollInterval = Math.min(pollInterval * 2, 10000);
1287
+
1288
+ const pollResponse = await fetch(`${DJ_URL}/data/query/${results.id}`, {
1289
+ credentials: 'include',
1290
+ });
1291
+ if (!pollResponse.ok) {
1292
+ const errorData = await pollResponse.json().catch(() => ({}));
1293
+ throw new Error(
1294
+ errorData.message ||
1295
+ errorData.detail ||
1296
+ `Query poll failed: ${pollResponse.status}`,
1297
+ );
1298
+ }
1299
+ results = await pollResponse.json();
1300
+ }
1301
+
1302
+ if (results.state === 'CANCELED') {
1303
+ throw new Error('Query execution was canceled');
1304
+ }
1305
+ if (results.state === 'FAILED') {
1306
+ throw new Error(results.errors?.[0] || 'Query execution failed');
1307
+ }
1308
+ return results;
1222
1309
  },
1223
1310
 
1224
1311
  nodeData: async function (nodeName, selection = null) {
@@ -1444,6 +1531,13 @@ export const DataJunctionAPI = {
1444
1531
  })
1445
1532
  ).json();
1446
1533
  },
1534
+ dimensionDag: async function (nodeName) {
1535
+ return await (
1536
+ await fetch(`${DJ_URL}/nodes/${nodeName}/dimension-dag/`, {
1537
+ credentials: 'include',
1538
+ })
1539
+ ).json();
1540
+ },
1447
1541
  linkDimension: async function (nodeName, columnName, dimensionName) {
1448
1542
  const response = await fetch(
1449
1543
  `${DJ_URL}/nodes/${nodeName}/columns/${columnName}?dimension=${dimensionName}`,
@@ -1928,11 +2022,18 @@ export const DataJunctionAPI = {
1928
2022
 
1929
2023
  // ===== My Workspace GraphQL Queries =====
1930
2024
 
1931
- getWorkspaceRecentlyEdited: async function (username, limit = 10) {
2025
+ getWorkspaceRecentlyEdited: async function (
2026
+ username,
2027
+ limit = 10,
2028
+ nodeType = null,
2029
+ ) {
1932
2030
  // Nodes the user has edited, ordered by last updated (excluding source nodes)
1933
2031
  const query = `
1934
2032
  query RecentlyEdited($editedBy: String!, $limit: Int!, $nodeTypes: [NodeType!]) {
1935
2033
  findNodesPaginated(editedBy: $editedBy, limit: $limit, nodeTypes: $nodeTypes, orderBy: UPDATED_AT, ascending: false) {
2034
+ pageInfo {
2035
+ hasNextPage
2036
+ }
1936
2037
  edges {
1937
2038
  node {
1938
2039
  name
@@ -1957,6 +2058,7 @@ export const DataJunctionAPI = {
1957
2058
  }
1958
2059
  }
1959
2060
  `;
2061
+ const allTypes = ['TRANSFORM', 'METRIC', 'DIMENSION', 'CUBE'];
1960
2062
  return await (
1961
2063
  await fetch(DJ_GQL, {
1962
2064
  method: 'POST',
@@ -1967,18 +2069,25 @@ export const DataJunctionAPI = {
1967
2069
  variables: {
1968
2070
  editedBy: username,
1969
2071
  limit,
1970
- nodeTypes: ['TRANSFORM', 'METRIC', 'DIMENSION', 'CUBE'],
2072
+ nodeTypes: nodeType ? [nodeType] : allTypes,
1971
2073
  },
1972
2074
  }),
1973
2075
  })
1974
2076
  ).json();
1975
2077
  },
1976
2078
 
1977
- getWorkspaceOwnedNodes: async function (username, limit = 10) {
2079
+ getWorkspaceOwnedNodes: async function (
2080
+ username,
2081
+ limit = 10,
2082
+ nodeType = null,
2083
+ ) {
1978
2084
  // Owned nodes ordered by UPDATED_AT (excluding source nodes)
1979
2085
  const query = `
1980
2086
  query OwnedNodes($ownedBy: String!, $limit: Int!, $nodeTypes: [NodeType!]) {
1981
2087
  findNodesPaginated(ownedBy: $ownedBy, limit: $limit, nodeTypes: $nodeTypes, orderBy: UPDATED_AT, ascending: false) {
2088
+ pageInfo {
2089
+ hasNextPage
2090
+ }
1982
2091
  edges {
1983
2092
  node {
1984
2093
  name
@@ -2003,6 +2112,7 @@ export const DataJunctionAPI = {
2003
2112
  }
2004
2113
  }
2005
2114
  `;
2115
+ const allTypes = ['TRANSFORM', 'METRIC', 'DIMENSION', 'CUBE'];
2006
2116
  return await (
2007
2117
  await fetch(DJ_GQL, {
2008
2118
  method: 'POST',
@@ -2013,7 +2123,7 @@ export const DataJunctionAPI = {
2013
2123
  variables: {
2014
2124
  ownedBy: username,
2015
2125
  limit,
2016
- nodeTypes: ['TRANSFORM', 'METRIC', 'DIMENSION', 'CUBE'],
2126
+ nodeTypes: nodeType ? [nodeType] : allTypes,
2017
2127
  },
2018
2128
  }),
2019
2129
  })