datajunction-ui 0.0.1-a31 → 0.0.1-a32

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.1a31",
3
+ "version": "0.0.1-a32",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -54,6 +54,7 @@
54
54
  "file-loader": "6.2.0",
55
55
  "fontfaceobserver": "2.3.0",
56
56
  "formik": "2.4.3",
57
+ "fuse.js": "6.6.2",
57
58
  "husky": "8.0.1",
58
59
  "i18next": "21.9.2",
59
60
  "i18next-browser-languagedetector": "6.1.5",
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect, useContext } from 'react';
2
+ import DJClientContext from '../providers/djclient';
3
+ import Fuse from 'fuse.js';
4
+
5
+ import './search.css';
6
+
7
+ export default function Search() {
8
+ const [fuse, setFuse] = useState();
9
+ const [searchValue, setSearchValue] = useState('');
10
+ const [searchResults, setSearchResults] = useState([]);
11
+
12
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
13
+
14
+ const truncate = str => {
15
+ return str.length > 100 ? str.substring(0, 90) + '...' : str;
16
+ };
17
+ useEffect(() => {
18
+ const fetchNodes = async () => {
19
+ const data = (await djClient.nodeDetails()) || [];
20
+ const tags = (await djClient.listTags()) || [];
21
+ const allEntities = data.concat(
22
+ tags.map(tag => {
23
+ tag.type = 'tag';
24
+ return tag;
25
+ }),
26
+ );
27
+ const fuse = new Fuse(allEntities || [], {
28
+ keys: [
29
+ 'name', // will be assigned a `weight` of 1
30
+ {
31
+ name: 'description',
32
+ weight: 2,
33
+ },
34
+ {
35
+ name: 'display_name',
36
+ weight: 3,
37
+ },
38
+ {
39
+ name: 'type',
40
+ weight: 4,
41
+ },
42
+ {
43
+ name: 'tag_type',
44
+ weight: 5,
45
+ },
46
+ ],
47
+ });
48
+ setFuse(fuse);
49
+ };
50
+ fetchNodes();
51
+ }, []);
52
+
53
+ const handleChange = e => {
54
+ setSearchValue(e.target.value);
55
+ if (fuse) {
56
+ setSearchResults(fuse.search(e.target.value).map(result => result.item));
57
+ }
58
+ };
59
+
60
+ return (
61
+ <div>
62
+ <form
63
+ className="search-box"
64
+ onSubmit={e => {
65
+ e.preventDefault();
66
+ }}
67
+ >
68
+ <input
69
+ type="text"
70
+ placeholder="Search"
71
+ name="search"
72
+ value={searchValue}
73
+ onChange={handleChange}
74
+ />
75
+ </form>
76
+ <div className="search-results">
77
+ {searchResults.map(item => {
78
+ const itemUrl =
79
+ item.type !== 'tag' ? `/nodes/${item.name}` : `/tags/${item.name}`;
80
+ return (
81
+ <a href={itemUrl}>
82
+ <div key={item.name} className="search-result-item">
83
+ <span className={`node_type__${item.type} badge node_type`}>
84
+ {item.type}
85
+ </span>
86
+ {item.display_name} (<b>{item.name}</b>){' '}
87
+ {item.description ? '- ' : ' '}
88
+ {truncate(item.description)}
89
+ </div>
90
+ </a>
91
+ );
92
+ })}
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,63 @@
1
+ import * as React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import Search from '../Search';
4
+ import DJClientContext from '../../providers/djclient';
5
+ import { Root } from '../../pages/Root';
6
+ import { HelmetProvider } from 'react-helmet-async';
7
+
8
+ describe('<Search />', () => {
9
+ const mockDjClient = {
10
+ logout: jest.fn(),
11
+ nodeDetails: async () => [
12
+ {
13
+ name: 'default.repair_orders',
14
+ display_name: 'Default: Repair Orders',
15
+ description: 'Repair orders',
16
+ version: 'v1.0',
17
+ type: 'source',
18
+ status: 'valid',
19
+ mode: 'published',
20
+ updated_at: '2023-08-21T16:48:52.880498+00:00',
21
+ },
22
+ {
23
+ name: 'default.repair_order_details',
24
+ display_name: 'Default: Repair Order Details',
25
+ description: 'Details on repair orders',
26
+ version: 'v1.0',
27
+ type: 'source',
28
+ status: 'valid',
29
+ mode: 'published',
30
+ updated_at: '2023-08-21T16:48:52.981201+00:00',
31
+ },
32
+ ],
33
+ listTags: async () => [
34
+ {
35
+ description: 'something',
36
+ display_name: 'Report A',
37
+ tag_metadata: {},
38
+ name: 'report.a',
39
+ tag_type: 'report',
40
+ },
41
+ {
42
+ description: 'report B',
43
+ display_name: 'Report B',
44
+ tag_metadata: {},
45
+ name: 'report.b',
46
+ tag_type: 'report',
47
+ },
48
+ ],
49
+ };
50
+
51
+ it('displays search results correctly', () => {
52
+ render(
53
+ <HelmetProvider>
54
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
55
+ <Root />
56
+ </DJClientContext.Provider>
57
+ </HelmetProvider>,
58
+ );
59
+ const searchInput = screen.queryByPlaceholderText('Search');
60
+ fireEvent.change(searchInput, { target: { value: 'Repair' } });
61
+ expect(searchInput.value).toBe('Repair');
62
+ });
63
+ });
@@ -0,0 +1,17 @@
1
+ .search-box {
2
+ display: flex;
3
+ height: 50%;
4
+ }
5
+
6
+ .search-results {
7
+ position: absolute;
8
+ z-index: 1000;
9
+ width: 75%;
10
+ background-color: rgba(244, 244, 244, 0.8);
11
+ border-radius: 1rem;
12
+ }
13
+
14
+ .search-result-item {
15
+ text-decoration: wavy;
16
+ padding: 0.5rem;
17
+ }
@@ -32,10 +32,13 @@ describe('AddEditNodePage submission failed', () => {
32
32
  const { container } = renderCreateNode(element);
33
33
 
34
34
  await userEvent.type(
35
- screen.getByLabelText('Display Name'),
35
+ screen.getByLabelText('Display Name *'),
36
36
  'Some Test Metric',
37
37
  );
38
- await userEvent.type(screen.getByLabelText('Query'), 'SELECT * FROM test');
38
+ await userEvent.type(
39
+ screen.getByLabelText('Query *'),
40
+ 'SELECT * FROM test',
41
+ );
39
42
  await userEvent.click(screen.getByText('Create dimension'));
40
43
 
41
44
  await waitFor(() => {
@@ -49,6 +52,8 @@ describe('AddEditNodePage submission failed', () => {
49
52
  'draft',
50
53
  'default',
51
54
  null,
55
+ undefined,
56
+ undefined,
52
57
  );
53
58
  expect(
54
59
  screen.getByText(/Some columns in the primary key \[] were not found/),
@@ -80,7 +85,7 @@ describe('AddEditNodePage submission failed', () => {
80
85
  const element = testElement(mockDjClient);
81
86
  renderEditNode(element);
82
87
 
83
- await userEvent.type(screen.getByLabelText('Display Name'), '!!!');
88
+ await userEvent.type(screen.getByLabelText('Display Name *'), '!!!');
84
89
  await userEvent.type(screen.getByLabelText('Description'), '!!!');
85
90
  await userEvent.click(screen.getByText('Save'));
86
91
  await waitFor(async () => {
@@ -88,7 +93,7 @@ describe('AddEditNodePage submission failed', () => {
88
93
  expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalled();
89
94
  expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledWith(
90
95
  'default.num_repair_orders',
91
- [{ display_name: 'Purpose', name: 'purpose' }],
96
+ ['purpose'],
92
97
  );
93
98
  expect(mockDjClient.DataJunctionAPI.tagsNode).toReturnWith({
94
99
  json: { message: 'Some tags were not found' },
@@ -42,10 +42,13 @@ describe('AddEditNodePage submission succeeded', () => {
42
42
  const { container } = renderCreateNode(element);
43
43
 
44
44
  await userEvent.type(
45
- screen.getByLabelText('Display Name'),
45
+ screen.getByLabelText('Display Name *'),
46
46
  'Some Test Metric',
47
47
  );
48
- await userEvent.type(screen.getByLabelText('Query'), 'SELECT * FROM test');
48
+ await userEvent.type(
49
+ screen.getByLabelText('Query *'),
50
+ 'SELECT * FROM test',
51
+ );
49
52
  await userEvent.click(screen.getByText('Create dimension'));
50
53
 
51
54
  await waitFor(() => {
@@ -59,10 +62,7 @@ describe('AddEditNodePage submission succeeded', () => {
59
62
  'draft',
60
63
  'default',
61
64
  null,
62
- );
63
- expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalled();
64
- expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledWith(
65
- 'default.some_test_metric',
65
+ undefined,
66
66
  undefined,
67
67
  );
68
68
  expect(screen.getByText(/default.some_test_metric/)).toBeInTheDocument();
@@ -97,7 +97,7 @@ describe('AddEditNodePage submission succeeded', () => {
97
97
  const element = testElement(mockDjClient);
98
98
  const { getByTestId } = renderEditNode(element);
99
99
 
100
- await userEvent.type(screen.getByLabelText('Display Name'), '!!!');
100
+ await userEvent.type(screen.getByLabelText('Display Name *'), '!!!');
101
101
  await userEvent.type(screen.getByLabelText('Description'), '!!!');
102
102
  await userEvent.click(screen.getByText('Save'));
103
103
 
@@ -114,13 +114,18 @@ describe('AddEditNodePage submission succeeded', () => {
114
114
  'SELECT count(repair_order_id) default_DOT_num_repair_orders FROM default.repair_orders',
115
115
  'published',
116
116
  ['repair_order_id', 'country'],
117
+ 'neutral',
118
+ 'unitless',
117
119
  );
118
120
  expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledTimes(1);
119
121
  expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledWith(
120
122
  'default.num_repair_orders',
121
- [{ display_name: 'Purpose', name: 'purpose' }],
123
+ ['purpose'],
122
124
  );
123
125
 
126
+ expect(mockDjClient.DataJunctionAPI.listMetricMetadata).toBeCalledTimes(
127
+ 1,
128
+ );
124
129
  expect(
125
130
  await screen.getByDisplayValue('repair_order_id, country'),
126
131
  ).toBeInTheDocument();
@@ -53,6 +53,13 @@ export const initializeMockDJClient = () => {
53
53
  node: jest.fn(),
54
54
  tagsNode: jest.fn(),
55
55
  listTags: jest.fn(),
56
+ listMetricMetadata: jest.fn().mockReturnValue({
57
+ directions: ['higher_is_better', 'lower_is_better', 'neutral'],
58
+ units: [
59
+ { name: 'dollar', label: 'Dollar' },
60
+ { name: 'second', label: 'Second' },
61
+ ],
62
+ }),
56
63
  },
57
64
  };
58
65
  };
@@ -33,6 +33,8 @@ export function AddEditNodePage() {
33
33
 
34
34
  const [namespaces, setNamespaces] = useState([]);
35
35
  const [tags, setTags] = useState([]);
36
+ const [metricUnits, setMetricUnits] = useState([]);
37
+ const [metricDirections, setMetricDirections] = useState([]);
36
38
 
37
39
  const initialValues = {
38
40
  name: action === Action.Edit ? name : '',
@@ -108,9 +110,13 @@ export function AddEditNodePage() {
108
110
  values.mode,
109
111
  values.namespace,
110
112
  values.primary_key ? primaryKeyToList(values.primary_key) : null,
113
+ values.metric_direction,
114
+ values.metric_unit,
111
115
  );
112
116
  if (status === 200 || status === 201) {
113
- await djClient.tagsNode(values.name, values.tags);
117
+ if (values.tags) {
118
+ await djClient.tagsNode(values.name, values.tags);
119
+ }
114
120
  setStatus({
115
121
  success: (
116
122
  <>
@@ -134,8 +140,13 @@ export function AddEditNodePage() {
134
140
  values.query,
135
141
  values.mode,
136
142
  values.primary_key ? primaryKeyToList(values.primary_key) : null,
143
+ values.metric_direction,
144
+ values.metric_unit,
145
+ );
146
+ const tagsResponse = await djClient.tagsNode(
147
+ values.name,
148
+ values.tags.map(tag => tag.name),
137
149
  );
138
- const tagsResponse = await djClient.tagsNode(values.name, values.tags);
139
150
  if ((status === 200 || status === 201) && tagsResponse.status === 200) {
140
151
  setStatus({
141
152
  success: (
@@ -155,7 +166,7 @@ export function AddEditNodePage() {
155
166
  const namespaceInput = (
156
167
  <div className="NamespaceInput">
157
168
  <ErrorMessage name="namespace" component="span" />
158
- <label htmlFor="react-select-3-input">Namespace</label>
169
+ <label htmlFor="react-select-3-input">Namespace *</label>
159
170
  <FormikSelect
160
171
  selectOptions={namespaces}
161
172
  formikFieldName="namespace"
@@ -171,7 +182,7 @@ export function AddEditNodePage() {
171
182
  const fullNameInput = (
172
183
  <div className="FullNameInput NodeCreationInput">
173
184
  <ErrorMessage name="name" component="span" />
174
- <label htmlFor="FullName">Full Name</label>
185
+ <label htmlFor="FullName">Full Name *</label>
175
186
  <FullNameField type="text" name="name" />
176
187
  </div>
177
188
  );
@@ -201,6 +212,15 @@ export function AddEditNodePage() {
201
212
  setFieldValue(field, data[field] || '', false);
202
213
  }
203
214
  });
215
+ if (data.metric_metadata?.direction) {
216
+ setFieldValue('metric_direction', data.metric_metadata.direction);
217
+ }
218
+ if (data.metric_metadata?.unit) {
219
+ setFieldValue(
220
+ 'metric_unit',
221
+ data.metric_metadata.unit.name.toLowerCase(),
222
+ );
223
+ }
204
224
  };
205
225
 
206
226
  const alertMessage = message => {
@@ -242,6 +262,16 @@ export function AddEditNodePage() {
242
262
  fetchData().catch(console.error);
243
263
  }, [djClient, djClient.listTags]);
244
264
 
265
+ // Get metric metadata values
266
+ useEffect(() => {
267
+ const fetchData = async () => {
268
+ const metadata = await djClient.listMetricMetadata();
269
+ setMetricDirections(metadata.directions);
270
+ setMetricUnits(metadata.units);
271
+ };
272
+ fetchData().catch(console.error);
273
+ }, [djClient]);
274
+
245
275
  return (
246
276
  <div className="mid">
247
277
  <NamespaceHeader namespace="" />
@@ -281,6 +311,43 @@ export function AddEditNodePage() {
281
311
  </div>
282
312
  );
283
313
 
314
+ const metricMetadataInput = (
315
+ <>
316
+ <div
317
+ className="MetricDirectionInput NodeCreationInput"
318
+ style={{ width: '25%' }}
319
+ >
320
+ <ErrorMessage name="metric_direction" component="span" />
321
+ <label htmlFor="MetricDirection">Metric Direction</label>
322
+ <Field
323
+ as="select"
324
+ name="metric_direction"
325
+ id="MetricDirection"
326
+ >
327
+ <option value=""></option>
328
+ {metricDirections.map(direction => (
329
+ <option value={direction}>
330
+ {labelize(direction)}
331
+ </option>
332
+ ))}
333
+ </Field>
334
+ </div>
335
+ <div
336
+ className="MetricUnitInput NodeCreationInput"
337
+ style={{ width: '25%' }}
338
+ >
339
+ <ErrorMessage name="metric_unit" component="span" />
340
+ <label htmlFor="MetricUnit">Metric Unit</label>
341
+ <Field as="select" name="metric_unit" id="MetricUnit">
342
+ <option value=""></option>
343
+ {metricUnits.map(unit => (
344
+ <option value={unit.name}>{unit.label}</option>
345
+ ))}
346
+ </Field>
347
+ </div>
348
+ </>
349
+ );
350
+
284
351
  useEffect(() => {
285
352
  const fetchData = async () => {
286
353
  if (action === Action.Edit) {
@@ -332,7 +399,7 @@ export function AddEditNodePage() {
332
399
  : staticFieldsInEdit(node)}
333
400
  <div className="DisplayNameInput NodeCreationInput">
334
401
  <ErrorMessage name="display_name" component="span" />
335
- <label htmlFor="displayName">Display Name</label>
402
+ <label htmlFor="displayName">Display Name *</label>
336
403
  <Field
337
404
  type="text"
338
405
  name="display_name"
@@ -352,9 +419,12 @@ export function AddEditNodePage() {
352
419
  placeholder="Describe your node"
353
420
  />
354
421
  </div>
422
+ {nodeType === 'metric' || node.type === 'metric'
423
+ ? metricMetadataInput
424
+ : ''}
355
425
  <div className="QueryInput NodeCreationInput">
356
426
  <ErrorMessage name="query" component="span" />
357
- <label htmlFor="Query">Query</label>
427
+ <label htmlFor="Query">Query *</label>
358
428
  <NodeQueryField
359
429
  djClient={djClient}
360
430
  value={node.query ? node.query : ''}
@@ -379,6 +449,7 @@ export function AddEditNodePage() {
379
449
  <option value="published">Published</option>
380
450
  </Field>
381
451
  </div>
452
+
382
453
  <button type="submit" disabled={isSubmitting}>
383
454
  {action === Action.Add ? 'Create' : 'Save'} {nodeType}
384
455
  </button>
@@ -6,6 +6,7 @@ import NodeStatus from './NodeStatus';
6
6
  import ListGroupItem from '../../components/ListGroupItem';
7
7
  import ToggleSwitch from '../../components/ToggleSwitch';
8
8
  import DJClientContext from '../../providers/djclient';
9
+ import { labelize } from '../../../utils/form';
9
10
 
10
11
  SyntaxHighlighter.registerLanguage('sql', sql);
11
12
  foundation.hljs['padding'] = '2rem';
@@ -88,6 +89,42 @@ export default function NodeInfoTab({ node }) {
88
89
  );
89
90
  };
90
91
 
92
+ const metricMetadataDiv =
93
+ node?.type === 'metric' ? (
94
+ <div className="list-group-item d-flex">
95
+ <div className="d-flex gap-2 w-100 py-3">
96
+ <div>
97
+ <h6 className="mb-0 w-100">Direction</h6>
98
+ <p
99
+ className="mb-0 opacity-75"
100
+ role="dialog"
101
+ aria-hidden="false"
102
+ aria-label="MetricDirection"
103
+ >
104
+ {node?.metric_metadata?.direction
105
+ ? labelize(node?.metric_metadata?.direction)
106
+ : 'None'}
107
+ </p>
108
+ </div>
109
+ <div style={{ marginRight: '2rem' }}>
110
+ <h6 className="mb-0 w-100">Unit</h6>
111
+ <p
112
+ className="mb-0 opacity-75"
113
+ role="dialog"
114
+ aria-hidden="false"
115
+ aria-label="MetricUnit"
116
+ >
117
+ {node?.metric_metadata?.unit
118
+ ? labelize(node?.metric_metadata?.unit?.label)
119
+ : 'None'}
120
+ </p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ ) : (
125
+ ''
126
+ );
127
+
91
128
  const cubeElementsDiv = node?.cube_elements ? (
92
129
  <div className="list-group-item d-flex">
93
130
  <div className="d-flex gap-2 w-100 justify-content-between py-3">
@@ -205,6 +242,7 @@ export default function NodeInfoTab({ node }) {
205
242
  </div>
206
243
  </div>
207
244
  </div>
245
+ {metricMetadataDiv}
208
246
  {node?.type !== 'cube' ? queryDiv : ''}
209
247
  {cubeElementsDiv}
210
248
  </div>
@@ -464,7 +464,7 @@ describe('<NodePage />', () => {
464
464
  fireEvent.click(savePartition);
465
465
  expect(screen.getByText('Saved!'));
466
466
  });
467
- });
467
+ }, 60000);
468
468
  // check compiled SQL on nodeInfo page
469
469
 
470
470
  it('renders the NodeHistory tab correctly', async () => {
@@ -50,6 +50,7 @@ export function NodePage() {
50
50
  if (data.type === 'metric') {
51
51
  const metric = await djClient.metric(name);
52
52
  data.dimensions = metric.dimensions;
53
+ data.metric_metadata = metric.metric_metadata;
53
54
  setNode(data);
54
55
  }
55
56
  if (data.type === 'cube') {
@@ -7,6 +7,8 @@ import { HelmetProvider } from 'react-helmet-async';
7
7
  describe('<Root />', () => {
8
8
  const mockDjClient = {
9
9
  logout: jest.fn(),
10
+ nodeDetails: jest.fn(),
11
+ listTags: jest.fn(),
10
12
  };
11
13
 
12
14
  it('renders with the correct title and navigation', async () => {
@@ -3,6 +3,7 @@ import { Outlet } from 'react-router-dom';
3
3
  import DJLogo from '../../icons/DJLogo';
4
4
  import { Helmet } from 'react-helmet-async';
5
5
  import DJClientContext from '../../providers/djclient';
6
+ import Search from '../../components/Search';
6
7
 
7
8
  export function Root() {
8
9
  const djClient = useContext(DJClientContext).DataJunctionAPI;
@@ -28,6 +29,7 @@ export function Root() {
28
29
  Data<b>Junction</b>
29
30
  </h2>
30
31
  </div>
32
+ <Search />
31
33
  <div className="menu">
32
34
  <div className="menu-item here menu-here-bg menu-lg-down-accordion me-0 me-lg-2 fw-semibold">
33
35
  <span className="menu-link">
@@ -52,8 +52,17 @@ export const DataJunctionAPI = {
52
52
  },
53
53
 
54
54
  nodes: async function (prefix) {
55
+ const queryParams = prefix ? `?prefix=${prefix}` : '';
55
56
  return await (
56
- await fetch(`${DJ_URL}/nodes/?prefix=${prefix}`, {
57
+ await fetch(`${DJ_URL}/nodes/${queryParams}`, {
58
+ credentials: 'include',
59
+ })
60
+ ).json();
61
+ },
62
+
63
+ nodeDetails: async () => {
64
+ return await (
65
+ await fetch(`${DJ_URL}/nodes/details/`, {
57
66
  credentials: 'include',
58
67
  })
59
68
  ).json();
@@ -68,7 +77,16 @@ export const DataJunctionAPI = {
68
77
  mode,
69
78
  namespace,
70
79
  primary_key,
80
+ metric_direction,
81
+ metric_unit,
71
82
  ) {
83
+ const metricMetadata =
84
+ metric_direction || metric_unit
85
+ ? {
86
+ direction: metric_direction,
87
+ unit: metric_unit,
88
+ }
89
+ : null;
72
90
  const response = await fetch(`${DJ_URL}/nodes/${nodeType}`, {
73
91
  method: 'POST',
74
92
  headers: {
@@ -82,6 +100,7 @@ export const DataJunctionAPI = {
82
100
  mode: mode,
83
101
  namespace: namespace,
84
102
  primary_key: primary_key,
103
+ metric_metadata: metricMetadata,
85
104
  }),
86
105
  credentials: 'include',
87
106
  });
@@ -95,8 +114,17 @@ export const DataJunctionAPI = {
95
114
  query,
96
115
  mode,
97
116
  primary_key,
117
+ metric_direction,
118
+ metric_unit,
98
119
  ) {
99
120
  try {
121
+ const metricMetadata =
122
+ metric_direction || metric_unit
123
+ ? {
124
+ direction: metric_direction,
125
+ unit: metric_unit,
126
+ }
127
+ : null;
100
128
  const response = await fetch(`${DJ_URL}/nodes/${name}`, {
101
129
  method: 'PATCH',
102
130
  headers: {
@@ -108,6 +136,7 @@ export const DataJunctionAPI = {
108
136
  query: query,
109
137
  mode: mode,
110
138
  primary_key: primary_key,
139
+ metric_metadata: metricMetadata,
111
140
  }),
112
141
  credentials: 'include',
113
142
  });
@@ -662,4 +691,14 @@ export const DataJunctionAPI = {
662
691
  );
663
692
  return { status: response.status, json: await response.json() };
664
693
  },
694
+ listMetricMetadata: async function () {
695
+ const response = await fetch(`${DJ_URL}/metrics/metadata`, {
696
+ method: 'GET',
697
+ headers: {
698
+ 'Content-Type': 'application/json',
699
+ },
700
+ credentials: 'include',
701
+ });
702
+ return await response.json();
703
+ },
665
704
  };
@@ -91,6 +91,7 @@ describe('DataJunctionAPI', () => {
91
91
  mode: sampleArgs[5],
92
92
  namespace: sampleArgs[6],
93
93
  primary_key: sampleArgs[7],
94
+ metric_metadata: null,
94
95
  }),
95
96
  credentials: 'include',
96
97
  });
@@ -104,6 +105,8 @@ describe('DataJunctionAPI', () => {
104
105
  'query',
105
106
  'mode',
106
107
  'primary_key',
108
+ 'neutral',
109
+ '',
107
110
  ];
108
111
  fetch.mockResponseOnce(JSON.stringify({}));
109
112
  await DataJunctionAPI.patchNode(...sampleArgs);
@@ -118,6 +121,10 @@ describe('DataJunctionAPI', () => {
118
121
  query: sampleArgs[3],
119
122
  mode: sampleArgs[4],
120
123
  primary_key: sampleArgs[5],
124
+ metric_metadata: {
125
+ direction: 'neutral',
126
+ unit: '',
127
+ },
121
128
  }),
122
129
  credentials: 'include',
123
130
  });
@@ -209,6 +209,13 @@ export const mocks = {
209
209
  label: 'default.us_state.state_region_description (string)',
210
210
  },
211
211
  ],
212
+ metric_metadata: {
213
+ unit: {
214
+ name: 'unitless',
215
+ label: 'Unitless',
216
+ },
217
+ direction: 'neutral',
218
+ },
212
219
  },
213
220
  attributes: [
214
221
  {
@@ -1013,6 +1020,13 @@ export const mocks = {
1013
1020
  ],
1014
1021
  },
1015
1022
  ],
1023
+ metric_metadata: {
1024
+ unit: {
1025
+ name: 'unitless',
1026
+ label: 'Unitless',
1027
+ },
1028
+ direction: 'neutral',
1029
+ },
1016
1030
  },
1017
1031
  mockNodeDAG: [
1018
1032
  {