datajunction-ui 0.0.31 → 0.0.34

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.
package/TODO.md ADDED
@@ -0,0 +1,265 @@
1
+ # UI Test Improvements TODO
2
+
3
+ ## Phase 1: Fix Test Warnings (Completed)
4
+
5
+ ### 1.1 Fix `act()` Warnings ✅
6
+
7
+ - [x] Wrap async state updates in `waitFor()` properly
8
+ - [x] Add proper cleanup/waiting in tests for components with async effects
9
+ - [x] Fixed: SQLBuilderPage, NotificationsPage, CubeBuilderPage tests
10
+ - [ ] Remaining: Some AddEditNodePage/AddEditTagPage warnings (minor, from Formik internals)
11
+
12
+ ### 1.2 Fix Key Prop Warnings ✅
13
+
14
+ - [x] Fix `key={rowValue}` in SQLBuilderPage (line 185) - use index-based key
15
+ - [x] Fix `key={rowValue}` in NodeValidateTab (line 346) - use index-based key
16
+ - [x] Fix missing key in QueryInfo.jsx error mapping (line 125)
17
+ - [x] Fix incorrect mock data in Collapse.test.jsx causing undefined keys
18
+ - [x] Fix missing key in NodeInfoTab.jsx: nodeTags, primary_key, required_dimensions, owners
19
+
20
+ ### 1.3 Fix DOM/React Warnings ✅
21
+
22
+ - [x] Fix `value` prop on `textarea` should not be null - changed `custom_metadata: null` to `''` in AddEditNodePage
23
+ - [x] Fix invalid DOM property `class` -> `className` in AddEditTagPage
24
+ - [x] Fix nested elements: changed `<div>` to `<span>` in NodeInfoTab nodeTags
25
+ - [x] Fix nested `<p>` tags: changed outer `<p>` to `<div>` in ListGroupItem (Markdown can render `<p>`)
26
+
27
+ ---
28
+
29
+ ## Phase 2: GraphQL Type Safety (In Progress)
30
+
31
+ ### Goal
32
+
33
+ Migrate all REST GET endpoints to GraphQL for:
34
+
35
+ - Field selection (fetch only what's needed)
36
+ - Type safety via GraphQL codegen
37
+ - Single source of truth for read operations
38
+
39
+ Keep REST for mutations (POST/PATCH/DELETE) since they're already implemented and mutations are simpler.
40
+
41
+ ### Completed ✅
42
+
43
+ #### 2.1 Type Generation Setup
44
+
45
+ - [x] Added `@graphql-codegen/cli`, `@graphql-codegen/typescript`, `@graphql-codegen/typescript-operations`
46
+ - [x] Added `openapi-typescript` for REST type generation
47
+ - [x] Created `codegen.yml` pointing to static `schema.graphql` (no live server needed)
48
+ - [x] Added scripts: `yarn codegen`, `yarn codegen:graphql`, `yarn codegen:openapi`, `yarn codegen:check`
49
+ - [x] Generated types:
50
+ - `src/types/graphql.ts` (GraphQL types + query operation types)
51
+ - `src/types/openapi.ts` (REST endpoint types)
52
+ - [x] Server Makefile updated with `make generate-schemas` and `make check-schemas`
53
+
54
+ #### 2.2 GraphQL Query Files
55
+
56
+ - [x] Created `src/graphql/queries/` directory with 10 `.graphql` files:
57
+ - `listNodesForLanding.graphql`
58
+ - `listCubesForPreset.graphql`
59
+ - `cubeForPlanner.graphql`
60
+ - `getNodeForEditing.graphql`
61
+ - `getNodesByNames.graphql`
62
+ - `getMetric.graphql`
63
+ - `getCubeForEditing.graphql`
64
+ - `upstreamNodes.graphql`
65
+ - `downstreamNodes.graphql`
66
+ - `getNodeColumnsWithPartitions.graphql`
67
+
68
+ #### 2.3 Typed GraphQL Service
69
+
70
+ - [x] Created `src/app/services/DJGraphQLService.ts` - fully typed GraphQL service
71
+ - [x] All 10 existing GraphQL methods are now typed
72
+ - [x] DJService.js GraphQL methods now delegate to DJGraphQLService.ts
73
+ - [x] Components continue to use djClient context pattern (no changes needed)
74
+
75
+ ### Usage
76
+
77
+ Components continue to use the existing djClient pattern - no changes needed:
78
+
79
+ ```jsx
80
+ // Existing pattern (still works, now backed by typed service)
81
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
82
+ const node = await djClient.getNodeForEditing('my.node');
83
+ ```
84
+
85
+ For direct typed access (optional):
86
+
87
+ ```typescript
88
+ import { DJGraphQLService } from '../services/DJGraphQLService';
89
+ import type { GetNodeForEditingQuery } from '../../types/graphql';
90
+
91
+ const node = await DJGraphQLService.getNodeForEditing('my.node');
92
+ // node.name, node.type, node.current.displayName are all typed
93
+ ```
94
+
95
+ ### Development Workflow
96
+
97
+ **Schema files (committed to repo):**
98
+ | File | Source |
99
+ |------|--------|
100
+ | `openapi.json` (monorepo root) | Server OpenAPI spec |
101
+ | `datajunction-server/.../schema.graphql` | Server GraphQL schema |
102
+ | `datajunction-ui/src/types/graphql.ts` | Generated GraphQL types |
103
+ | `datajunction-ui/src/types/openapi.ts` | Generated REST types |
104
+
105
+ **When you change the server API:**
106
+
107
+ ```bash
108
+ cd datajunction-server
109
+ make generate-schemas # Regenerates openapi.json + schema.graphql
110
+ git add ../openapi.json datajunction_server/api/graphql/schema.graphql
111
+ ```
112
+
113
+ **When you change UI GraphQL queries or pull server changes:**
114
+
115
+ ```bash
116
+ cd datajunction-ui
117
+ yarn codegen # Regenerates graphql.ts + openapi.ts
118
+ git add src/types/
119
+ ```
120
+
121
+ **CI validation:**
122
+
123
+ - Server: `make check` includes `make check-schemas` → fails if schemas are stale
124
+ - UI: `yarn codegen:check` → fails if generated types are stale
125
+
126
+ ### Current State
127
+
128
+ **Typed GraphQL methods (in DJGraphQLService.ts):**
129
+ | Method | Purpose |
130
+ |--------|---------|
131
+ | `listNodesForLanding` | Paginated node listing |
132
+ | `listCubesForPreset` | Cube dropdown |
133
+ | `cubeForPlanner` | Query planner page |
134
+ | `getNodeForEditing` | Edit node form |
135
+ | `getNodesByNames` | Batch node fetch |
136
+ | `getMetric` | Metric details |
137
+ | `getCubeForEditing` | Edit cube form |
138
+ | `upstreamNodes` | Upstream nodes |
139
+ | `downstreamNodes` | Downstream nodes |
140
+ | `getNodeColumnsWithPartitions` | Column partitions |
141
+
142
+ ### REST GET Endpoints to Migrate (~45 methods)
143
+
144
+ #### High Priority (core functionality, frequently used)
145
+
146
+ - [ ] `node` - GET /nodes/{name}/ → Full node details page
147
+ - [ ] `nodes` - GET /nodes/?prefix= → Node listings
148
+ - [ ] `nodesWithType` - GET /nodes/?node_type= → Filtered node lists
149
+ - [ ] `metric` - GET /metrics/{name}/ → Metric details (REST version)
150
+ - [ ] `metrics` - GET /metrics/ → All metrics list
151
+ - [ ] `cube` - GET /cubes/{name}/ → Full cube details
152
+ - [ ] `commonDimensions` - GET /metrics/common/dimensions/ → Dimension lookups
153
+ - [ ] `upstreams` - GET /nodes/{name}/upstream/ → (has GQL version, deprecate REST)
154
+ - [ ] `downstreams` - GET /nodes/{name}/downstream/ → (has GQL version, deprecate REST)
155
+ - [ ] `history` - GET /history?node= → Activity history
156
+ - [ ] `revisions` - GET /nodes/{name}/revisions/ → Node revision history
157
+
158
+ #### Medium Priority (namespace/catalog/tags)
159
+
160
+ - [ ] `namespaces` - GET /namespaces/ → Namespace list
161
+ - [ ] `namespace` - GET /namespaces/{nmspce} → Namespace details
162
+ - [ ] `namespaceSources` - GET /namespaces/{namespace}/sources
163
+ - [ ] `catalogs` - GET /catalogs → Catalog list
164
+ - [ ] `engines` - GET /engines → Engine list
165
+ - [ ] `dimensions` - GET /dimensions → All dimensions
166
+ - [ ] `nodeDimensions` - GET /nodes/{nodeName}/dimensions
167
+ - [ ] `nodesWithDimension` - GET /dimensions/{name}/nodes/
168
+ - [ ] `attributes` - GET /attributes
169
+ - [ ] `listTags` - GET /tags → Tag list
170
+ - [ ] `getTag` - GET /tags/{tagName} → Tag details
171
+ - [ ] `listNodesForTag` - GET /tags/{tagName}/nodes
172
+ - [ ] `users` - GET /users?with_activity=true
173
+
174
+ #### Medium Priority (materializations)
175
+
176
+ - [ ] `materializations` - GET /nodes/{node}/materializations
177
+ - [ ] `availabilityStates` - GET /nodes/{node}/availability/
178
+ - [ ] `materializationInfo` - GET /materialization/info
179
+ - [ ] `listPreaggs` - GET /preaggs/
180
+ - [ ] `getPreagg` - GET /preaggs/{preaggId}
181
+ - [ ] `getCubeDetails` - GET /cubes/{cubeName}
182
+
183
+ #### Lower Priority (SQL/data - may need special handling)
184
+
185
+ - [ ] `sql` - GET /sql/{metric_name}?... → Generated SQL
186
+ - [ ] `sqls` - GET /sql/?metrics=&dimensions= → Multi-metric SQL
187
+ - [ ] `measuresV3` - GET /sql/measures/v3/ → V3 measures SQL
188
+ - [ ] `metricsV3` - GET /sql/metrics/v3/ → V3 metrics SQL
189
+ - [ ] `data` - GET /data/?metrics=&dimensions= → Query execution
190
+ - [ ] `nodeData` - GET /data/{nodeName}? → Node data query
191
+ - [ ] `compiledSql` - GET /sql/{node}/ → Compiled SQL
192
+
193
+ #### Lower Priority (system metrics/admin)
194
+
195
+ - [ ] `querySystemMetric` - GET /system/data/{metric} → Dashboard analytics
196
+ - [ ] `system.node_counts_by_*` - System metrics
197
+ - [ ] `system.dimensions` - GET /system/dimensions
198
+ - [ ] `nodeDetails` - GET /nodes/details/
199
+ - [ ] `node_dag` - GET /nodes/{name}/dag/
200
+ - [ ] `node_lineage` - GET /nodes/{name}/lineage/
201
+ - [ ] `listDeployments` - GET /deployments
202
+ - [ ] `listMetricMetadata` - GET /metrics/metadata
203
+
204
+ #### Lower Priority (notifications/export)
205
+
206
+ - [ ] `getNotificationPreferences` - GET /notifications/
207
+ - [ ] `getSubscribedHistory` - GET /history/?only_subscribed=true
208
+ - [ ] `listServiceAccounts` - GET /service-accounts
209
+ - [ ] `clientCode` - GET /datajunction-clients/python/new_node/{name}
210
+ - [ ] `notebookExportCube` - GET /datajunction-clients/python/notebook/?cube=
211
+ - [ ] `notebookExportNamespace` - GET /datajunction-clients/python/notebook/?namespace=
212
+
213
+ #### Keep as REST (streaming - EventSource)
214
+
215
+ - `stream` - EventSource /stream/... (GraphQL subscriptions are different tech)
216
+ - `streamNodeData` - EventSource /stream/{nodeName}...
217
+
218
+ ### Implementation Tasks
219
+
220
+ 1. **Server-side: Extend GraphQL schema**
221
+
222
+ - [ ] Ensure all required fields are available in GraphQL schema
223
+ - [ ] Add any missing queries to the GraphQL API
224
+ - [ ] Verify introspection is enabled for codegen
225
+
226
+ 2. **UI-side: Set up GraphQL codegen**
227
+
228
+ - [ ] Add `@graphql-codegen/cli` and `@graphql-codegen/typescript` as dev dependencies
229
+ - [ ] Create `codegen.yml` configuration
230
+ - [ ] Add npm script: `yarn generate-types`
231
+ - [ ] Generate types to `src/types/graphql.ts`
232
+
233
+ 3. **UI-side: Migrate DJService methods**
234
+
235
+ - [ ] Start with high-priority methods
236
+ - [ ] Create GraphQL queries in `src/graphql/queries/`
237
+ - [ ] Update DJService.js methods to use GraphQL
238
+ - [ ] Remove deprecated REST calls
239
+
240
+ 4. **Type the mutation payloads**
241
+ - [ ] Keep OpenAPI types for REST mutations
242
+ - [ ] Add `openapi-typescript` for mutation request/response types
243
+ - [ ] Generate to `src/types/api-mutations.ts`
244
+
245
+ ### Benefits
246
+
247
+ - **Field selection**: UI fetches only needed data
248
+ - **Type safety**: Compile-time errors when code doesn't match schema
249
+ - **Single type source for reads**: GraphQL schema is authoritative
250
+ - **Performance**: Already proven with `cubeForPlanner`, `listNodesForLanding`
251
+
252
+ ---
253
+
254
+ ## Phase 3: Integration Tests (Long-term)
255
+
256
+ ### Goal
257
+
258
+ Add integration test suite that runs against a real DJ server to catch real integration issues.
259
+
260
+ ### Tasks
261
+
262
+ - [ ] Add Docker Compose setup for test DJ server
263
+ - [ ] Create separate test suite for integration tests
264
+ - [ ] Add CI workflow to run integration tests
265
+ - [ ] Consider using Playwright or Cypress for E2E tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.31",
3
+ "version": "0.0.34",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -9,14 +9,14 @@ export default class ListGroupItem extends Component {
9
9
  <div className="d-flex gap-2 w-100 justify-content-between py-3">
10
10
  <div>
11
11
  <h6 className="mb-0 w-100">{label}</h6>
12
- <p
12
+ <div
13
13
  className="mb-0 opacity-75"
14
14
  role="dialog"
15
15
  aria-hidden="false"
16
16
  aria-label={label}
17
17
  >
18
18
  <Markdown>{value}</Markdown>
19
- </p>
19
+ </div>
20
20
  </div>
21
21
  </div>
22
22
  </div>
@@ -122,8 +122,9 @@ export default function QueryInfo({
122
122
  <li className={'query-info'}>
123
123
  <label>Logs</label>{' '}
124
124
  {errors?.length ? (
125
- errors.map(error => (
125
+ errors.map((error, idx) => (
126
126
  <div
127
+ key={`error-${idx}`}
127
128
  style={{
128
129
  height: '800px',
129
130
  width: '80%',
@@ -13,7 +13,7 @@ exports[`<ListGroupItem /> should render and match the snapshot 1`] = `
13
13
  >
14
14
  Name
15
15
  </h6>
16
- <p
16
+ <div
17
17
  aria-hidden="false"
18
18
  aria-label="Name"
19
19
  className="mb-0 opacity-75"
@@ -24,7 +24,7 @@ exports[`<ListGroupItem /> should render and match the snapshot 1`] = `
24
24
  Something
25
25
  </span>
26
26
  </Markdown>
27
- </p>
27
+ </div>
28
28
  </div>
29
29
  </div>
30
30
  </div>
@@ -35,10 +35,13 @@ describe('<Collapse />', () => {
35
35
  <Collapse
36
36
  {...defaultProps}
37
37
  data={{
38
+ name: 'test.transform',
38
39
  type: 'transform',
39
- column_names: Array(11).fill(idx => {
40
- return `column-${idx}`;
41
- }),
40
+ column_names: Array.from({ length: 11 }, (_, idx) => ({
41
+ name: `column_${idx}`,
42
+ type: 'string',
43
+ order: idx,
44
+ })),
42
45
  primary_key: [],
43
46
  }}
44
47
  />
@@ -53,7 +53,7 @@ export function AddEditNodePage({ extensions = {} }) {
53
53
  primary_key: '',
54
54
  mode: 'published',
55
55
  owners: [],
56
- custom_metadata: null,
56
+ custom_metadata: '',
57
57
  };
58
58
 
59
59
  const validator = values => {
@@ -90,7 +90,7 @@ export function AddEditTagPage() {
90
90
  name="display_name"
91
91
  id="display_name"
92
92
  placeholder="Display Name"
93
- class="FullNameField"
93
+ className="FullNameField"
94
94
  />
95
95
  </div>
96
96
  <br />
@@ -185,30 +185,48 @@ describe('CubeBuilderPage', () => {
185
185
  jest.clearAllMocks();
186
186
  });
187
187
 
188
- it('renders without crashing', () => {
188
+ it('renders without crashing', async () => {
189
189
  render(
190
190
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
191
191
  <CubeBuilderPage />
192
192
  </DJClientContext.Provider>,
193
193
  );
194
+
195
+ // Wait for async effects to complete
196
+ await waitFor(() => {
197
+ expect(mockDjClient.metrics).toHaveBeenCalled();
198
+ });
199
+
194
200
  expect(screen.getByText('Cube')).toBeInTheDocument();
195
201
  });
196
202
 
197
- it('renders the Metrics section', () => {
203
+ it('renders the Metrics section', async () => {
198
204
  render(
199
205
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
200
206
  <CubeBuilderPage />
201
207
  </DJClientContext.Provider>,
202
208
  );
209
+
210
+ // Wait for async effects to complete
211
+ await waitFor(() => {
212
+ expect(mockDjClient.metrics).toHaveBeenCalled();
213
+ });
214
+
203
215
  expect(screen.getByText('Metrics *')).toBeInTheDocument();
204
216
  });
205
217
 
206
- it('renders the Dimensions section', () => {
218
+ it('renders the Dimensions section', async () => {
207
219
  render(
208
220
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
209
221
  <CubeBuilderPage />
210
222
  </DJClientContext.Provider>,
211
223
  );
224
+
225
+ // Wait for async effects to complete
226
+ await waitFor(() => {
227
+ expect(mockDjClient.metrics).toHaveBeenCalled();
228
+ });
229
+
212
230
  expect(screen.getByText('Dimensions *')).toBeInTheDocument();
213
231
  });
214
232
 
@@ -237,22 +255,31 @@ describe('CubeBuilderPage', () => {
237
255
  }
238
256
  fireEvent.click(screen.getAllByText('Dimensions *')[0]);
239
257
 
240
- expect(mockDjClient.commonDimensions).toHaveBeenCalled();
258
+ // Wait for commonDimensions to be called and state to update
259
+ await waitFor(() => {
260
+ expect(mockDjClient.commonDimensions).toHaveBeenCalled();
261
+ });
241
262
 
242
263
  const selectDimensions = screen.getAllByTestId('select-dimensions')[0];
243
264
  expect(selectDimensions).toBeDefined();
244
265
  expect(selectDimensions).not.toBeNull();
245
- expect(
246
- screen.getByText(
247
- 'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
248
- ),
249
- ).toBeInTheDocument();
266
+
267
+ await waitFor(() => {
268
+ expect(
269
+ screen.getByText(
270
+ 'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
271
+ ),
272
+ ).toBeInTheDocument();
273
+ });
250
274
 
251
275
  const selectDimensionsDate = screen.getAllByTestId(
252
276
  'dimensions-default.date_dim',
253
277
  )[0];
254
278
 
255
279
  fireEvent.keyDown(selectDimensionsDate.firstChild, { key: 'ArrowDown' });
280
+ await waitFor(() => {
281
+ expect(screen.getByText('Day')).toBeInTheDocument();
282
+ });
256
283
  fireEvent.click(screen.getByText('Day'));
257
284
  fireEvent.click(screen.getByText('Month'));
258
285
  fireEvent.click(screen.getByText('Year'));
@@ -264,9 +291,8 @@ describe('CubeBuilderPage', () => {
264
291
  })[0];
265
292
  expect(createCube).toBeInTheDocument();
266
293
 
267
- await waitFor(() => {
268
- fireEvent.click(createCube);
269
- });
294
+ fireEvent.click(createCube);
295
+
270
296
  await waitFor(() => {
271
297
  expect(mockDjClient.createCube).toHaveBeenCalledWith(
272
298
  '',
@@ -322,22 +348,31 @@ describe('CubeBuilderPage', () => {
322
348
 
323
349
  fireEvent.click(screen.getAllByText('Dimensions *')[0]);
324
350
 
325
- expect(mockDjClient.commonDimensions).toHaveBeenCalled();
351
+ // Wait for commonDimensions to be called and state to update
352
+ await waitFor(() => {
353
+ expect(mockDjClient.commonDimensions).toHaveBeenCalled();
354
+ });
326
355
 
327
356
  const selectDimensions = screen.getAllByTestId('select-dimensions')[0];
328
357
  expect(selectDimensions).toBeDefined();
329
358
  expect(selectDimensions).not.toBeNull();
330
- expect(
331
- screen.getByText(
332
- 'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
333
- ),
334
- ).toBeInTheDocument();
359
+
360
+ await waitFor(() => {
361
+ expect(
362
+ screen.getByText(
363
+ 'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
364
+ ),
365
+ ).toBeInTheDocument();
366
+ });
335
367
 
336
368
  const selectDimensionsDate = screen.getAllByTestId(
337
369
  'dimensions-default.date_dim',
338
370
  )[0];
339
371
 
340
372
  fireEvent.keyDown(selectDimensionsDate.firstChild, { key: 'ArrowDown' });
373
+ await waitFor(() => {
374
+ expect(screen.getByText('Day')).toBeInTheDocument();
375
+ });
341
376
  fireEvent.click(screen.getByText('Day'));
342
377
  fireEvent.click(screen.getByText('Month'));
343
378
  fireEvent.click(screen.getByText('Year'));
@@ -349,9 +384,8 @@ describe('CubeBuilderPage', () => {
349
384
  })[0];
350
385
  expect(createCube).toBeInTheDocument();
351
386
 
352
- await waitFor(() => {
353
- fireEvent.click(createCube);
354
- });
387
+ fireEvent.click(createCube);
388
+
355
389
  await waitFor(() => {
356
390
  expect(mockDjClient.patchCube).toHaveBeenCalledWith(
357
391
  'default.repair_orders_cube',
@@ -37,9 +37,13 @@ export default function NodeInfoTab({ node }) {
37
37
  const [metricInfo, setMetricInfo] = useState(null);
38
38
 
39
39
  const nodeTags = node?.tags.map(tag => (
40
- <div className={'badge tag_value'}>
40
+ <span
41
+ key={tag.name}
42
+ className={'badge tag_value'}
43
+ style={{ marginRight: '4px' }}
44
+ >
41
45
  <a href={`/tags/${tag.name}`}>{tag.display_name}</a>
42
- </div>
46
+ </span>
43
47
  ));
44
48
  const djClient = useContext(DJClientContext).DataJunctionAPI;
45
49
 
@@ -314,13 +318,19 @@ export default function NodeInfoTab({ node }) {
314
318
  }
315
319
  >
316
320
  {node?.type !== 'metric'
317
- ? node?.primary_key?.map(dim => (
318
- <span className="rounded-pill badge bg-secondary-soft PrimaryKey">
321
+ ? node?.primary_key?.map((dim, idx) => (
322
+ <span
323
+ key={`pk-${idx}`}
324
+ className="rounded-pill badge bg-secondary-soft PrimaryKey"
325
+ >
319
326
  <a href={`/nodes/${node?.name}`}>{dim}</a>
320
327
  </span>
321
328
  ))
322
- : node?.required_dimensions?.map(dim => (
323
- <span className="rounded-pill badge bg-secondary-soft PrimaryKey">
329
+ : node?.required_dimensions?.map((dim, idx) => (
330
+ <span
331
+ key={`rd-${idx}`}
332
+ className="rounded-pill badge bg-secondary-soft PrimaryKey"
333
+ >
324
334
  <a href={`/nodes/${node?.upstream_node}`}>{dim.name}</a>
325
335
  </span>
326
336
  ))}
@@ -341,6 +351,7 @@ export default function NodeInfoTab({ node }) {
341
351
  <p className="mb-0 opacity-75">
342
352
  {node?.owners.map(owner => (
343
353
  <span
354
+ key={owner.username}
344
355
  className="badge node_type__transform"
345
356
  style={{ margin: '2px', fontSize: '16px', cursor: 'pointer' }}
346
357
  >
@@ -11,6 +11,11 @@ import AvailabilityStateBlock from './AvailabilityStateBlock';
11
11
 
12
12
  const cronstrue = require('cronstrue');
13
13
 
14
+ /**
15
+ * Cube materialization tab - shows cube-specific materializations.
16
+ * For non-cube nodes, the parent component (index.jsx) renders
17
+ * NodePreAggregationsTab instead.
18
+ */
14
19
  export default function NodeMaterializationTab({ node, djClient }) {
15
20
  const [rawMaterializations, setRawMaterializations] = useState([]);
16
21
  const [selectedRevisionTab, setSelectedRevisionTab] = useState(null);