datajunction-ui 0.0.1-a35.dev0 → 0.0.1-a37

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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/forms/Action.jsx +8 -0
  3. package/src/app/components/forms/NodeNameField.jsx +64 -0
  4. package/src/app/components/forms/NodeTagsInput.jsx +61 -0
  5. package/src/app/index.tsx +18 -0
  6. package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormFailed.test.jsx +1 -1
  7. package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormSuccess.test.jsx +2 -5
  8. package/src/app/pages/AddEditNodePage/index.jsx +16 -7
  9. package/src/app/pages/CubeBuilderPage/DimensionsSelect.jsx +154 -0
  10. package/src/app/pages/CubeBuilderPage/Loadable.jsx +16 -0
  11. package/src/app/pages/CubeBuilderPage/MetricsSelect.jsx +79 -0
  12. package/src/app/pages/CubeBuilderPage/__tests__/index.test.jsx +405 -0
  13. package/src/app/pages/CubeBuilderPage/index.jsx +254 -0
  14. package/src/app/pages/NamespacePage/index.jsx +5 -0
  15. package/src/app/pages/NodePage/AddBackfillPopover.jsx +13 -6
  16. package/src/app/pages/NodePage/AddMaterializationPopover.jsx +47 -24
  17. package/src/app/pages/NodePage/NodeInfoTab.jsx +6 -1
  18. package/src/app/pages/NodePage/NodeMaterializationTab.jsx +44 -32
  19. package/src/app/pages/NodePage/__tests__/AddBackfillPopover.test.jsx +0 -1
  20. package/src/app/pages/NodePage/__tests__/AddMaterializationPopover.test.jsx +84 -0
  21. package/src/app/pages/NodePage/__tests__/__snapshots__/NodePage.test.jsx.snap +78 -174
  22. package/src/app/services/DJService.js +66 -12
  23. package/src/app/services/__tests__/DJService.test.jsx +72 -7
  24. package/src/styles/index.css +4 -0
  25. package/src/styles/node-creation.scss +12 -0
@@ -2,27 +2,24 @@ import { useContext, useEffect, useRef, useState } from 'react';
2
2
  import * as React from 'react';
3
3
  import DJClientContext from '../../providers/djclient';
4
4
  import { ErrorMessage, Field, Form, Formik } from 'formik';
5
- import { FormikSelect } from '../AddEditNodePage/FormikSelect';
6
- import EditIcon from '../../icons/EditIcon';
7
5
  import { displayMessageAfterSubmit, labelize } from '../../../utils/form';
8
6
 
9
7
  export default function AddMaterializationPopover({ node, onSubmit }) {
10
8
  const djClient = useContext(DJClientContext).DataJunctionAPI;
11
9
  const [popoverAnchor, setPopoverAnchor] = useState(false);
12
- const [engines, setEngines] = useState([]);
13
- const [defaultEngine, setDefaultEngine] = useState('');
10
+ const [options, setOptions] = useState([]);
11
+ const [jobs, setJobs] = useState([]);
14
12
 
15
13
  const ref = useRef(null);
16
14
 
17
15
  useEffect(() => {
18
16
  const fetchData = async () => {
19
- const engines = await djClient.engines();
20
- setEngines(engines);
21
- setDefaultEngine(
22
- engines && engines.length > 0
23
- ? engines[0].name + '__' + engines[0].version
24
- : '',
17
+ const options = await djClient.materializationInfo();
18
+ setOptions(options);
19
+ const allowedJobs = options.job_types?.filter(job =>
20
+ job.allowed_node_types.includes(node.type),
25
21
  );
22
+ setJobs(allowedJobs);
26
23
  };
27
24
  fetchData().catch(console.error);
28
25
  const handleClickOutside = event => {
@@ -41,14 +38,15 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
41
38
  { setSubmitting, setStatus },
42
39
  ) => {
43
40
  setSubmitting(false);
44
- const engineVersion = values.engine.split('__').slice(-1).join('');
45
- const engineName = values.engine.split('__').slice(0, -1).join('');
41
+ const config = JSON.parse(values.config);
42
+ config.lookback_window = values.lookback_window;
43
+ console.log('values', values);
46
44
  const response = await djClient.materialize(
47
45
  values.node,
48
- engineName,
49
- engineVersion,
46
+ values.job_type,
47
+ values.strategy,
50
48
  values.schedule,
51
- values.config,
49
+ config,
52
50
  );
53
51
  if (response.status === 200 || response.status === 201) {
54
52
  setStatus({ success: 'Saved!' });
@@ -65,7 +63,7 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
65
63
  <>
66
64
  <button
67
65
  className="edit_button"
68
- aria-label="PartitionColumn"
66
+ aria-label="AddMaterialization"
69
67
  tabIndex="0"
70
68
  onClick={() => {
71
69
  setPopoverAnchor(!popoverAnchor);
@@ -90,9 +88,11 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
90
88
  <Formik
91
89
  initialValues={{
92
90
  node: node?.name,
93
- engine: defaultEngine,
91
+ job_type: 'spark_sql',
92
+ strategy: 'full',
94
93
  config: '{"spark": {"spark.executor.memory": "6g"}}',
95
94
  schedule: '@daily',
95
+ lookback_window: '1 DAY',
96
96
  }}
97
97
  onSubmit={configureMaterialization}
98
98
  >
@@ -101,16 +101,15 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
101
101
  <Form>
102
102
  <h2>Configure Materialization</h2>
103
103
  {displayMessageAfterSubmit(status)}
104
- <span data-testid="edit-partition">
105
- <label htmlFor="engine">Engine</label>
106
- <Field as="select" name="engine">
104
+ <span data-testid="job-type">
105
+ <label htmlFor="job_type">Job Type</label>
106
+ <Field as="select" name="job_type">
107
107
  <>
108
- {engines?.map(engine => (
109
- <option value={engine.name + '__' + engine.version}>
110
- {engine.name} {engine.version}
108
+ {jobs?.map(job => (
109
+ <option key={job.name} value={job.name}>
110
+ {job.label}
111
111
  </option>
112
112
  ))}
113
- <option value=""></option>
114
113
  </>
115
114
  </Field>
116
115
  </span>
@@ -122,6 +121,18 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
122
121
  />
123
122
  <br />
124
123
  <br />
124
+ <span data-testid="edit-partition">
125
+ <label htmlFor="strategy">Strategy</label>
126
+ <Field as="select" name="strategy">
127
+ <>
128
+ {options.strategies?.map(strategy => (
129
+ <option value={strategy.name}>{strategy.label}</option>
130
+ ))}
131
+ </>
132
+ </Field>
133
+ </span>
134
+ <br />
135
+ <br />
125
136
  <label htmlFor="schedule">Schedule</label>
126
137
  <Field
127
138
  type="text"
@@ -132,6 +143,18 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
132
143
  />
133
144
  <br />
134
145
  <br />
146
+ <div className="DescriptionInput">
147
+ <ErrorMessage name="description" component="span" />
148
+ <label htmlFor="Config">Lookback Window</label>
149
+ <Field
150
+ type="text"
151
+ name="lookback_window"
152
+ id="lookback_window"
153
+ placeholder="1 DAY"
154
+ default="1 DAY"
155
+ />
156
+ </div>
157
+ <br />
135
158
  <div className="DescriptionInput">
136
159
  <ErrorMessage name="description" component="span" />
137
160
  <label htmlFor="Config">Config</label>
@@ -76,7 +76,12 @@ export default function NodeInfoTab({ node }) {
76
76
  aria-label="CubeElement"
77
77
  aria-hidden="false"
78
78
  >
79
- <a href={`/nodes/${cubeElem.node_name}`}>{cubeElem.display_name}</a>
79
+ <a href={`/nodes/${cubeElem.node_name}`}>
80
+ {cubeElem.type === 'dimension'
81
+ ? labelize(cubeElem.node_name.split('.').slice(-1)[0]) + ' → '
82
+ : ''}
83
+ {cubeElem.display_name}
84
+ </a>
80
85
  <span
81
86
  className={`badge node_type__${
82
87
  cubeElem.type === 'metric' ? cubeElem.type : 'dimension'
@@ -53,11 +53,9 @@ export default function NodeMaterializationTab({ node, djClient }) {
53
53
  <div className={`cron-description`}>{cron(materialization)} </div>
54
54
  </td>
55
55
  <td>
56
- {materialization.engine.name}
57
- <br />
58
- {materialization.engine.version}
59
- <ClientCodePopover code={materialization.clientCode} />
56
+ {materialization.job?.replace('MaterializationJob', '').toUpperCase()}
60
57
  </td>
58
+ <td>{materialization.strategy?.toUpperCase()}</td>
61
59
  <td>
62
60
  {node.columns
63
61
  .filter(col => col.partition !== null)
@@ -90,27 +88,34 @@ export default function NodeMaterializationTab({ node, djClient }) {
90
88
  </div>
91
89
  ))}
92
90
  </td>
93
- <td>
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]}
99
- </div>
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>
91
+ {materializations[0].strategy === 'incremental_time' ? (
92
+ <td>
93
+ {materialization.backfills.map(backfill => (
94
+ <a href={backfill.urls[0]} className="partitionLink">
95
+ <div
96
+ className="partition__full"
97
+ key={backfill.spec.column_name}
98
+ >
99
+ <div className="partition__header">
100
+ {partitionColumnsMap[backfill.spec.column_name]}
101
+ </div>
102
+ <div className="partition__body">
103
+ <span className="badge partition_value">
104
+ {backfill.spec.range[0]}
105
+ </span>
106
+ to
107
+ <span className="badge partition_value">
108
+ {backfill.spec.range[1]}
109
+ </span>
110
+ </div>
108
111
  </div>
109
- </div>
110
- </a>
111
- ))}
112
- <AddBackfillPopover node={node} materialization={materialization} />
113
- </td>
112
+ </a>
113
+ ))}
114
+ <AddBackfillPopover node={node} materialization={materialization} />
115
+ </td>
116
+ ) : (
117
+ <></>
118
+ )}
114
119
  <td>
115
120
  {materialization.urls.map((url, idx) => (
116
121
  <a href={url} key={`url-${idx}`}>
@@ -118,6 +123,9 @@ export default function NodeMaterializationTab({ node, djClient }) {
118
123
  </a>
119
124
  ))}
120
125
  </td>
126
+ <td>
127
+ <ClientCodePopover code={materialization.clientCode} />
128
+ </td>
121
129
  </tr>
122
130
  ));
123
131
  };
@@ -136,10 +144,15 @@ export default function NodeMaterializationTab({ node, djClient }) {
136
144
  <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
137
145
  <tr>
138
146
  <th className="text-start">Schedule</th>
139
- <th>Engine</th>
147
+ <th>Job Type</th>
148
+ <th>Strategy</th>
140
149
  <th>Partitions</th>
141
- <th>Output Tables</th>
142
- <th>Backfills</th>
150
+ <th>Intended Output Tables</th>
151
+ {materializations[0].strategy === 'incremental_time' ? (
152
+ <th>Backfills</th>
153
+ ) : (
154
+ <></>
155
+ )}
143
156
  <th>URLs</th>
144
157
  </tr>
145
158
  </thead>
@@ -171,16 +184,13 @@ export default function NodeMaterializationTab({ node, djClient }) {
171
184
  >
172
185
  <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
173
186
  <tr>
174
- <th className="text-start">Catalog</th>
175
- <th>Schema</th>
176
- <th>Table</th>
187
+ <th className="text-start">Output Dataset</th>
177
188
  <th>Valid Through</th>
178
189
  <th>Partitions</th>
179
190
  </tr>
180
191
  </thead>
181
192
  <tbody>
182
193
  <tr>
183
- <td>{node.availability.schema_}</td>
184
194
  <td>
185
195
  {
186
196
  <div
@@ -203,7 +213,9 @@ export default function NodeMaterializationTab({ node, djClient }) {
203
213
  </div>
204
214
  }
205
215
  </td>
206
- <td>{node.availability.valid_through_ts}</td>
216
+ <td>
217
+ {new Date(node.availability.valid_through_ts).toISOString()}
218
+ </td>
207
219
  <td>
208
220
  <span
209
221
  className={`badge partition_value`}
@@ -36,7 +36,6 @@ describe('<AddBackfillPopover />', () => {
36
36
  fireEvent.click(getByLabelText('AddBackfill'));
37
37
 
38
38
  fireEvent.click(getByText('Save'));
39
- getByText('Save').click();
40
39
 
41
40
  // Expect setAttributes to be called
42
41
  await waitFor(() => {
@@ -0,0 +1,84 @@
1
+ import React from 'react';
2
+ import { render, fireEvent, waitFor, screen } from '@testing-library/react';
3
+ import DJClientContext from '../../../providers/djclient';
4
+ import AddMaterializationPopover from '../AddMaterializationPopover';
5
+ import { mocks } from '../../../../mocks/mockNodes';
6
+
7
+ const mockDjClient = {
8
+ DataJunctionAPI: {
9
+ materialize: jest.fn(),
10
+ materializationInfo: jest.fn(),
11
+ },
12
+ };
13
+
14
+ describe('<AddMaterializationPopover />', () => {
15
+ it('renders correctly and handles form submission', async () => {
16
+ // Mock onSubmit function
17
+ const onSubmitMock = jest.fn();
18
+ mockDjClient.DataJunctionAPI.materialize.mockReturnValue({
19
+ status: 201,
20
+ });
21
+ mockDjClient.DataJunctionAPI.materializationInfo.mockReturnValue({
22
+ status: 200,
23
+ json: {
24
+ job_types: [
25
+ {
26
+ name: 'spark_sql',
27
+ label: 'Spark SQL',
28
+ description: 'Spark SQL materialization job',
29
+ allowed_node_types: ['transform', 'dimension', 'cube'],
30
+ job_class: 'SparkSqlMaterializationJob',
31
+ },
32
+ {
33
+ name: 'druid_cube',
34
+ label: 'Druid Cube',
35
+ description:
36
+ 'Used to materialize a cube to Druid for low-latency access to a set of metrics and dimensions. While the logical cube definition is at the level of metrics and dimensions, a materialized Druid cube will reference measures and dimensions, with rollup configured on the measures where appropriate.',
37
+ allowed_node_types: ['cube'],
38
+ job_class: 'DruidCubeMaterializationJob',
39
+ },
40
+ ],
41
+ strategies: [
42
+ {
43
+ name: 'full',
44
+ label: 'Full',
45
+ },
46
+ {
47
+ name: 'snapshot',
48
+ label: 'Snapshot',
49
+ },
50
+ {
51
+ name: 'incremental_time',
52
+ label: 'Incremental Time',
53
+ },
54
+ {
55
+ name: 'view',
56
+ label: 'View',
57
+ },
58
+ ],
59
+ },
60
+ });
61
+
62
+ // Render the component
63
+ const { getByText } = render(
64
+ <DJClientContext.Provider value={mockDjClient}>
65
+ <AddMaterializationPopover
66
+ node={mocks.mockMetricNode}
67
+ onSubmit={onSubmitMock}
68
+ />
69
+ </DJClientContext.Provider>,
70
+ );
71
+
72
+ // Open the popover
73
+ fireEvent.click(getByText('+ Add Materialization'));
74
+
75
+ // Save the materialization
76
+ fireEvent.click(getByText('Save'));
77
+
78
+ // Expect setAttributes to be called
79
+ await waitFor(() => {
80
+ expect(mockDjClient.DataJunctionAPI.materialize).toHaveBeenCalled();
81
+ expect(getByText('Saved!')).toBeInTheDocument();
82
+ });
83
+ });
84
+ });
@@ -107,16 +107,16 @@ exports[`<NodePage /> renders the NodeMaterialization tab with materializations
107
107
  Schedule
108
108
  </th>
109
109
  <th>
110
- Engine
110
+ Job Type
111
111
  </th>
112
112
  <th>
113
- Partitions
113
+ Strategy
114
114
  </th>
115
115
  <th>
116
- Output Tables
116
+ Partitions
117
117
  </th>
118
118
  <th>
119
- Backfills
119
+ Intended Output Tables
120
120
  </th>
121
121
  <th>
122
122
  URLs
@@ -141,9 +141,80 @@ exports[`<NodePage /> renders the NodeMaterialization tab with materializations
141
141
  </div>
142
142
  </td>
143
143
  <td>
144
- spark
145
- <br />
146
- 2.4.4
144
+ SPARKSQL
145
+ </td>
146
+ <td />
147
+ <td />
148
+ <td>
149
+ <div
150
+ class="table__full"
151
+ >
152
+ <div
153
+ class="table__header"
154
+ >
155
+ <svg
156
+ class="bi bi-table"
157
+ fill="currentColor"
158
+ height="16"
159
+ viewBox="0 0 16 16"
160
+ width="16"
161
+ xmlns="http://www.w3.org/2000/svg"
162
+ >
163
+ <path
164
+ d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"
165
+ />
166
+ </svg>
167
+
168
+ <span
169
+ class="entity-info"
170
+ >
171
+ common.a
172
+ </span>
173
+ </div>
174
+ <div
175
+ class="table__body upstream_tables"
176
+ />
177
+ </div>
178
+ <div
179
+ class="table__full"
180
+ >
181
+ <div
182
+ class="table__header"
183
+ >
184
+ <svg
185
+ class="bi bi-table"
186
+ fill="currentColor"
187
+ height="16"
188
+ viewBox="0 0 16 16"
189
+ width="16"
190
+ xmlns="http://www.w3.org/2000/svg"
191
+ >
192
+ <path
193
+ d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"
194
+ />
195
+ </svg>
196
+
197
+ <span
198
+ class="entity-info"
199
+ >
200
+ common.b
201
+ </span>
202
+ </div>
203
+ <div
204
+ class="table__body upstream_tables"
205
+ />
206
+ </div>
207
+ </td>
208
+ <td>
209
+ <a
210
+ href="http://fake.url/job"
211
+ >
212
+ [
213
+ 1
214
+ ]
215
+ </a>
216
+ </td>
217
+ <td>
147
218
  <button
148
219
  aria-label="code-button"
149
220
  class="code-button"
@@ -230,173 +301,6 @@ exports[`<NodePage /> renders the NodeMaterialization tab with materializations
230
301
  </pre>
231
302
  </div>
232
303
  </td>
233
- <td />
234
- <td>
235
- <div
236
- class="table__full"
237
- >
238
- <div
239
- class="table__header"
240
- >
241
- <svg
242
- class="bi bi-table"
243
- fill="currentColor"
244
- height="16"
245
- viewBox="0 0 16 16"
246
- width="16"
247
- xmlns="http://www.w3.org/2000/svg"
248
- >
249
- <path
250
- d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"
251
- />
252
- </svg>
253
-
254
- <span
255
- class="entity-info"
256
- >
257
- common.a
258
- </span>
259
- </div>
260
- <div
261
- class="table__body upstream_tables"
262
- />
263
- </div>
264
- <div
265
- class="table__full"
266
- >
267
- <div
268
- class="table__header"
269
- >
270
- <svg
271
- class="bi bi-table"
272
- fill="currentColor"
273
- height="16"
274
- viewBox="0 0 16 16"
275
- width="16"
276
- xmlns="http://www.w3.org/2000/svg"
277
- >
278
- <path
279
- d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"
280
- />
281
- </svg>
282
-
283
- <span
284
- class="entity-info"
285
- >
286
- common.b
287
- </span>
288
- </div>
289
- <div
290
- class="table__body upstream_tables"
291
- />
292
- </div>
293
- </td>
294
- <td>
295
- <a
296
- class="partitionLink"
297
- >
298
- <div
299
- class="partition__full"
300
- >
301
- <div
302
- class="partition__header"
303
- />
304
- <div
305
- class="partition__body"
306
- >
307
- <span
308
- class="badge partition_value"
309
- >
310
- 20230101
311
- </span>
312
- to
313
- <span
314
- class="badge partition_value"
315
- >
316
- 20230102
317
- </span>
318
- </div>
319
- </div>
320
- </a>
321
- <button
322
- aria-label="AddBackfill"
323
- class="edit_button"
324
- tabindex="0"
325
- >
326
- <span
327
- class="add_node"
328
- >
329
- + Add Backfill
330
- </span>
331
- </button>
332
- <div
333
- class="fade modal-backdrop in"
334
- style="display: none;"
335
- />
336
- <div
337
- aria-label="client-code"
338
- class="centerPopover"
339
- role="dialog"
340
- style="display: none; width: 50%;"
341
- >
342
- <form
343
- action="#"
344
- >
345
- <h2>
346
- Run Backfill
347
- </h2>
348
- <span
349
- data-testid="edit-partition"
350
- >
351
- <label
352
- for="engine"
353
- style="padding-bottom: 1rem;"
354
- >
355
- Engine
356
- </label>
357
- <select
358
- disabled=""
359
- id="engine"
360
- name="engine"
361
- >
362
- <option
363
- value="spark"
364
- >
365
- spark
366
-
367
- 2.4.4
368
- </option>
369
- </select>
370
- </span>
371
- <br />
372
- <br />
373
- <label
374
- for="partition"
375
- style="padding-bottom: 1rem;"
376
- >
377
- Partition Range
378
- </label>
379
- <br />
380
- <button
381
- aria-hidden="false"
382
- aria-label="SaveEditColumn"
383
- class="add_node"
384
- type="submit"
385
- >
386
- Save
387
- </button>
388
- </form>
389
- </div>
390
- </td>
391
- <td>
392
- <a
393
- href="http://fake.url/job"
394
- >
395
- [
396
- 1
397
- ]
398
- </a>
399
- </td>
400
304
  </tr>
401
305
  </tbody>
402
306
  </table>