datajunction-ui 0.0.1-a70 → 0.0.1-a71

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.1a70",
3
+ "version": "0.0.1a71",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  const AddItemIcon = props => (
2
2
  <svg
3
- enable-background="new 0 0 512 512"
3
+ enableBackground="new 0 0 512 512"
4
4
  height="20px"
5
5
  id="Layer_1"
6
6
  version="1.1"
@@ -10,7 +10,7 @@ const Explorer = ({ item = [], current }) => {
10
10
  useEffect(() => {
11
11
  setItems(item);
12
12
  setHighlight(current);
13
- if (current === undefined || current?.startsWith(item.path)) {
13
+ if (current !== undefined && current?.startsWith(item.path)) {
14
14
  setExpand(true);
15
15
  } else setExpand(false);
16
16
  }, [current, item]);
@@ -43,8 +43,12 @@ const Explorer = ({ item = [], current }) => {
43
43
  marginLeft: '1rem',
44
44
  borderLeft: '1px solid rgb(218 233 255)',
45
45
  }}
46
+ key={index}
46
47
  >
47
- <div className={`${expand ? '' : 'inactive'}`}>
48
+ <div
49
+ className={`${expand ? '' : 'inactive'}`}
50
+ key={`nested-${index}`}
51
+ >
48
52
  <Explorer item={item} current={highlight} />
49
53
  </div>
50
54
  </div>
@@ -3,8 +3,11 @@ import Control from './FieldControl';
3
3
 
4
4
  export default function NodeTypeSelect({ onChange }) {
5
5
  return (
6
- <span className="menu-link" style={{ marginLeft: '30px', width: '300px' }}
7
- data-testid="select-node-type">
6
+ <span
7
+ className="menu-link"
8
+ style={{ marginLeft: '30px', width: '300px' }}
9
+ data-testid="select-node-type"
10
+ >
8
11
  <Select
9
12
  name="node_type"
10
13
  isClearable
@@ -15,11 +18,11 @@ export default function NodeTypeSelect({ onChange }) {
15
18
  control: styles => ({ ...styles, backgroundColor: 'white' }),
16
19
  }}
17
20
  options={[
18
- {value: 'source', label: 'Source'},
19
- {value: 'transform', label: 'Transform'},
20
- {value: 'dimension', label: 'Dimension'},
21
- {value: 'metric', label: 'Metric'},
22
- {value: 'cube', label: 'Cube'},
21
+ { value: 'source', label: 'Source' },
22
+ { value: 'transform', label: 'Transform' },
23
+ { value: 'dimension', label: 'Dimension' },
24
+ { value: 'metric', label: 'Metric' },
25
+ { value: 'cube', label: 'Cube' },
23
26
  ]}
24
27
  />
25
28
  </span>
@@ -20,7 +20,11 @@ export default function TagSelect({ onChange }) {
20
20
  }, [djClient]);
21
21
 
22
22
  return (
23
- <span className="menu-link" style={{ marginLeft: '30px', width: '350px' }} data-testid="select-tag">
23
+ <span
24
+ className="menu-link"
25
+ style={{ marginLeft: '30px', width: '350px' }}
26
+ data-testid="select-tag"
27
+ >
24
28
  <Select
25
29
  name="tags"
26
30
  isClearable
@@ -19,7 +19,11 @@ export default function UserSelect({ onChange, currentUser }) {
19
19
  }, [djClient]);
20
20
 
21
21
  return (
22
- <span className="menu-link" style={{ marginLeft: '30px', width: '400px' }} data-testid="select-user">
22
+ <span
23
+ className="menu-link"
24
+ style={{ marginLeft: '30px', width: '400px' }}
25
+ data-testid="select-user"
26
+ >
23
27
  {retrieved ? (
24
28
  <Select
25
29
  name="edited_by"
@@ -8,6 +8,7 @@ import userEvent from '@testing-library/user-event';
8
8
  const mockDjClient = {
9
9
  namespaces: jest.fn(),
10
10
  namespace: jest.fn(),
11
+ listNodesForLanding: jest.fn(),
11
12
  addNamespace: jest.fn(),
12
13
  whoami: jest.fn(),
13
14
  users: jest.fn(),
@@ -37,9 +38,15 @@ describe('NamespacePage', () => {
37
38
 
38
39
  beforeEach(() => {
39
40
  fetch.resetMocks();
40
- mockDjClient.whoami.mockResolvedValue({username: 'dj'});
41
- mockDjClient.users.mockResolvedValue([{username: 'dj'}, {username: 'user1'}]);
42
- mockDjClient.listTags.mockResolvedValue([{name: 'tag1'}, {name: 'tag2'}]);
41
+ mockDjClient.whoami.mockResolvedValue({ username: 'dj' });
42
+ mockDjClient.users.mockResolvedValue([
43
+ { username: 'dj' },
44
+ { username: 'user1' },
45
+ ]);
46
+ mockDjClient.listTags.mockResolvedValue([
47
+ { name: 'tag1' },
48
+ { name: 'tag2' },
49
+ ]);
43
50
  mockDjClient.namespaces.mockResolvedValue([
44
51
  {
45
52
  namespace: 'common.one',
@@ -81,10 +88,43 @@ describe('NamespacePage', () => {
81
88
  type: 'transform',
82
89
  mode: 'active',
83
90
  updated_at: new Date(),
84
- tags: [{name: 'tag1'}],
91
+ tags: [{ name: 'tag1' }],
85
92
  edited_by: ['dj'],
86
93
  },
87
94
  ]);
95
+ mockDjClient.listNodesForLanding.mockResolvedValue({
96
+ data: {
97
+ findNodesPaginated: {
98
+ pageInfo: {
99
+ hasNextPage: true,
100
+ endCursor:
101
+ 'eyJjcmVhdGVkX2F0IjogIjIwMjQtMDQtMTZUMjM6MjI6MjIuNDQxNjg2KzAwOjAwIiwgImlkIjogNjE0fQ==',
102
+ hasPrevPage: true,
103
+ startCursor:
104
+ 'eyJjcmVhdGVkX2F0IjogIjIwMjQtMTAtMTZUMTY6MDM6MTcuMDgzMjY3KzAwOjAwIiwgImlkIjogMjQwOX0=',
105
+ },
106
+ edges: [
107
+ {
108
+ node: {
109
+ name: 'default.test_node',
110
+ type: 'DIMENSION',
111
+ currentVersion: 'v4.0',
112
+ tags: [],
113
+ editedBy: ['dj'],
114
+ current: {
115
+ displayName: 'Test Node',
116
+ status: 'VALID',
117
+ updatedAt: '2024-10-18T15:15:33.532949+00:00',
118
+ },
119
+ createdBy: {
120
+ username: 'dj',
121
+ },
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ });
88
128
  });
89
129
 
90
130
  afterEach(() => {
@@ -106,52 +146,59 @@ describe('NamespacePage', () => {
106
146
  </MemoryRouter>,
107
147
  );
108
148
 
109
- await waitFor(() => {
110
- expect(mockDjClient.namespaces).toHaveBeenCalledTimes(1);
111
- expect(screen.getByText('Namespaces')).toBeInTheDocument();
112
-
113
- // check that it displays namespaces
114
- expect(screen.getByText('common')).toBeInTheDocument();
115
- expect(screen.getByText('one')).toBeInTheDocument();
116
- expect(screen.getByText('fruits')).toBeInTheDocument();
117
- expect(screen.getByText('vegetables')).toBeInTheDocument();
118
-
119
- // check that it renders nodes
120
- expect(screen.getByText('Test Node')).toBeInTheDocument();
121
-
122
- // check that it sorts nodes
123
- fireEvent.click(screen.getByText('name'));
124
- fireEvent.click(screen.getByText('display name'));
125
-
126
- // check that we can filter by node type
127
- const selectNodeType = screen.getAllByTestId('select-node-type')[0];
128
- expect(selectNodeType).toBeDefined();
129
- expect(selectNodeType).not.toBeNull();
130
- fireEvent.keyDown(selectNodeType.firstChild, { key: 'ArrowDown' });
131
- fireEvent.click(screen.getByText('Source'));
132
-
133
- // check that we can filter by tag
134
- const selectTag = screen.getAllByTestId('select-tag')[0];
135
- expect(selectTag).toBeDefined();
136
- expect(selectTag).not.toBeNull();
137
- fireEvent.keyDown(selectTag.firstChild, { key: 'ArrowDown' });
138
-
139
- // check that we can filter by user
140
- const selectUser = screen.getAllByTestId('select-user')[0];
141
- expect(selectUser).toBeDefined();
142
- expect(selectUser).not.toBeNull();
143
- fireEvent.keyDown(selectUser.firstChild, { key: 'ArrowDown' });
144
- // fireEvent.click(screen.getByText('dj'));
145
-
146
- // click to open and close tab
147
- fireEvent.click(screen.getByText('common'));
148
- fireEvent.click(screen.getByText('common'));
149
- });
150
- });
149
+ await waitFor(
150
+ () => {
151
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalled();
152
+ expect(screen.getByText('Namespaces')).toBeInTheDocument();
151
153
 
152
- afterEach(() => {
153
- jest.clearAllMocks();
154
- });
154
+ // check that it displays namespaces
155
+ expect(screen.getByText('common')).toBeInTheDocument();
156
+ expect(screen.getByText('one')).toBeInTheDocument();
157
+ expect(screen.getByText('fruits')).toBeInTheDocument();
158
+ expect(screen.getByText('vegetables')).toBeInTheDocument();
159
+
160
+ // check that it renders nodes
161
+ expect(screen.getByText('Test Node')).toBeInTheDocument();
162
+
163
+ // check that it sorts nodes
164
+ fireEvent.click(screen.getByText('name'));
165
+ fireEvent.click(screen.getByText('name'));
166
+ fireEvent.click(screen.getByText('display Name'));
167
+
168
+ // paginate
169
+ const previousButton = screen.getByText('← Previous');
170
+ expect(previousButton).toBeDefined();
171
+ fireEvent.click(previousButton);
172
+ const nextButton = screen.getByText('Next →');
173
+ expect(nextButton).toBeDefined();
174
+ fireEvent.click(nextButton);
175
+
176
+ // check that we can filter by node type
177
+ const selectNodeType = screen.getAllByTestId('select-node-type')[0];
178
+ expect(selectNodeType).toBeDefined();
179
+ expect(selectNodeType).not.toBeNull();
180
+ fireEvent.keyDown(selectNodeType.firstChild, { key: 'ArrowDown' });
181
+ fireEvent.click(screen.getByText('Source'));
182
+
183
+ // check that we can filter by tag
184
+ const selectTag = screen.getAllByTestId('select-tag')[0];
185
+ expect(selectTag).toBeDefined();
186
+ expect(selectTag).not.toBeNull();
187
+ fireEvent.keyDown(selectTag.firstChild, { key: 'ArrowDown' });
188
+
189
+ // check that we can filter by user
190
+ const selectUser = screen.getAllByTestId('select-user')[0];
191
+ expect(selectUser).toBeDefined();
192
+ expect(selectUser).not.toBeNull();
193
+ fireEvent.keyDown(selectUser.firstChild, { key: 'ArrowDown' });
194
+
195
+ // click to open and close tab
196
+ fireEvent.click(screen.getByText('common'));
197
+ fireEvent.click(screen.getByText('common'));
198
+ },
199
+ { timeout: 3000 },
200
+ );
201
+ }, 60000);
155
202
 
156
203
  it('can add new namespace via add namespace popover', async () => {
157
204
  mockDjClient.addNamespace.mockReturnValue({
@@ -20,13 +20,13 @@ export function NamespacePage() {
20
20
  const ASC = 'ascending';
21
21
  const DESC = 'descending';
22
22
 
23
- const fields = ['name', 'display_name', 'type', 'status', 'updated_at'];
23
+ const fields = ['name', 'displayName', 'type', 'status', 'updatedAt'];
24
24
 
25
25
  const djClient = useContext(DJClientContext).DataJunctionAPI;
26
26
  var { namespace } = useParams();
27
27
 
28
28
  const [state, setState] = useState({
29
- namespace: namespace,
29
+ namespace: namespace ? namespace : '',
30
30
  nodes: [],
31
31
  });
32
32
  const [retrieved, setRetrieved] = useState(false);
@@ -35,39 +35,38 @@ export function NamespacePage() {
35
35
  const [filters, setFilters] = useState({
36
36
  tags: [],
37
37
  node_type: '',
38
- edited_by: currentUser?.username,
38
+ edited_by: '',
39
39
  });
40
40
 
41
41
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
42
42
 
43
43
  const [sortConfig, setSortConfig] = useState({
44
- key: 'updated_at',
44
+ key: 'updatedAt',
45
45
  direction: DESC,
46
46
  });
47
+
48
+ const [before, setBefore] = useState(null);
49
+ const [after, setAfter] = useState(null);
50
+ const [prevCursor, setPrevCursor] = useState(true);
51
+ const [nextCursor, setNextCursor] = useState(true);
52
+
53
+ const [hasNextPage, setHasNextPage] = useState(true);
54
+ const [hasPrevPage, setHasPrevPage] = useState(true);
55
+
47
56
  const sortedNodes = React.useMemo(() => {
48
57
  let sortableData = [...Object.values(state.nodes)];
49
- if (filters.node_type !== '' && filters.node_type !== null) {
50
- sortableData = sortableData.filter(
51
- node => node.type === filters.node_type,
52
- );
53
- }
54
- if (filters.tags) {
55
- sortableData = sortableData.filter(node => {
56
- const nodeTags = node.tags.map(tag => tag.name);
57
- return filters.tags.every(item => nodeTags.includes(item));
58
- });
59
- }
60
- if (filters.edited_by) {
61
- sortableData = sortableData.filter(node => {
62
- return node.edited_by.includes(filters.edited_by);
63
- });
64
- }
65
58
  if (sortConfig !== null) {
66
59
  sortableData.sort((a, b) => {
67
- if (a[sortConfig.key] < b[sortConfig.key]) {
60
+ if (
61
+ a[sortConfig.key] < b[sortConfig.key] ||
62
+ a.current[sortConfig.key] < b.current[sortConfig.key]
63
+ ) {
68
64
  return sortConfig.direction === ASC ? -1 : 1;
69
65
  }
70
- if (a[sortConfig.key] > b[sortConfig.key]) {
66
+ if (
67
+ a[sortConfig.key] > b[sortConfig.key] ||
68
+ a.current[sortConfig.key] > b.current[sortConfig.key]
69
+ ) {
71
70
  return sortConfig.direction === ASC ? 1 : -1;
72
71
  }
73
72
  return 0;
@@ -125,6 +124,7 @@ export function NamespacePage() {
125
124
  const hierarchy = createNamespaceHierarchy(namespaces);
126
125
  setNamespaceHierarchy(hierarchy);
127
126
  const currentUser = await djClient.whoami();
127
+ // setFilters({...filters, edited_by: currentUser?.username});
128
128
  setCurrentUser(currentUser);
129
129
  };
130
130
  fetchData().catch(console.error);
@@ -132,61 +132,132 @@ export function NamespacePage() {
132
132
 
133
133
  useEffect(() => {
134
134
  const fetchData = async () => {
135
- if (namespace === undefined && namespaceHierarchy !== undefined) {
136
- namespace = namespaceHierarchy[0].namespace;
137
- }
138
- const nodes = await djClient.namespace(namespace);
139
- const foundNodes = await Promise.all(nodes);
135
+ setRetrieved(false);
136
+ console.log('cursor', before, filters.edited_by);
137
+ const nodes = await djClient.listNodesForLanding(
138
+ namespace,
139
+ filters.node_type ? [filters.node_type.toUpperCase()] : [],
140
+ filters.tags,
141
+ filters.edited_by,
142
+ before,
143
+ after,
144
+ 50,
145
+ );
146
+ console.log('nodes', nodes);
147
+
140
148
  setState({
141
149
  namespace: namespace,
142
- nodes: foundNodes,
150
+ nodes: nodes.data
151
+ ? nodes.data.findNodesPaginated.edges.map(n => n.node)
152
+ : [],
143
153
  });
154
+ if (nodes.data) {
155
+ setPrevCursor(
156
+ nodes.data ? nodes.data.findNodesPaginated.pageInfo.startCursor : '',
157
+ );
158
+ setNextCursor(
159
+ nodes.data ? nodes.data.findNodesPaginated.pageInfo.endCursor : '',
160
+ );
161
+ console.log(
162
+ 'setting hasPrevPage, ',
163
+ nodes.data.findNodesPaginated.pageInfo.hasPrevPage,
164
+ );
165
+ setHasPrevPage(
166
+ nodes.data
167
+ ? nodes.data.findNodesPaginated.pageInfo.hasPrevPage
168
+ : false,
169
+ );
170
+ setHasNextPage(
171
+ nodes.data
172
+ ? nodes.data.findNodesPaginated.pageInfo.hasNextPage
173
+ : false,
174
+ );
175
+ }
144
176
  setRetrieved(true);
145
177
  };
146
178
  fetchData().catch(console.error);
147
- }, [djClient, namespace, namespaceHierarchy]);
179
+ }, [djClient, filters, before, after]);
180
+ const loadNext = () => {
181
+ if (nextCursor) {
182
+ setAfter(nextCursor);
183
+ setBefore(null);
184
+ }
185
+ };
186
+ const loadPrev = () => {
187
+ if (prevCursor) {
188
+ setAfter(null);
189
+ setBefore(prevCursor);
190
+ }
191
+ };
148
192
 
149
193
  const nodesList = retrieved ? (
150
- sortedNodes.map(node => (
194
+ sortedNodes.length > 0 ? (
195
+ sortedNodes.map(node => (
196
+ <tr key={node.name}>
197
+ <td>
198
+ <a href={'/nodes/' + node.name} className="link-table">
199
+ {node.name}
200
+ </a>
201
+ <span
202
+ className="rounded-pill badge bg-secondary-soft"
203
+ style={{ marginLeft: '0.5rem' }}
204
+ >
205
+ {node.currentVersion}
206
+ </span>
207
+ </td>
208
+ <td>
209
+ <a href={'/nodes/' + node.name} className="link-table">
210
+ {node.type !== 'source' ? node.current.displayName : ''}
211
+ </a>
212
+ </td>
213
+ <td>
214
+ <span
215
+ className={
216
+ 'node_type__' + node.type.toLowerCase() + ' badge node_type'
217
+ }
218
+ >
219
+ {node.type}
220
+ </span>
221
+ </td>
222
+ <td>
223
+ <NodeStatus node={node} revalidate={false} />
224
+ </td>
225
+ <td>
226
+ <span className="status">
227
+ {new Date(node.current.updatedAt).toLocaleString('en-us')}
228
+ </span>
229
+ </td>
230
+ <td>
231
+ <NodeListActions nodeName={node?.name} />
232
+ </td>
233
+ </tr>
234
+ ))
235
+ ) : (
151
236
  <tr>
152
237
  <td>
153
- <a href={'/nodes/' + node.name} className="link-table">
154
- {node.name}
155
- </a>
156
238
  <span
157
- className="rounded-pill badge bg-secondary-soft"
158
- style={{ marginLeft: '0.5rem' }}
239
+ style={{
240
+ display: 'block',
241
+ marginTop: '2rem',
242
+ marginLeft: '2rem',
243
+ fontSize: '16px',
244
+ }}
159
245
  >
160
- {node.version}
246
+ There are no nodes in{' '}
247
+ <a href={`/namespaces/${namespace}`}>{namespace}</a> with the above
248
+ filters!
161
249
  </span>
162
250
  </td>
163
- <td>
164
- <a href={'/nodes/' + node.name} className="link-table">
165
- {node.type !== 'source' ? node.display_name : ''}
166
- </a>
167
- </td>
168
- <td>
169
- <span className={'node_type__' + node.type + ' badge node_type'}>
170
- {node.type}
171
- </span>
172
- </td>
173
- <td>
174
- <NodeStatus node={node} revalidate={false} />
175
- </td>
176
- <td>
177
- <span className="status">
178
- {new Date(node.updated_at).toLocaleString('en-us')}
179
- </span>
180
- </td>
181
- <td>
182
- <NodeListActions nodeName={node?.name} />
183
- </td>
184
251
  </tr>
185
- ))
252
+ )
186
253
  ) : (
187
- <span style={{ display: 'block', marginTop: '2rem' }}>
188
- <LoadingIcon />
189
- </span>
254
+ <tr>
255
+ <td>
256
+ <span style={{ display: 'block', marginTop: '2rem' }}>
257
+ <LoadingIcon />
258
+ </span>
259
+ </td>
260
+ </tr>
190
261
  );
191
262
 
192
263
  return (
@@ -194,7 +265,7 @@ export function NamespacePage() {
194
265
  <div className="card">
195
266
  <div className="card-header">
196
267
  <h2>Explore</h2>
197
- <div class="menu" style={{ margin: '0 0 20px 0' }}>
268
+ <div className="menu" style={{ margin: '0 0 20px 0' }}>
198
269
  <div
199
270
  className="menu-link"
200
271
  style={{
@@ -260,6 +331,7 @@ export function NamespacePage() {
260
331
  item={child}
261
332
  current={state.namespace}
262
333
  defaultExpand={true}
334
+ key={child.namespace}
263
335
  />
264
336
  ))
265
337
  : null}
@@ -269,13 +341,13 @@ export function NamespacePage() {
269
341
  <tr>
270
342
  {fields.map(field => {
271
343
  return (
272
- <th>
344
+ <th key={field}>
273
345
  <button
274
346
  type="button"
275
347
  onClick={() => requestSort(field)}
276
348
  className={'sortable ' + getClassNamesFor(field)}
277
349
  >
278
- {field.replace('_', ' ')}
350
+ {field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
279
351
  </button>
280
352
  </th>
281
353
  );
@@ -284,6 +356,29 @@ export function NamespacePage() {
284
356
  </tr>
285
357
  </thead>
286
358
  <tbody>{nodesList}</tbody>
359
+ <tfoot>
360
+ <tr>
361
+ <td>
362
+ {retrieved && hasPrevPage ? (
363
+ <a
364
+ onClick={loadPrev}
365
+ className="previous round pagination"
366
+ >
367
+ ← Previous
368
+ </a>
369
+ ) : (
370
+ ''
371
+ )}
372
+ {retrieved && hasNextPage ? (
373
+ <a onClick={loadNext} className="next round pagination">
374
+ Next →
375
+ </a>
376
+ ) : (
377
+ ''
378
+ )}
379
+ </td>
380
+ </tr>
381
+ </tfoot>
287
382
  </table>
288
383
  </div>
289
384
  </div>
@@ -256,6 +256,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
256
256
  <th className="text-start">Output Dataset</th>
257
257
  <th>Valid Through</th>
258
258
  <th>Partitions</th>
259
+ <th>Links</th>
259
260
  </tr>
260
261
  </thead>
261
262
  <tbody>
@@ -299,6 +300,21 @@ export default function NodeMaterializationTab({ node, djClient }) {
299
300
  </span>
300
301
  </span>
301
302
  </td>
303
+ <td>
304
+ {node.availability.links !== null ? (
305
+ Object.entries(node.availability.links).map(
306
+ ([key, value]) => (
307
+ <div key={key}>
308
+ <a href={value} target="_blank" rel="noreferrer">
309
+ {key}
310
+ </a>
311
+ </div>
312
+ ),
313
+ )
314
+ ) : (
315
+ <></>
316
+ )}
317
+ </td>
302
318
  </tr>
303
319
  </tbody>
304
320
  </table>
@@ -81,7 +81,9 @@ export default function NodeStatus({ node, revalidate = true }) {
81
81
  <>
82
82
  {revalidate && validation?.errors?.length > 0 ? (
83
83
  displayValidation
84
- ) : validation?.status === 'valid' || node?.status === 'valid' ? (
84
+ ) : validation?.status === 'valid' ||
85
+ node?.status === 'valid' ||
86
+ node?.current?.status === 'VALID' ? (
85
87
  <span
86
88
  className="status__valid status"
87
89
  style={{ alignContent: 'center' }}
@@ -25,7 +25,7 @@ export default function NotebookDownload({ node }) {
25
25
  <>
26
26
  <div
27
27
  className="badge download_notebook"
28
- style={{cursor: 'pointer', backgroundColor: '#ffefd0'}}
28
+ style={{ cursor: 'pointer', backgroundColor: '#ffefd0' }}
29
29
  tabIndex="0"
30
30
  height="45px"
31
31
  onClick={downloadFile}
@@ -0,0 +1,148 @@
1
+ import React from 'react';
2
+ import { render, waitFor, screen } from '@testing-library/react';
3
+ import NodeMaterializationTab from '../NodeMaterializationTab';
4
+
5
+ describe('<NodeMaterializationTab />', () => {
6
+ const mockDjClient = {
7
+ node: jest.fn(),
8
+ materializations: jest.fn(),
9
+ };
10
+
11
+ const mockMaterializations = [
12
+ {
13
+ name: 'mat_one',
14
+ config: {},
15
+ schedule: '@daily',
16
+ job: 'SparkSqlMaterializationJob',
17
+ backfills: [
18
+ {
19
+ spec: [
20
+ {
21
+ column_name: 'date',
22
+ values: ['20200101'],
23
+ range: ['20201010'],
24
+ },
25
+ ],
26
+ urls: ['https://example.com/'],
27
+ },
28
+ ],
29
+ strategy: 'full',
30
+ output_tables: ['table1'],
31
+ urls: ['https://example.com/'],
32
+ },
33
+ ];
34
+
35
+ const mockNode = {
36
+ node_revision_id: 1,
37
+ node_id: 1,
38
+ type: 'source',
39
+ name: 'default.repair_orders',
40
+ display_name: 'Default: Repair Orders',
41
+ version: 'v1.0',
42
+ status: 'valid',
43
+ mode: 'published',
44
+ catalog: {
45
+ id: 1,
46
+ uuid: '0fc18295-e1a2-4c3c-b72a-894725c12488',
47
+ created_at: '2023-08-21T16:48:51.146121+00:00',
48
+ updated_at: '2023-08-21T16:48:51.146122+00:00',
49
+ extra_params: {},
50
+ name: 'warehouse',
51
+ },
52
+ schema_: 'roads',
53
+ table: 'repair_orders',
54
+ description: 'Repair orders',
55
+ query: null,
56
+ availability: {
57
+ catalog: 'default',
58
+ categorical_partitions: [],
59
+ max_temporal_partition: ['2023', '01', '25'],
60
+ min_temporal_partition: ['2022', '01', '01'],
61
+ partitions: [],
62
+ schema_: 'foo',
63
+ table: 'bar',
64
+ temporal_partitions: [],
65
+ valid_through_ts: 1729667463,
66
+ url: 'https://www.table.com',
67
+ links: { dashboard: 'https://www.foobar.com/dashboard' },
68
+ },
69
+ columns: [
70
+ {
71
+ name: 'repair_order_id',
72
+ type: 'int',
73
+ attributes: [],
74
+ dimension: null,
75
+ partition: {
76
+ type_: 'temporal',
77
+ format: 'YYYYMMDD',
78
+ granularity: 'day',
79
+ },
80
+ },
81
+ {
82
+ name: 'municipality_id',
83
+ type: 'string',
84
+ attributes: [],
85
+ dimension: null,
86
+ partition: null,
87
+ },
88
+ {
89
+ name: 'hard_hat_id',
90
+ type: 'int',
91
+ attributes: [],
92
+ dimension: null,
93
+ partition: null,
94
+ },
95
+ ],
96
+ updated_at: '2023-08-21T16:48:52.880498+00:00',
97
+ materializations: [
98
+ {
99
+ name: 'mat1',
100
+ config: {},
101
+ schedule: 'string',
102
+ job: 'string',
103
+ backfills: [
104
+ {
105
+ spec: [
106
+ {
107
+ column_name: 'string',
108
+ values: ['string'],
109
+ range: ['string'],
110
+ },
111
+ ],
112
+ urls: ['string'],
113
+ },
114
+ ],
115
+ strategy: 'string',
116
+ output_tables: ['string'],
117
+ urls: ['https://example.com/'],
118
+ },
119
+ ],
120
+ parents: [],
121
+ dimension_links: [
122
+ {
123
+ dimension: {
124
+ name: 'default.contractor',
125
+ },
126
+ join_type: 'left',
127
+ join_sql:
128
+ 'default.contractor.contractor_id = default.repair_orders.contractor_id',
129
+ join_cardinality: 'one_to_one',
130
+ role: 'contractor',
131
+ },
132
+ ],
133
+ };
134
+
135
+ beforeEach(() => {
136
+ mockDjClient.materializations.mockReset();
137
+ });
138
+
139
+ it('renders NodeMaterializationTab tab correctly', async () => {
140
+ mockDjClient.materializations.mockReturnValue(mockMaterializations);
141
+
142
+ render(<NodeMaterializationTab node={mockNode} djClient={mockDjClient} />);
143
+ await waitFor(() => {
144
+ const link = screen.getByText('dashboard').closest('a');
145
+ expect(link).toHaveAttribute('href', `https://www.foobar.com/dashboard`);
146
+ });
147
+ });
148
+ });
@@ -12,12 +12,12 @@ type DocsSites = {
12
12
 
13
13
  // Default docs sites if REACT_APP_DOCS_SITES is not defined
14
14
  const defaultDocsSites: DocsSites = {
15
- "Open-Source": "https://www.datajunction.io/"
15
+ 'Open-Source': 'https://www.datajunction.io/',
16
16
  };
17
17
 
18
18
  // Parse the JSON map from the environment variable or use the default
19
19
  const docsSites: DocsSites = process.env.REACT_APP_DOCS_SITES
20
- ? JSON.parse(process.env.REACT_APP_DOCS_SITES as string) as DocsSites
20
+ ? (JSON.parse(process.env.REACT_APP_DOCS_SITES as string) as DocsSites)
21
21
  : defaultDocsSites;
22
22
 
23
23
  export function Root() {
@@ -32,7 +32,10 @@ export function Root() {
32
32
  <>
33
33
  <Helmet>
34
34
  <title>DataJunction</title>
35
- <meta name="description" content="DataJunction Metrics Platform Webapp" />
35
+ <meta
36
+ name="description"
37
+ content="DataJunction Metrics Platform Webapp"
38
+ />
36
39
  </Helmet>
37
40
  <div className="container d-flex align-items-center justify-content-between">
38
41
  <div className="header">
@@ -69,17 +72,25 @@ export function Root() {
69
72
  <div className="dropdown">
70
73
  <a
71
74
  className="btn btn-link dropdown-toggle"
72
- href="#"
75
+ href="/"
73
76
  id="docsDropdown"
74
77
  role="button"
75
78
  aria-expanded="false"
76
79
  >
77
80
  Docs
78
81
  </a>
79
- <ul className="dropdown-menu" aria-labelledby="docsDropdown">
82
+ <ul
83
+ className="dropdown-menu"
84
+ aria-labelledby="docsDropdown"
85
+ >
80
86
  {Object.entries(docsSites).map(([key, value]) => (
81
87
  <li key={key}>
82
- <a className="dropdown-item" href={value} target="_blank" rel="noreferrer">
88
+ <a
89
+ className="dropdown-item"
90
+ href={value}
91
+ target="_blank"
92
+ rel="noreferrer"
93
+ >
83
94
  {key}
84
95
  </a>
85
96
  </li>
@@ -106,4 +117,4 @@ export function Root() {
106
117
  <Outlet />
107
118
  </>
108
119
  );
109
- }
120
+ }
@@ -4,7 +4,84 @@ const DJ_URL = process.env.REACT_APP_DJ_URL
4
4
  ? process.env.REACT_APP_DJ_URL
5
5
  : 'http://localhost:8000';
6
6
 
7
+ const DJ_GQL = process.env.REACT_APP_DJ_GQL
8
+ ? process.env.REACT_APP_DJ_GQL
9
+ : process.env.REACT_APP_DJ_URL + '/graphql';
10
+
7
11
  export const DataJunctionAPI = {
12
+ listNodesForLanding: async function (
13
+ namespace,
14
+ nodeTypes,
15
+ tags,
16
+ editedBy,
17
+ before,
18
+ after,
19
+ limit,
20
+ ) {
21
+ const query = `
22
+ query ListNodes($namespace: String, $nodeTypes: [NodeType!], $tags: [String!], $editedBy: String, $before: String, $after: String, $limit: Int) {
23
+ findNodesPaginated(
24
+ namespace: $namespace
25
+ nodeTypes: $nodeTypes
26
+ tags: $tags
27
+ editedBy: $editedBy
28
+ limit: $limit
29
+ before: $before
30
+ after: $after
31
+ ) {
32
+ pageInfo {
33
+ hasNextPage
34
+ endCursor
35
+ hasPrevPage
36
+ startCursor
37
+ }
38
+ edges {
39
+ node {
40
+ name
41
+ type
42
+ currentVersion
43
+ tags {
44
+ name
45
+ tagType
46
+ }
47
+ editedBy
48
+ current {
49
+ displayName
50
+ status
51
+ updatedAt
52
+ }
53
+ createdBy {
54
+ username
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ `;
61
+
62
+ return await (
63
+ await fetch(DJ_GQL, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ credentials: 'include',
69
+ body: JSON.stringify({
70
+ query,
71
+ variables: {
72
+ namespace: namespace,
73
+ nodeTypes: nodeTypes,
74
+ tags: tags,
75
+ editedBy: editedBy,
76
+ before: before,
77
+ after: after,
78
+ limit: limit,
79
+ },
80
+ }),
81
+ })
82
+ ).json();
83
+ },
84
+
8
85
  whoami: async function () {
9
86
  return await (
10
87
  await fetch(`${DJ_URL}/whoami/`, { credentials: 'include' })
@@ -346,11 +423,14 @@ export const DataJunctionAPI = {
346
423
  ).json();
347
424
  },
348
425
 
349
- namespace: async function (nmspce) {
426
+ namespace: async function (nmspce, editedBy) {
350
427
  return await (
351
- await fetch(`${DJ_URL}/namespaces/${nmspce}/?with_edited_by=true`, {
352
- credentials: 'include',
353
- })
428
+ await fetch(
429
+ `${DJ_URL}/namespaces/${nmspce}?edited_by=${editedBy}&with_edited_by=true`,
430
+ {
431
+ credentials: 'include',
432
+ },
433
+ )
354
434
  ).json();
355
435
  },
356
436
 
@@ -426,9 +426,12 @@ describe('DataJunctionAPI', () => {
426
426
  const nmspce = 'sampleNamespace';
427
427
  fetch.mockResponseOnce(JSON.stringify({}));
428
428
  await DataJunctionAPI.namespace(nmspce);
429
- expect(fetch).toHaveBeenCalledWith(`${DJ_URL}/namespaces/${nmspce}/?with_edited_by=true`, {
430
- credentials: 'include',
431
- });
429
+ expect(fetch).toHaveBeenCalledWith(
430
+ `${DJ_URL}/namespaces/${nmspce}?edited_by=undefined&with_edited_by=true`,
431
+ {
432
+ credentials: 'include',
433
+ },
434
+ );
432
435
  });
433
436
 
434
437
  it('calls sql correctly', async () => {
@@ -489,9 +492,9 @@ describe('DataJunctionAPI', () => {
489
492
  const sampleNode = {
490
493
  name: 'sampleNode',
491
494
  columns: [
492
- { name: 'column1', dimension: { name: 'dimension1' }, },
495
+ { name: 'column1', dimension: { name: 'dimension1' } },
493
496
  { name: 'column2', dimension: null },
494
- { name: 'column3', dimension: { name: 'dimension2' }, },
497
+ { name: 'column3', dimension: { name: 'dimension2' } },
495
498
  ],
496
499
  };
497
500
 
@@ -1069,4 +1072,28 @@ describe('DataJunctionAPI', () => {
1069
1072
  },
1070
1073
  );
1071
1074
  });
1075
+
1076
+ it('calls listNodesForLanding correctly', () => {
1077
+ fetch.mockResponseOnce(JSON.stringify({}));
1078
+
1079
+ DataJunctionAPI.listNodesForLanding(
1080
+ '',
1081
+ ['source'],
1082
+ [],
1083
+ '',
1084
+ null,
1085
+ null,
1086
+ 100,
1087
+ );
1088
+ expect(fetch).toHaveBeenCalledWith(
1089
+ `${DJ_URL}/graphql`,
1090
+ expect.objectContaining({
1091
+ method: 'POST',
1092
+ credentials: 'include',
1093
+ headers: {
1094
+ 'Content-Type': 'application/json',
1095
+ },
1096
+ }),
1097
+ );
1098
+ });
1072
1099
  });
@@ -1050,6 +1050,32 @@ pre {
1050
1050
  white-space: break-spaces;
1051
1051
  }
1052
1052
 
1053
+ .pagination {
1054
+ background-color: #fff !important;
1055
+ color: #74b7c3;
1056
+ border: 1px solid #74b7c3;
1057
+ text-transform: none;
1058
+ vertical-align: middle;
1059
+ padding-right: 1rem;
1060
+ padding-left: 1rem;
1061
+ padding-top: 0.5rem;
1062
+ padding-bottom: 0.5rem;
1063
+ margin-left: 0.5rem !important;
1064
+ margin-bottom: 0.5rem !important;
1065
+ font-size: 1rem;
1066
+ border-radius: 0.2rem;
1067
+ word-wrap: break-word;
1068
+ white-space: break-spaces;
1069
+ text-decoration: none;
1070
+ }
1071
+ .pagination:hover {
1072
+ background-color: #b6dae0 !important;
1073
+ color: #436e76;
1074
+ text-transform: none;
1075
+ text-decoration: none;
1076
+ cursor: pointer;
1077
+ }
1078
+
1053
1079
  .edit_button {
1054
1080
  background: none;
1055
1081
  color: inherit;
@@ -1258,67 +1284,67 @@ pre {
1258
1284
 
1259
1285
  .backfills {
1260
1286
  margin-left: -4rem;
1261
- --spacing : 1.5rem;
1262
- --radius : 10px;
1287
+ --spacing: 1.5rem;
1288
+ --radius: 10px;
1263
1289
  }
1264
1290
 
1265
- .backfills li{
1266
- display : block;
1267
- position : relative;
1268
- padding-left : calc(2 * var(--spacing) - var(--radius) - 2px);
1291
+ .backfills li {
1292
+ display: block;
1293
+ position: relative;
1294
+ padding-left: calc(2 * var(--spacing) - var(--radius) - 2px);
1269
1295
  }
1270
1296
 
1271
- .backfills ul{
1272
- margin-left : calc(var(--radius) - var(--spacing));
1273
- padding-left : 2rem;
1297
+ .backfills ul {
1298
+ margin-left: calc(var(--radius) - var(--spacing));
1299
+ padding-left: 2rem;
1274
1300
  }
1275
1301
 
1276
- .backfills ul li{
1277
- border-left : 2px solid #ddd;
1302
+ .backfills ul li {
1303
+ border-left: 2px solid #ddd;
1278
1304
  }
1279
1305
 
1280
- .backfills ul li:last-child{
1281
- border-color : transparent;
1306
+ .backfills ul li:last-child {
1307
+ border-color: transparent;
1282
1308
  }
1283
1309
 
1284
- .backfills ul li::before{
1285
- content : '';
1286
- display : block;
1287
- position : absolute;
1288
- top : calc(var(--spacing) / -2);
1289
- left : -2px;
1290
- width : calc(var(--spacing) + 2px);
1291
- height : calc(var(--spacing) + 1px);
1292
- border : solid #ddd;
1293
- border-width : 0 0 2px 2px;
1310
+ .backfills ul li::before {
1311
+ content: '';
1312
+ display: block;
1313
+ position: absolute;
1314
+ top: calc(var(--spacing) / -2);
1315
+ left: -2px;
1316
+ width: calc(var(--spacing) + 2px);
1317
+ height: calc(var(--spacing) + 1px);
1318
+ border: solid #ddd;
1319
+ border-width: 0 0 2px 2px;
1294
1320
  }
1295
1321
 
1296
- .backfills summary{
1297
- display : block;
1298
- cursor : pointer;
1322
+ .backfills summary {
1323
+ display: block;
1324
+ cursor: pointer;
1299
1325
  margin-bottom: 10px;
1300
1326
  }
1301
1327
 
1302
1328
  .backfills summary::marker,
1303
- .backfills summary::-webkit-details-marker{
1304
- display : none;
1329
+ .backfills summary::-webkit-details-marker {
1330
+ display: none;
1305
1331
  }
1306
1332
 
1307
- .backfills summary:focus{
1308
- outline : none;
1333
+ .backfills summary:focus {
1334
+ outline: none;
1309
1335
  }
1310
1336
 
1311
- .backfills summary:focus-visible{
1312
- outline : 1px dotted #000;
1337
+ .backfills summary:focus-visible {
1338
+ outline: 1px dotted #000;
1313
1339
  }
1314
1340
 
1315
- .backfills summary::before{
1316
- z-index : 1;
1341
+ .backfills summary::before {
1342
+ z-index: 1;
1317
1343
  /*background : #696 url('expand-collapse.svg') 0 0;*/
1318
1344
  }
1319
1345
 
1320
- .backfills details[open] > summary::before{
1321
- background-position : calc(-2 * var(--radius)) 0;
1346
+ .backfills details[open] > summary::before {
1347
+ background-position: calc(-2 * var(--radius)) 0;
1322
1348
  }
1323
1349
 
1324
1350
  .backfills_header {
@@ -1345,32 +1371,32 @@ pre {
1345
1371
  border-radius: 2px;
1346
1372
  border-style: none;
1347
1373
  padding: 0.4rem;
1348
- font-family: Lato, "sans-serif";
1374
+ font-family: Lato, 'sans-serif';
1349
1375
  font-size: 110%;
1350
1376
  border-right: 16px solid transparent;
1351
1377
  margin-left: 0.4rem;
1352
1378
  }
1353
1379
 
1354
1380
  .backfills summary::marker,
1355
- .backfills summary::-webkit-details-marker{
1356
- display : none;
1381
+ .backfills summary::-webkit-details-marker {
1382
+ display: none;
1357
1383
  }
1358
1384
 
1359
- .backfills summary:focus{
1360
- outline : none;
1385
+ .backfills summary:focus {
1386
+ outline: none;
1361
1387
  }
1362
1388
 
1363
- .backfills summary:focus-visible{
1364
- outline : 1px dotted #000;
1389
+ .backfills summary:focus-visible {
1390
+ outline: 1px dotted #000;
1365
1391
  }
1366
1392
 
1367
- .backfills summary::before{
1368
- z-index : 1;
1393
+ .backfills summary::before {
1394
+ z-index: 1;
1369
1395
  /*background : #696 url('expand-collapse.svg') 0 0;*/
1370
1396
  }
1371
1397
 
1372
- .backfills details[open] > summary::before{
1373
- background-position : calc(-2 * var(--radius)) 0;
1398
+ .backfills details[open] > summary::before {
1399
+ background-position: calc(-2 * var(--radius)) 0;
1374
1400
  }
1375
1401
 
1376
1402
  .backfills_header {
@@ -1419,8 +1445,8 @@ table {
1419
1445
  position: absolute;
1420
1446
  z-index: 1;
1421
1447
 
1422
- top: -5px;
1423
- left: 125%;
1448
+ top: -5px;
1449
+ left: 125%;
1424
1450
  /*bottom: 125%;*/
1425
1451
  /*left: 50%;*/
1426
1452
  margin-left: -60px;
@@ -1429,7 +1455,7 @@ table {
1429
1455
  }
1430
1456
 
1431
1457
  .tooltip .tooltiptext::after {
1432
- content: "";
1458
+ content: '';
1433
1459
  position: absolute;
1434
1460
  top: 100%;
1435
1461
  left: 50%;
@@ -1450,13 +1476,19 @@ table {
1450
1476
  grid-template-rows: 250px 1fr;
1451
1477
  gap: 20px;
1452
1478
  grid-template-areas:
1453
- "left righttop"
1454
- "left rightbottom";
1479
+ 'left righttop'
1480
+ 'left rightbottom';
1455
1481
  margin-top: 1rem;
1456
1482
  }
1457
- .left { grid-area: left; }
1458
- .righttop { grid-area: righttop; }
1459
- .rightbottom { grid-area: rightbottom; }
1483
+ .left {
1484
+ grid-area: left;
1485
+ }
1486
+ .righttop {
1487
+ grid-area: righttop;
1488
+ }
1489
+ .rightbottom {
1490
+ grid-area: rightbottom;
1491
+ }
1460
1492
 
1461
1493
  .queryrunner-query pre {
1462
1494
  border-radius: 0;
@@ -1512,4 +1544,4 @@ table {
1512
1544
  }
1513
1545
  .dropdown:hover .dropdown-menu {
1514
1546
  display: block;
1515
- }
1547
+ }
@@ -1,4 +1,4 @@
1
1
  table {
2
2
  width: 100%;
3
3
  height: min-content;
4
- }
4
+ }