datajunction-ui 0.0.1-rc.25 → 0.0.1-rc.26

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.
@@ -1,6 +1,9 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import ClientCodePopover from './ClientCodePopover';
3
3
  import TableIcon from '../../icons/TableIcon';
4
+ import AddMaterializationPopover from './AddMaterializationPopover';
5
+ import * as React from 'react';
6
+ import AddBackfillPopover from './AddBackfillPopover';
4
7
 
5
8
  const cronstrue = require('cronstrue');
6
9
 
@@ -15,18 +18,25 @@ export default function NodeMaterializationTab({ node, djClient }) {
15
18
  };
16
19
  fetchData().catch(console.error);
17
20
  }, [djClient, node]);
21
+ //
22
+ // const rangePartition = partition => {
23
+ // return (
24
+ // <div>
25
+ // <span className="badge partition_value">
26
+ // <span className="badge partition_value">{partition.range[0]}</span>to
27
+ // <span className="badge partition_value">{partition.range[1]}</span>
28
+ // </span>
29
+ // </div>
30
+ // );
31
+ // };
18
32
 
19
- const rangePartition = partition => {
20
- return (
21
- <div>
22
- <span className="badge partition_value">
23
- <span className="badge partition_value">{partition.range[0]}</span>to
24
- <span className="badge partition_value">{partition.range[1]}</span>
25
- </span>
26
- </div>
27
- );
28
- };
29
-
33
+ const partitionColumnsMap = node
34
+ ? Object.fromEntries(
35
+ node?.columns
36
+ .filter(col => col.partition !== null)
37
+ .map(col => [col.name, col.display_name]),
38
+ )
39
+ : {};
30
40
  const cron = materialization => {
31
41
  var parsedCron = '';
32
42
  try {
@@ -39,10 +49,6 @@ export default function NodeMaterializationTab({ node, djClient }) {
39
49
  return materializations.map(materialization => (
40
50
  <tr key={materialization.name}>
41
51
  <td className="text-start node_name">
42
- <a href={materialization.urls[0]}>{materialization.name}</a>
43
- <ClientCodePopover code={materialization.clientCode} />
44
- </td>
45
- <td>
46
52
  <span className={`badge cron`}>{materialization.schedule}</span>
47
53
  <div className={`cron-description`}>{cron(materialization)} </div>
48
54
  </td>
@@ -50,41 +56,24 @@ export default function NodeMaterializationTab({ node, djClient }) {
50
56
  {materialization.engine.name}
51
57
  <br />
52
58
  {materialization.engine.version}
59
+ <ClientCodePopover code={materialization.clientCode} />
53
60
  </td>
54
61
  <td>
55
- {materialization.config.partitions ? (
56
- materialization.config.partitions.map(partition =>
57
- partition.type_ === 'categorical' ? (
58
- <div className="partition__full" key={partition.name}>
59
- <div className="partition__header">{partition.name}</div>
62
+ {node.columns
63
+ .filter(col => col.partition !== null)
64
+ .map(column => {
65
+ return (
66
+ <div className="partition__full" key={column.name}>
67
+ <div className="partition__header">{column.display_name}</div>
60
68
  <div className="partition__body">
61
- {partition.values !== null && partition.values.length > 0
62
- ? partition.values.map(val => (
63
- <span
64
- className="badge partition_value"
65
- key={`partition-value-${val}`}
66
- >
67
- {val}
68
- </span>
69
- ))
70
- : null}
71
- {partition.range !== null && partition.range.length > 0
72
- ? rangePartition(partition)
73
- : null}
74
- {(partition.range === null && partition.values === null) ||
75
- (partition.range?.length === 0 &&
76
- partition.values?.length === 0) ? (
77
- <span className={`badge partition_value_highlight`}>
78
- ALL
79
- </span>
80
- ) : null}
69
+ <code>{column.name}</code>
70
+ <span className="badge partition_value">
71
+ {column.partition.type_}
72
+ </span>
81
73
  </div>
82
74
  </div>
83
- ) : null,
84
- )
85
- ) : (
86
- <br />
87
- )}
75
+ );
76
+ })}
88
77
  </td>
89
78
  <td>
90
79
  {materialization.output_tables.map(table => (
@@ -101,30 +90,26 @@ export default function NodeMaterializationTab({ node, djClient }) {
101
90
  </div>
102
91
  ))}
103
92
  </td>
104
- {/*<td>{Object.keys(materialization.config.spark).map(key => <li className={`list-group-item`}>{key}: {materialization.config.spark[key]}</li>)}</td>*/}
105
-
106
93
  <td>
107
- {materialization.config.partitions ? (
108
- materialization.config.partitions.map(partition =>
109
- partition.type_ === 'temporal' ? (
110
- <div className="partition__full" key={partition.name}>
111
- <div className="partition__header">{partition.name}</div>
112
- <div className="partition__body">
113
- {partition.values !== null && partition.values.length > 0
114
- ? partition.values.map(val => (
115
- <span className="badge partition_value">{val}</span>
116
- ))
117
- : null}
118
- {partition.range !== null && partition.range.length > 0
119
- ? rangePartition(partition)
120
- : null}
121
- </div>
94
+ {materialization.backfills.map(backfill => (
95
+ <a href={backfill.urls[0]} className="partitionLink">
96
+ <div className="partition__full" key={backfill.spec.column_name}>
97
+ <div className="partition__header">
98
+ {partitionColumnsMap[backfill.spec.column_name]}
122
99
  </div>
123
- ) : null,
124
- )
125
- ) : (
126
- <br />
127
- )}
100
+ <div className="partition__body">
101
+ <span className="badge partition_value">
102
+ {backfill.spec.range[0]}
103
+ </span>
104
+ to
105
+ <span className="badge partition_value">
106
+ {backfill.spec.range[1]}
107
+ </span>
108
+ </div>
109
+ </div>
110
+ </a>
111
+ ))}
112
+ <AddBackfillPopover node={node} materialization={materialization} />
128
113
  </td>
129
114
  <td>
130
115
  {materialization.urls.map((url, idx) => (
@@ -137,38 +122,112 @@ export default function NodeMaterializationTab({ node, djClient }) {
137
122
  ));
138
123
  };
139
124
  return (
140
- <div className="table-responsive">
141
- {materializations.length > 0 ? (
142
- <table
143
- className="card-inner-table table"
144
- aria-label="Materializations"
145
- aria-hidden="false"
146
- >
147
- <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
148
- <tr>
149
- <th className="text-start">Name</th>
150
- <th>Schedule</th>
151
- <th>Engine</th>
152
- <th>Partitions</th>
153
- <th>Output Tables</th>
154
- <th>Backfills</th>
155
- <th>URLs</th>
156
- </tr>
157
- </thead>
158
- <tbody>
159
- {materializationRows(
160
- materializations.filter(
161
- materialization =>
162
- !(materialization.name === 'default' && node.type === 'cube'),
163
- ),
164
- )}
165
- </tbody>
166
- </table>
167
- ) : (
168
- <div className="message alert" style={{ marginTop: '10px' }}>
169
- No materializations available for this node
125
+ <>
126
+ <div className="table-vertical">
127
+ <div>
128
+ <h2>Materializations</h2>
129
+ <AddMaterializationPopover node={node} />
130
+ {materializations.length > 0 ? (
131
+ <table
132
+ className="card-inner-table table"
133
+ aria-label="Materializations"
134
+ aria-hidden="false"
135
+ >
136
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
137
+ <tr>
138
+ <th className="text-start">Schedule</th>
139
+ <th>Engine</th>
140
+ <th>Partitions</th>
141
+ <th>Output Tables</th>
142
+ <th>Backfills</th>
143
+ <th>URLs</th>
144
+ </tr>
145
+ </thead>
146
+ <tbody>
147
+ {materializationRows(
148
+ materializations.filter(
149
+ materialization =>
150
+ !(
151
+ materialization.name === 'default' &&
152
+ node.type === 'cube'
153
+ ),
154
+ ),
155
+ )}
156
+ </tbody>
157
+ </table>
158
+ ) : (
159
+ <div className="message alert" style={{ marginTop: '10px' }}>
160
+ No materialization workflows configured for this node.
161
+ </div>
162
+ )}
170
163
  </div>
171
- )}
172
- </div>
164
+ <div>
165
+ <h2>Materialized Datasets</h2>
166
+ {node && node.availability !== null ? (
167
+ <table
168
+ className="card-inner-table table"
169
+ aria-label="Availability"
170
+ aria-hidden="false"
171
+ >
172
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
173
+ <tr>
174
+ <th className="text-start">Catalog</th>
175
+ <th>Schema</th>
176
+ <th>Table</th>
177
+ <th>Valid Through</th>
178
+ <th>Partitions</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody>
182
+ <tr>
183
+ <td>{node.availability.schema_}</td>
184
+ <td>
185
+ {
186
+ <div
187
+ className={`table__full`}
188
+ key={node.availability.table}
189
+ >
190
+ <div className="table__header">
191
+ <TableIcon />{' '}
192
+ <span className={`entity-info`}>
193
+ {node.availability.catalog +
194
+ '.' +
195
+ node.availability.schema_}
196
+ </span>
197
+ </div>
198
+ <div className={`table__body upstream_tables`}>
199
+ <a href={node.availability.url}>
200
+ {node.availability.table}
201
+ </a>
202
+ </div>
203
+ </div>
204
+ }
205
+ </td>
206
+ <td>{node.availability.valid_through_ts}</td>
207
+ <td>
208
+ <span
209
+ className={`badge partition_value`}
210
+ style={{ fontSize: '100%' }}
211
+ >
212
+ <span className={`badge partition_value_highlight`}>
213
+ {node.availability.min_temporal_partition}
214
+ </span>
215
+ to
216
+ <span className={`badge partition_value_highlight`}>
217
+ {node.availability.max_temporal_partition}
218
+ </span>
219
+ </span>
220
+ </td>
221
+ </tr>
222
+ </tbody>
223
+ </table>
224
+ ) : (
225
+ <div className="message alert" style={{ marginTop: '10px' }}>
226
+ No materialized datasets available for this node.
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ </>
173
232
  );
174
233
  }
@@ -0,0 +1,153 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react';
2
+ import * as React from 'react';
3
+ import DJClientContext from '../../providers/djclient';
4
+ import { Field, Form, Formik } from 'formik';
5
+ import { FormikSelect } from '../AddEditNodePage/FormikSelect';
6
+ import EditIcon from '../../icons/EditIcon';
7
+ import { displayMessageAfterSubmit, labelize } from '../../../utils/form';
8
+
9
+ export default function PartitionColumnPopover({ column, node, onSubmit }) {
10
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
11
+ const [popoverAnchor, setPopoverAnchor] = useState(false);
12
+ const ref = useRef(null);
13
+
14
+ useEffect(() => {
15
+ const handleClickOutside = event => {
16
+ if (ref.current && !ref.current.contains(event.target)) {
17
+ setPopoverAnchor(false);
18
+ }
19
+ };
20
+ document.addEventListener('click', handleClickOutside, true);
21
+ return () => {
22
+ document.removeEventListener('click', handleClickOutside, true);
23
+ };
24
+ }, [setPopoverAnchor]);
25
+
26
+ const savePartition = async (
27
+ { node, column, partition_type, format, granularity },
28
+ { setSubmitting, setStatus },
29
+ ) => {
30
+ setSubmitting(false);
31
+ const response = await djClient.setPartition(
32
+ node,
33
+ column,
34
+ partition_type,
35
+ format,
36
+ granularity,
37
+ );
38
+ if (response.status === 200 || response.status === 201) {
39
+ setStatus({ success: 'Saved!' });
40
+ } else {
41
+ setStatus({
42
+ failure: `${response.json.message}`,
43
+ });
44
+ }
45
+ onSubmit();
46
+ // window.location.reload();
47
+ };
48
+
49
+ return (
50
+ <>
51
+ <button
52
+ className="edit_button"
53
+ aria-label="PartitionColumn"
54
+ tabIndex="0"
55
+ onClick={() => {
56
+ setPopoverAnchor(!popoverAnchor);
57
+ }}
58
+ >
59
+ <EditIcon />
60
+ </button>
61
+ <div
62
+ className="popover"
63
+ role="dialog"
64
+ aria-label="client-code"
65
+ style={{ display: popoverAnchor === false ? 'none' : 'block' }}
66
+ ref={ref}
67
+ >
68
+ <Formik
69
+ initialValues={{
70
+ column: column.name,
71
+ node: node.name,
72
+ partition_type: '',
73
+ format: 'yyyyMMdd',
74
+ granularity: 'day',
75
+ }}
76
+ onSubmit={savePartition}
77
+ >
78
+ {function Render({ values, isSubmitting, status, setFieldValue }) {
79
+ return (
80
+ <Form>
81
+ {displayMessageAfterSubmit(status)}
82
+ <span data-testid="edit-partition">
83
+ <label htmlFor="react-select-3-input">Partition Type</label>
84
+ <Field
85
+ as="select"
86
+ name="partition_type"
87
+ id="partitionType"
88
+ placeholder="Partition Type"
89
+ >
90
+ <option value=""></option>
91
+ <option value="temporal">Temporal</option>
92
+ <option value="categorical">Categorical</option>
93
+ </Field>
94
+ </span>
95
+ <input
96
+ hidden={true}
97
+ name="column"
98
+ value={column.name}
99
+ readOnly={true}
100
+ />
101
+ <input
102
+ hidden={true}
103
+ name="node"
104
+ value={node.name}
105
+ readOnly={true}
106
+ />
107
+ <br />
108
+ <br />
109
+ {values.partition_type === 'temporal' ? (
110
+ <>
111
+ <label htmlFor="react-select-3-input">
112
+ Partition Format
113
+ </label>
114
+ <Field
115
+ type="text"
116
+ name="format"
117
+ id="partitionFormat"
118
+ placeholder="Optional temporal partition format (ex: yyyyMMdd)"
119
+ />
120
+ <br />
121
+ <br />
122
+ <label htmlFor="react-select-3-input">
123
+ Partition Granularity
124
+ </label>
125
+ <Field
126
+ as="select"
127
+ name="granularity"
128
+ id="partitionGranularity"
129
+ placeholder="Granularity"
130
+ >
131
+ <option value="day">Day</option>
132
+ <option value="hour">Hour</option>
133
+ </Field>
134
+ </>
135
+ ) : (
136
+ ''
137
+ )}
138
+ <button
139
+ className="add_node"
140
+ type="submit"
141
+ aria-label="SaveEditColumn"
142
+ aria-hidden="false"
143
+ >
144
+ Save
145
+ </button>
146
+ </Form>
147
+ );
148
+ }}
149
+ </Formik>
150
+ </div>
151
+ </>
152
+ );
153
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { render, fireEvent, waitFor, screen } from '@testing-library/react';
3
+ import EditColumnPopover from '../EditColumnPopover';
4
+ import DJClientContext from '../../../providers/djclient';
5
+ import AddBackfillPopover from '../AddBackfillPopover';
6
+ import { mocks } from '../../../../mocks/mockNodes';
7
+
8
+ const mockDjClient = {
9
+ DataJunctionAPI: {
10
+ runBackfill: jest.fn(),
11
+ },
12
+ };
13
+
14
+ describe('<AddBackfillPopover />', () => {
15
+ it('renders correctly and handles form submission', async () => {
16
+ // Mock onSubmit function
17
+ const onSubmitMock = jest.fn();
18
+
19
+ mockDjClient.DataJunctionAPI.runBackfill.mockReturnValue({
20
+ status: 201,
21
+ json: { message: '' },
22
+ });
23
+
24
+ // Render the component
25
+ const { getByLabelText, getByText } = render(
26
+ <DJClientContext.Provider value={mockDjClient}>
27
+ <AddBackfillPopover
28
+ node={mocks.mockMetricNode}
29
+ materialization={mocks.nodeMaterializations}
30
+ onSubmit={onSubmitMock}
31
+ />
32
+ </DJClientContext.Provider>,
33
+ );
34
+
35
+ // Open the popover
36
+ fireEvent.click(getByLabelText('AddBackfill'));
37
+
38
+ fireEvent.click(getByText('Save'));
39
+ getByText('Save').click();
40
+
41
+ // Expect setAttributes to be called
42
+ await waitFor(() => {
43
+ expect(mockDjClient.DataJunctionAPI.runBackfill).toHaveBeenCalled();
44
+ expect(getByText('Saved!')).toBeInTheDocument();
45
+ });
46
+ });
47
+ });
@@ -36,6 +36,7 @@ describe('<NodePage />', () => {
36
36
  nodesWithDimension: jest.fn(),
37
37
  attributes: jest.fn(),
38
38
  dimensions: jest.fn(),
39
+ setPartition: jest.fn(),
39
40
  },
40
41
  };
41
42
  };
@@ -396,6 +397,11 @@ describe('<NodePage />', () => {
396
397
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
397
398
  djClient.DataJunctionAPI.attributes.mockReturnValue(mocks.attributes);
398
399
  djClient.DataJunctionAPI.dimensions.mockReturnValue(mocks.dimensions);
400
+ djClient.DataJunctionAPI.setPartition.mockReturnValue({
401
+ status: 200,
402
+ json: { message: '' },
403
+ });
404
+
399
405
  const element = (
400
406
  <DJClientContext.Provider value={djClient}>
401
407
  <NodePage />
@@ -442,6 +448,19 @@ describe('<NodePage />', () => {
442
448
  expect(
443
449
  screen.getByRole('button', { name: 'SaveLinkDimension' }),
444
450
  ).toBeInTheDocument();
451
+
452
+ // check that the set column partition popover can be clicked
453
+ const partitionColumnPopover = screen.getByRole('button', {
454
+ name: 'PartitionColumn',
455
+ });
456
+ expect(partitionColumnPopover).toBeInTheDocument();
457
+ fireEvent.click(partitionColumnPopover);
458
+ const savePartition = screen.getByRole('button', {
459
+ name: 'SaveEditColumn',
460
+ });
461
+ expect(savePartition).toBeInTheDocument();
462
+ fireEvent.click(savePartition);
463
+ expect(screen.getByText('Saved!'));
445
464
  });
446
465
  });
447
466
  // check compiled SQL on nodeInfo page
@@ -576,7 +595,10 @@ describe('<NodePage />', () => {
576
595
  expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
577
596
  mocks.mockMetricNode.name,
578
597
  );
579
- screen.getByText('No materializations available for this node');
598
+ screen.getByText(
599
+ 'No materialization workflows configured for this node.',
600
+ );
601
+ screen.getByText('No materialized datasets available for this node.');
580
602
  });
581
603
  });
582
604
 
@@ -601,20 +623,25 @@ describe('<NodePage />', () => {
601
623
  </Routes>
602
624
  </MemoryRouter>,
603
625
  );
604
- await waitFor(() => {
605
- fireEvent.click(screen.getByRole('button', { name: 'Materializations' }));
606
- expect(djClient.DataJunctionAPI.node).toHaveBeenCalledWith(
607
- mocks.mockMetricNode.name,
608
- );
609
- expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
610
- mocks.mockMetricNode.name,
611
- );
626
+ await waitFor(
627
+ () => {
628
+ fireEvent.click(
629
+ screen.getByRole('button', { name: 'Materializations' }),
630
+ );
631
+ expect(djClient.DataJunctionAPI.node).toHaveBeenCalledWith(
632
+ mocks.mockMetricNode.name,
633
+ );
634
+ expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
635
+ mocks.mockMetricNode.name,
636
+ );
612
637
 
613
- expect(
614
- screen.getByRole('table', { name: 'Materializations' }),
615
- ).toMatchSnapshot();
616
- });
617
- });
638
+ expect(
639
+ screen.getByRole('table', { name: 'Materializations' }),
640
+ ).toMatchSnapshot();
641
+ },
642
+ { timeout: 3000 },
643
+ );
644
+ }, 60000);
618
645
 
619
646
  it('renders the NodeSQL tab', async () => {
620
647
  const djClient = mockDJClient();