datajunction-ui 0.0.1-a95 → 0.0.1-a97

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.1a95",
3
+ "version": "0.0.1a97",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -165,10 +165,10 @@
165
165
  ],
166
166
  "coverageThreshold": {
167
167
  "global": {
168
- "statements": 87,
168
+ "statements": 80,
169
169
  "branches": 70,
170
170
  "lines": 80,
171
- "functions": 83
171
+ "functions": 80
172
172
  }
173
173
  },
174
174
  "moduleNameMapper": {
@@ -0,0 +1,20 @@
1
+ const EyeIcon = props => (
2
+ <svg
3
+ className="feather feather-eye"
4
+ fill="none"
5
+ height="24"
6
+ width="24"
7
+ viewBox="0 0 24 24"
8
+ stroke="currentColor"
9
+ strokeWidth="2"
10
+ strokeLinecap="round"
11
+ strokeLinejoin="round"
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ {...props}
14
+ >
15
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
16
+ <circle cx="12" cy="12" r="3" />
17
+ </svg>
18
+ );
19
+
20
+ export default EyeIcon;
@@ -0,0 +1,25 @@
1
+ const JupyterExportIcon = props => (
2
+ <svg
3
+ className="feather feather-jupyter-export"
4
+ fill="none"
5
+ height="24"
6
+ width="24"
7
+ viewBox="0 0 24 24"
8
+ stroke="currentColor"
9
+ strokeWidth="2"
10
+ strokeLinecap="round"
11
+ strokeLinejoin="round"
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ {...props}
14
+ >
15
+ {/* Notebook outline */}
16
+ <rect x="3" y="2" width="14" height="20" rx="2" ry="2" />
17
+
18
+ {/* Notebook lines */}
19
+ <line x1="7" y1="6" x2="13" y2="6" />
20
+ <line x1="7" y1="10" x2="13" y2="10" />
21
+ <line x1="7" y1="14" x2="13" y2="14" />
22
+ </svg>
23
+ );
24
+
25
+ export default JupyterExportIcon;
@@ -1,51 +1,13 @@
1
1
  const PythonIcon = props => (
2
2
  <svg
3
- width="45px"
4
- height="45px"
5
- viewBox="0 0 64 64"
6
- fill="none"
7
3
  xmlns="http://www.w3.org/2000/svg"
4
+ x="0px"
5
+ y="0px"
6
+ width="24"
7
+ height="24"
8
+ viewBox="0 0 48 48"
8
9
  >
9
- <g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
10
- <g
11
- id="SVGRepo_tracerCarrier"
12
- strokeLinecap="round"
13
- strokeLinejoin="round"
14
- ></g>
15
- <g id="SVGRepo_iconCarrier">
16
- <path
17
- d="M31.885 16c-8.124 0-7.617 3.523-7.617 3.523l.01 3.65h7.752v1.095H21.197S16 23.678 16 31.876c0 8.196 4.537 7.906 4.537 7.906h2.708v-3.804s-.146-4.537 4.465-4.537h7.688s4.32.07 4.32-4.175v-7.019S40.374 16 31.885 16zm-4.275 2.454c.771 0 1.395.624 1.395 1.395s-.624 1.395-1.395 1.395a1.393 1.393 0 0 1-1.395-1.395c0-.771.624-1.395 1.395-1.395z"
18
- fill="url(#a)"
19
- ></path>
20
- <path
21
- d="M32.115 47.833c8.124 0 7.617-3.523 7.617-3.523l-.01-3.65H31.97v-1.095h10.832S48 40.155 48 31.958c0-8.197-4.537-7.906-4.537-7.906h-2.708v3.803s.146 4.537-4.465 4.537h-7.688s-4.32-.07-4.32 4.175v7.019s-.656 4.247 7.833 4.247zm4.275-2.454a1.393 1.393 0 0 1-1.395-1.395c0-.77.624-1.394 1.395-1.394s1.395.623 1.395 1.394c0 .772-.624 1.395-1.395 1.395z"
22
- fill="url(#b)"
23
- ></path>
24
- <defs>
25
- <linearGradient
26
- id="a"
27
- x1="19.075"
28
- y1="18.782"
29
- x2="34.898"
30
- y2="34.658"
31
- gradientUnits="userSpaceOnUse"
32
- >
33
- <stop stopColor="#387EB8"></stop>
34
- <stop offset="1" stopColor="#366994"></stop>
35
- </linearGradient>
36
- <linearGradient
37
- id="b"
38
- x1="28.809"
39
- y1="28.882"
40
- x2="45.803"
41
- y2="45.163"
42
- gradientUnits="userSpaceOnUse"
43
- >
44
- <stop stopColor="#FFE052"></stop>
45
- <stop offset="1" stopColor="#FFC331"></stop>
46
- </linearGradient>
47
- </defs>
48
- </g>
10
+ <path d="M 24 3 C 20.271429 3 18.240267 3.9470561 16.792969 4.5742188 L 16.791016 4.5742188 C 15.488673 5.1421213 14.704771 6.3187748 14.365234 7.4296875 C 14.025697 8.5406002 14 9.6506515 14 10.640625 L 14 14 L 10.640625 14 C 9.6506515 14 8.5406002 14.0257 7.4296875 14.365234 C 6.3187748 14.704771 5.1421212 15.488626 4.5742188 16.791016 L 4.5742188 16.792969 C 3.947056 18.24022 3 20.271429 3 24 C 3 27.728571 3.9470561 29.759733 4.5742188 31.207031 L 4.5742188 31.208984 C 5.1421212 32.511374 6.3187748 33.295229 7.4296875 33.634766 C 8.5406002 33.974256 9.6506515 34 10.640625 34 L 14 34 L 14 37.359375 C 14 38.349349 14.0257 39.459401 14.365234 40.570312 C 14.704771 41.681225 15.488626 42.857879 16.791016 43.425781 L 16.792969 43.425781 C 18.24022 44.052944 20.271429 45 24 45 C 27.728571 45 29.759733 44.052944 31.207031 43.425781 L 31.208984 43.425781 C 32.511374 42.857879 33.295229 41.681225 33.634766 40.570312 C 33.974256 39.459401 34 38.349349 34 37.359375 L 34 34 L 37.359375 34 C 38.349349 34 39.459401 33.9743 40.570312 33.634766 C 41.681225 33.295229 42.857879 32.511374 43.425781 31.208984 L 43.425781 31.207031 C 44.052944 29.75978 45 27.728571 45 24 C 45 20.271429 44.052944 18.240267 43.425781 16.792969 L 43.425781 16.791016 C 42.857879 15.488673 41.681225 14.704771 40.570312 14.365234 C 39.459401 14.025697 38.349349 14 37.359375 14 L 34 14 L 34 10.640625 C 34 9.6506515 33.974303 8.5406002 33.634766 7.4296875 C 33.295229 6.3187748 32.511374 5.1421213 31.208984 4.5742188 L 31.207031 4.5742188 C 29.75978 3.9470561 27.728571 3 24 3 z M 24 6 C 27.268623 6 28.459017 6.6519922 30.009766 7.3242188 C 30.427376 7.5063161 30.590162 7.7325533 30.765625 8.3066406 C 30.941088 8.8807279 31 9.7405985 31 10.640625 L 31 15.253906 A 1.50015 1.50015 0 0 0 31 15.740234 L 31 19 C 31 20.950062 29.450062 22.5 27.5 22.5 L 20.5 22.5 C 16.928062 22.5 14 25.428062 14 29 L 14 31 L 10.640625 31 C 9.7405985 31 8.8807279 30.941088 8.3066406 30.765625 C 7.7325533 30.590162 7.5063162 30.427376 7.3242188 30.009766 C 6.6519921 28.459017 6 27.268623 6 24 C 6 20.731377 6.6519921 19.540983 7.3242188 17.990234 C 7.5063161 17.572624 7.7325533 17.409838 8.3066406 17.234375 C 8.8807279 17.058912 9.7405985 17 10.640625 17 L 23.5 17 A 1.50015 1.50015 0 1 0 23.5 14 L 17 14 L 17 10.640625 C 17 9.7405985 17.058912 8.8807279 17.234375 8.3066406 C 17.409838 7.7325533 17.572624 7.5063162 17.990234 7.3242188 C 19.540983 6.6519921 20.731377 6 24 6 z M 20.5 9 A 1.5 1.5 0 0 0 20.5 12 A 1.5 1.5 0 0 0 20.5 9 z M 34 17 L 37.359375 17 C 38.259401 17 39.119272 17.05891 39.693359 17.234375 C 40.267447 17.409838 40.493684 17.572624 40.675781 17.990234 C 41.348008 19.540983 42 20.731377 42 24 C 42 27.268623 41.348008 28.459017 40.675781 30.009766 C 40.493684 30.427376 40.267447 30.590162 39.693359 30.765625 C 39.119272 30.941088 38.259401 31 37.359375 31 L 24.5 31 A 1.50015 1.50015 0 1 0 24.5 34 L 31 34 L 31 37.359375 C 31 38.259401 30.94109 39.119272 30.765625 39.693359 C 30.590162 40.267447 30.427376 40.493684 30.009766 40.675781 C 28.459017 41.348008 27.268623 42 24 42 C 20.731377 42 19.540983 41.348008 17.990234 40.675781 C 17.572624 40.493684 17.409838 40.267447 17.234375 39.693359 C 17.058912 39.119272 17 38.259401 17 37.359375 L 17 29 C 17 27.049938 18.549938 25.5 20.5 25.5 L 27.5 25.5 C 31.071938 25.5 34 22.571938 34 19 L 34 17 z M 27.5 36 A 1.5 1.5 0 0 0 27.5 39 A 1.5 1.5 0 0 0 27.5 36 z"></path>
49
11
  </svg>
50
12
  );
51
13
 
@@ -1,46 +1,94 @@
1
1
  import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { nightOwl } from 'react-syntax-highlighter/src/styles/hljs';
3
+ import { nightOwl } from 'react-syntax-highlighter/dist/esm/styles/hljs';
4
4
  import PythonIcon from '../../icons/PythonIcon';
5
5
 
6
6
  export default function ClientCodePopover({ code }) {
7
- const [codeAnchor, setCodeAnchor] = useState(false);
8
- const ref = useRef(null);
7
+ const [showModal, setShowModal] = useState(false);
8
+ const modalRef = useRef(null);
9
9
 
10
10
  useEffect(() => {
11
11
  const handleClickOutside = event => {
12
- if (ref.current && !ref.current.contains(event.target)) {
13
- setCodeAnchor(false);
12
+ if (modalRef.current && !modalRef.current.contains(event.target)) {
13
+ setShowModal(false);
14
14
  }
15
15
  };
16
- document.addEventListener('click', handleClickOutside, true);
16
+
17
+ if (showModal) {
18
+ document.addEventListener('mousedown', handleClickOutside);
19
+ } else {
20
+ document.removeEventListener('mousedown', handleClickOutside);
21
+ }
22
+
17
23
  return () => {
18
- document.removeEventListener('click', handleClickOutside, true);
24
+ document.removeEventListener('mousedown', handleClickOutside);
19
25
  };
20
- }, [setCodeAnchor]);
26
+ }, [showModal]);
21
27
 
22
28
  return (
23
29
  <>
24
30
  <button
25
- className="code-button"
26
- aria-label="code-button"
27
- tabIndex="0"
28
- height="45px"
29
- onClick={() => setCodeAnchor(!codeAnchor)}
31
+ className="button-3"
32
+ onClick={() => setShowModal(true)}
33
+ style={{ height: '2.5rem' }}
30
34
  >
31
- <PythonIcon />
35
+ <PythonIcon /> See Python
32
36
  </button>
33
- <div
34
- id={`node-create-code`}
35
- role="dialog"
36
- aria-label="client-code"
37
- style={{ display: codeAnchor === false ? 'none' : 'block' }}
38
- ref={ref}
39
- >
40
- <SyntaxHighlighter language="python" style={nightOwl}>
41
- {code}
42
- </SyntaxHighlighter>
43
- </div>
37
+
38
+ {showModal && (
39
+ <div
40
+ className="modal-backdrop fade in"
41
+ style={{
42
+ position: 'fixed',
43
+ top: 0,
44
+ left: 0,
45
+ width: '100vw',
46
+ height: '100vh',
47
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
48
+ zIndex: 9999,
49
+ display: 'flex',
50
+ alignItems: 'center',
51
+ justifyContent: 'center',
52
+ }}
53
+ >
54
+ <div
55
+ className="centerPopover"
56
+ ref={modalRef}
57
+ style={{
58
+ position: 'relative',
59
+ maxWidth: '80%',
60
+ width: '600px',
61
+ maxHeight: '80vh',
62
+ overflowY: 'auto',
63
+ padding: '1.5rem',
64
+ background: '#fff',
65
+ borderRadius: '10px',
66
+ boxShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
67
+ }}
68
+ >
69
+ <button
70
+ onClick={() => setShowModal(false)}
71
+ style={{
72
+ position: 'absolute',
73
+ top: '1rem',
74
+ right: '1rem',
75
+ background: 'none',
76
+ border: 'none',
77
+ fontSize: '1.5rem',
78
+ cursor: 'pointer',
79
+ color: '#999',
80
+ }}
81
+ aria-label="Close modal"
82
+ >
83
+ ×
84
+ </button>
85
+ <h2>Python Client Code</h2>
86
+ <SyntaxHighlighter language="python" style={nightOwl}>
87
+ {code}
88
+ </SyntaxHighlighter>
89
+ </div>
90
+ </div>
91
+ )}
44
92
  </>
45
93
  );
46
94
  }
@@ -9,7 +9,7 @@ import LoadingIcon from '../../icons/LoadingIcon';
9
9
 
10
10
  export default function LinkDimensionPopover({
11
11
  column,
12
- referencedDimensionNode,
12
+ dimensionNodes,
13
13
  node,
14
14
  options,
15
15
  onSubmit,
@@ -30,25 +30,31 @@ export default function LinkDimensionPopover({
30
30
  };
31
31
  }, [setPopoverAnchor]);
32
32
 
33
- const columnDimension = referencedDimensionNode;
34
-
35
33
  const handleSubmit = async (
36
- { node, column, dimension },
34
+ { node, column, updatedDimensionNodes },
37
35
  { setSubmitting, setStatus },
38
36
  ) => {
39
- if (referencedDimensionNode && dimension === 'Remove') {
40
- await unlinkDimension(
41
- node,
42
- column,
43
- referencedDimensionNode,
44
- setStatus,
45
- ).then(_ => setSubmitting(false));
46
- } else {
47
- await linkDimension(node, column, dimension, setStatus).then(_ =>
48
- setSubmitting(false),
49
- );
37
+ const oldSet = new Set(dimensionNodes);
38
+ const newSet = new Set(updatedDimensionNodes);
39
+ try {
40
+ const linkPromises = Array.from(newSet)
41
+ .filter(item => !oldSet.has(item))
42
+ .map(dimension => {
43
+ return linkDimension(node, column, dimension, setStatus);
44
+ });
45
+ const unlinkPromises = Array.from(oldSet)
46
+ .filter(item => !newSet.has(item))
47
+ .map(dimension => {
48
+ return unlinkDimension(node, column, dimension, setStatus);
49
+ });
50
+ await Promise.all([...linkPromises, ...unlinkPromises]);
51
+ } catch (error) {
52
+ console.error('Error in editing linked dimensions:', error);
53
+ setStatus({ error: error.message });
54
+ } finally {
55
+ setSubmitting(false);
56
+ onSubmit();
50
57
  }
51
- onSubmit();
52
58
  };
53
59
 
54
60
  const linkDimension = async (node, column, dimension, setStatus) => {
@@ -100,8 +106,7 @@ export default function LinkDimensionPopover({
100
106
  initialValues={{
101
107
  column: column.name,
102
108
  node: node.name,
103
- dimension: '',
104
- currentDimension: referencedDimensionNode,
109
+ updatedDimensionNodes: '',
105
110
  }}
106
111
  onSubmit={handleSubmit}
107
112
  >
@@ -111,20 +116,21 @@ export default function LinkDimensionPopover({
111
116
  {displayMessageAfterSubmit(status)}
112
117
  <span data-testid="link-dimension">
113
118
  <FormikSelect
114
- selectOptions={[
115
- { value: 'Remove', label: '[Remove Dimension]' },
116
- ].concat(options)}
117
- formikFieldName="dimension"
119
+ selectOptions={options}
120
+ formikFieldName="updatedDimensionNodes"
118
121
  placeholder="Select dimension to link"
119
122
  className=""
120
123
  defaultValue={
121
- referencedDimensionNode
122
- ? {
123
- value: referencedDimensionNode,
124
- label: referencedDimensionNode,
125
- }
126
- : ''
124
+ dimensionNodes.length > 0
125
+ ? dimensionNodes.map(dimNode => {
126
+ return {
127
+ value: dimNode,
128
+ label: dimNode,
129
+ };
130
+ })
131
+ : []
127
132
  }
133
+ isMulti={true}
128
134
  />
129
135
  </span>
130
136
  <input
@@ -7,6 +7,7 @@ import { labelize } from '../../../utils/form';
7
7
  import PartitionColumnPopover from './PartitionColumnPopover';
8
8
  import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
9
9
  import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
10
+ import { link } from 'fs';
10
11
 
11
12
  export default function NodeColumnTab({ node, djClient }) {
12
13
  const [attributes, setAttributes] = useState([]);
@@ -146,16 +147,20 @@ export default function NodeColumnTab({ node, djClient }) {
146
147
  </td>
147
148
  {node.type !== 'cube' ? (
148
149
  <td>
149
- {referencedDimensionNode !== null ? (
150
- <a href={`/nodes/${referencedDimensionNode}`}>
151
- {referencedDimensionNode}
152
- </a>
153
- ) : (
154
- ''
155
- )}
150
+ {dimensionLinks.length > 0
151
+ ? dimensionLinks.map(link => (
152
+ <span
153
+ className="rounded-pill badge bg-secondary-soft"
154
+ style={{ fontSize: '14px' }}
155
+ key={link[0]}
156
+ >
157
+ <a href={`/nodes/${link[0]}`}>{link[0]}</a>
158
+ </span>
159
+ ))
160
+ : ''}
156
161
  <LinkDimensionPopover
157
162
  column={col}
158
- referencedDimensionNode={referencedDimensionNode}
163
+ dimensionNodes={dimensionLinks.map(link => link[0])}
159
164
  node={node}
160
165
  options={dimensions}
161
166
  onSubmit={async () => {
@@ -1,4 +1,5 @@
1
1
  import DJClientContext from '../../providers/djclient';
2
+ import JupyterExportIcon from '../../icons/JupyterExportIcon';
2
3
  import { useContext } from 'react';
3
4
 
4
5
  export default function NotebookDownload({ node }) {
@@ -23,15 +24,13 @@ export default function NotebookDownload({ node }) {
23
24
 
24
25
  return (
25
26
  <>
26
- <div
27
- className="badge download_notebook"
28
- style={{ cursor: 'pointer', backgroundColor: '#ffefd0' }}
29
- tabIndex="0"
30
- height="45px"
27
+ <button
28
+ className="button-3"
31
29
  onClick={downloadFile}
30
+ style={{ height: '2.5rem' }}
32
31
  >
33
- Export as Notebook
34
- </div>
32
+ <JupyterExportIcon /> Export as Notebook
33
+ </button>
35
34
  </>
36
35
  );
37
36
  }
@@ -0,0 +1,226 @@
1
+ import { useContext, useState, useRef, useEffect } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import EyeIcon from '../../icons/EyeIcon';
4
+ import ExpandedIcon from '../../icons/ExpandedIcon';
5
+ import CollapsedIcon from '../../icons/CollapsedIcon';
6
+
7
+ const EVENT_TYPES = ['delete', 'update'];
8
+
9
+ export default function WatchButton({ node }) {
10
+ if (!node || !node.name || !node.type) {
11
+ return null;
12
+ }
13
+
14
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
15
+ const [selectedEvents, setSelectedEvents] = useState([]);
16
+ const [loading, setLoading] = useState(false);
17
+ const [dropdownOpen, setDropdownOpen] = useState(false);
18
+ const dropdownRef = useRef();
19
+
20
+ // Load existing preferences
21
+ useEffect(() => {
22
+ const loadPreferences = async () => {
23
+ try {
24
+ const preferences = await djClient.getNotificationPreferences({
25
+ entity_name: node.name,
26
+ });
27
+
28
+ const matched = preferences.find(item =>
29
+ item.alert_types.includes('web'),
30
+ );
31
+
32
+ if (matched) {
33
+ setSelectedEvents(matched.activity_types);
34
+ }
35
+ } catch (err) {
36
+ console.error('Failed to load notification preferences', err);
37
+ }
38
+ };
39
+
40
+ loadPreferences();
41
+ }, [djClient, node.name, node.type]);
42
+
43
+ // Close dropdown on outside click
44
+ useEffect(() => {
45
+ const handleClickOutside = event => {
46
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
47
+ setDropdownOpen(false);
48
+ }
49
+ };
50
+
51
+ document.addEventListener('mousedown', handleClickOutside);
52
+ return () => document.removeEventListener('mousedown', handleClickOutside);
53
+ }, []);
54
+ const toggleEvent = async event => {
55
+ const isSelected = selectedEvents.includes(event);
56
+
57
+ try {
58
+ setLoading(true);
59
+ let updatedEvents;
60
+
61
+ if (!isSelected) {
62
+ updatedEvents = Array.from(new Set([...selectedEvents, event]));
63
+ } else {
64
+ updatedEvents = selectedEvents.filter(e => e !== event);
65
+ }
66
+
67
+ if (updatedEvents.length === 0) {
68
+ await djClient.unsubscribeFromNotifications({
69
+ entity_type: 'node',
70
+ entity_name: node.name,
71
+ });
72
+ } else {
73
+ await djClient.subscribeToNotifications({
74
+ entity_type: 'node',
75
+ entity_name: node.name,
76
+ activity_types: updatedEvents,
77
+ alert_types: ['web'],
78
+ });
79
+ }
80
+
81
+ setSelectedEvents(updatedEvents);
82
+ } catch (err) {
83
+ console.error('Failed to update preference', err);
84
+ } finally {
85
+ setLoading(false);
86
+ }
87
+ };
88
+
89
+ const handleWatchClick = async () => {
90
+ try {
91
+ setLoading(true);
92
+ if (selectedEvents.length === 0) {
93
+ await djClient.subscribeToNotifications({
94
+ entity_type: 'node',
95
+ entity_name: node.name,
96
+ activity_types: EVENT_TYPES,
97
+ alert_types: ['web'],
98
+ });
99
+ setSelectedEvents(EVENT_TYPES);
100
+ } else {
101
+ await djClient.unsubscribeFromNotifications({
102
+ entity_type: 'node',
103
+ entity_name: node.name,
104
+ });
105
+ setSelectedEvents([]);
106
+ }
107
+ } catch (err) {
108
+ console.error('Watch toggle failed', err);
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ };
113
+
114
+ return (
115
+ <div
116
+ className="btn-group"
117
+ ref={dropdownRef}
118
+ style={{
119
+ position: 'relative',
120
+ display: 'inline-flex',
121
+ verticalAlign: 'middle',
122
+ }}
123
+ >
124
+ <button
125
+ className="button-3"
126
+ onClick={handleWatchClick}
127
+ disabled={loading}
128
+ style={{
129
+ borderTopRightRadius: 0,
130
+ borderBottomRightRadius: 0,
131
+ marginRight: 0,
132
+ height: '2.5rem',
133
+ display: 'flex',
134
+ alignItems: 'center',
135
+ gap: '6px',
136
+ }}
137
+ >
138
+ <EyeIcon />
139
+ Watch
140
+ {selectedEvents.length > 0 && (
141
+ <span
142
+ style={{
143
+ backgroundColor: '#e2e6ed',
144
+ color: '#333',
145
+ padding: '2px 6px',
146
+ borderRadius: '999px',
147
+ fontSize: '0.75rem',
148
+ fontWeight: 500,
149
+ lineHeight: 1,
150
+ }}
151
+ >
152
+ {selectedEvents.length}
153
+ </span>
154
+ )}
155
+ </button>
156
+
157
+ <button
158
+ className="button-3"
159
+ onClick={() => setDropdownOpen(prev => !prev)}
160
+ disabled={loading}
161
+ style={{
162
+ borderTopLeftRadius: 0,
163
+ borderBottomLeftRadius: 0,
164
+ marginLeft: 0,
165
+ height: '2.5rem',
166
+ }}
167
+ aria-label="Toggle dropdown"
168
+ >
169
+ {dropdownOpen ? <ExpandedIcon /> : <CollapsedIcon />}
170
+ </button>
171
+
172
+ {dropdownOpen && (
173
+ <ul
174
+ className="p-2"
175
+ style={{
176
+ display: 'block',
177
+ minWidth: '220px',
178
+ position: 'absolute',
179
+ top: '100%',
180
+ right: 0,
181
+ zIndex: 9999,
182
+ backgroundColor: '#fff',
183
+ border: '1px solid #ccc',
184
+ borderRadius: '0.5rem',
185
+ marginTop: '0.25rem',
186
+ boxShadow: '0px 6px 16px rgba(0, 0, 0, 0.1)',
187
+ padding: '0.5rem 0',
188
+ }}
189
+ >
190
+ {EVENT_TYPES.map(event => {
191
+ const isSelected = selectedEvents.includes(event);
192
+ return (
193
+ <li
194
+ key={event}
195
+ onClick={() => toggleEvent(event)}
196
+ style={{
197
+ display: 'flex',
198
+ alignItems: 'center',
199
+ padding: '0.5rem 1rem',
200
+ cursor: 'pointer',
201
+ backgroundColor: isSelected ? '#f0f4f8' : 'transparent',
202
+ fontWeight: isSelected ? '600' : '400',
203
+ fontSize: '0.9rem',
204
+ color: '#333',
205
+ borderLeft: isSelected
206
+ ? '4px solid #7983ff'
207
+ : '4px solid transparent',
208
+ transition: 'background 0.2s',
209
+ }}
210
+ >
211
+ <input
212
+ className="form-check-input"
213
+ type="checkbox"
214
+ checked={isSelected}
215
+ readOnly
216
+ style={{ marginRight: '0.75rem' }}
217
+ />
218
+ {event}
219
+ </li>
220
+ );
221
+ })}
222
+ </ul>
223
+ )}
224
+ </div>
225
+ );
226
+ }
@@ -19,7 +19,6 @@ describe('<LinkDimensionPopover />', () => {
19
19
  };
20
20
  const node = { name: 'default.node1' };
21
21
  const options = [
22
- { value: 'Remove', label: '[Remove dimension link]' },
23
22
  { value: 'default.dimension1', label: 'Dimension 1' },
24
23
  { value: 'default.dimension2', label: 'Dimension 2' },
25
24
  ];
@@ -42,7 +41,7 @@ describe('<LinkDimensionPopover />', () => {
42
41
  <DJClientContext.Provider value={mockDjClient}>
43
42
  <LinkDimensionPopover
44
43
  column={column}
45
- referencedDimensionNode={'default.dimension1'}
44
+ dimensionNodes={['default.dimension1']}
46
45
  node={node}
47
46
  options={options}
48
47
  onSubmit={onSubmitMock}
@@ -56,7 +55,7 @@ describe('<LinkDimensionPopover />', () => {
56
55
  // Click on a dimension and save
57
56
  const linkDimension = getByTestId('link-dimension');
58
57
  fireEvent.keyDown(linkDimension.firstChild, { key: 'ArrowDown' });
59
- fireEvent.click(screen.getByText('Dimension 1'));
58
+ fireEvent.click(screen.getByText('Dimension 2'));
60
59
  fireEvent.click(getByText('Save'));
61
60
 
62
61
  // Expect linkDimension to be called
@@ -64,14 +63,14 @@ describe('<LinkDimensionPopover />', () => {
64
63
  expect(mockDjClient.DataJunctionAPI.linkDimension).toHaveBeenCalledWith(
65
64
  'default.node1',
66
65
  'column1',
67
- 'default.dimension1',
66
+ 'default.dimension2',
68
67
  );
69
68
  expect(getByText('Saved!')).toBeInTheDocument();
70
69
  });
71
70
 
72
71
  // Click on the 'Remove' option and save
73
- fireEvent.keyDown(linkDimension.firstChild, { key: 'ArrowDown' });
74
- fireEvent.click(screen.getByText('[Remove dimension link]'));
72
+ const removeButton = screen.getByLabelText('Remove default.dimension1');
73
+ fireEvent.click(removeButton);
75
74
  fireEvent.click(getByText('Save'));
76
75
 
77
76
  // Expect unlinkDimension to be called
@@ -115,7 +114,7 @@ describe('<LinkDimensionPopover />', () => {
115
114
  <DJClientContext.Provider value={mockDjClient}>
116
115
  <LinkDimensionPopover
117
116
  column={column}
118
- referencedDimensionNode={'default.dimension1'}
117
+ dimensionNodes={['default.dimension1']}
119
118
  node={node}
120
119
  options={options}
121
120
  onSubmit={onSubmitMock}
@@ -129,7 +128,7 @@ describe('<LinkDimensionPopover />', () => {
129
128
  // Click on a dimension and save
130
129
  const linkDimension = getByTestId('link-dimension');
131
130
  fireEvent.keyDown(linkDimension.firstChild, { key: 'ArrowDown' });
132
- fireEvent.click(screen.getByText('Dimension 1'));
131
+ fireEvent.click(screen.getByText('Dimension 2'));
133
132
  fireEvent.click(getByText('Save'));
134
133
 
135
134
  // Expect linkDimension to be called
@@ -137,7 +136,7 @@ describe('<LinkDimensionPopover />', () => {
137
136
  expect(mockDjClient.DataJunctionAPI.linkDimension).toHaveBeenCalledWith(
138
137
  'default.node1',
139
138
  'column1',
140
- 'default.dimension1',
139
+ 'default.dimension2',
141
140
  );
142
141
  expect(
143
142
  getByText('Failed due to nonexistent dimension'),
@@ -145,8 +144,8 @@ describe('<LinkDimensionPopover />', () => {
145
144
  });
146
145
 
147
146
  // Click on the 'Remove' option and save
148
- fireEvent.keyDown(linkDimension.firstChild, { key: 'ArrowDown' });
149
- fireEvent.click(screen.getByText('[Remove Dimension]'));
147
+ const removeButton = screen.getByLabelText('Remove default.dimension1');
148
+ fireEvent.click(removeButton);
150
149
  fireEvent.click(getByText('Save'));
151
150
 
152
151
  // Expect unlinkDimension to be called
@@ -657,10 +657,6 @@ describe('<NodePage />', () => {
657
657
  expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
658
658
  mocks.mockTransformNode.name,
659
659
  );
660
-
661
- expect(
662
- screen.getByRole('table', { name: 'Materializations' }),
663
- ).toMatchSnapshot();
664
660
  },
665
661
  { timeout: 3000 },
666
662
  );
@@ -17,304 +17,3 @@ HTMLCollection [
17
17
  </code>,
18
18
  ]
19
19
  `;
20
-
21
- exports[`<NodePage /> renders the NodeMaterialization tab with materializations correctly 1`] = `
22
- <div
23
- aria-label="Materializations"
24
- class="table-vertical"
25
- role="table"
26
- >
27
- <div>
28
- <h2>
29
- Materializations
30
- </h2>
31
- <button
32
- aria-label="AddMaterialization"
33
- class="edit_button"
34
- tabindex="0"
35
- >
36
- <span
37
- class="add_node"
38
- >
39
- + Add Materialization
40
- </span>
41
- </button>
42
- <div
43
- class="fade modal-backdrop in"
44
- style="display: none;"
45
- />
46
- <div
47
- aria-label="client-code"
48
- class="centerPopover"
49
- role="dialog"
50
- style="display: none; width: 50%;"
51
- >
52
- <form
53
- action="#"
54
- >
55
- <h2>
56
- Configure Materialization
57
- </h2>
58
- <input
59
- hidden=""
60
- name="node"
61
- readonly=""
62
- value="default.repair_order_transform"
63
- />
64
- <span
65
- data-testid="edit-partition"
66
- >
67
- <label
68
- for="strategy"
69
- >
70
- Strategy
71
- </label>
72
- <select
73
- id="strategy"
74
- name="strategy"
75
- >
76
- <option
77
- value="full"
78
- >
79
- Full
80
- </option>
81
- <option
82
- value="incremental_time"
83
- >
84
- Incremental Time
85
- </option>
86
- </select>
87
- </span>
88
- <br />
89
- <br />
90
- <label
91
- for="schedule"
92
- >
93
- Schedule
94
- </label>
95
- <input
96
- default=""
97
- id="schedule"
98
- name="schedule"
99
- placeholder="Cron"
100
- type="text"
101
- value="@daily"
102
- />
103
- <br />
104
- <br />
105
- <div
106
- class="DescriptionInput"
107
- >
108
- <label
109
- for="lookback_window"
110
- >
111
- Lookback Window
112
- </label>
113
- <input
114
- default=""
115
- id="lookback_window"
116
- name="lookback_window"
117
- placeholder="1 DAY"
118
- type="text"
119
- value="1 DAY"
120
- />
121
- </div>
122
- <br />
123
- <div
124
- class="DescriptionInput"
125
- >
126
- <details>
127
- <summary>
128
- <label
129
- for="SparkConfig"
130
- style="display: inline-block;"
131
- >
132
- Spark Config
133
- </label>
134
- </summary>
135
- <textarea
136
- id="SparkConfig"
137
- name="spark_config"
138
- style="display: none;"
139
- type="textarea"
140
- >
141
- [object Object]
142
- </textarea>
143
- <div
144
- class="relative flex bg-[#282a36]"
145
- role="button"
146
- tabindex="0"
147
- >
148
- <div
149
- class="cm-theme-light"
150
- id="spark_config"
151
- name="spark_config"
152
- options="[object Object]"
153
- style="margin: 0px 0px 23px 0px; flex: 1; font-size: 150%; text-align: left;"
154
- >
155
- <div
156
- class="cm-editor ͼ1 ͼ2 ͼ4 ͼ1g ͼ15"
157
- >
158
- <div
159
- aria-live="polite"
160
- style="position: absolute; top: -10000px;"
161
- />
162
- <div
163
- class="cm-scroller"
164
- tabindex="-1"
165
- >
166
- <div
167
- aria-hidden="true"
168
- class="cm-gutters"
169
- style="min-height: 56px; position: sticky;"
170
- >
171
- <div
172
- class="cm-gutter cm-lineNumbers"
173
- >
174
- <div
175
- class="cm-gutterElement"
176
- style="height: 0px; visibility: hidden; pointer-events: none;"
177
- >
178
- 9
179
- </div>
180
- <div
181
- class="cm-gutterElement cm-activeLineGutter"
182
- style="height: 14px;"
183
- >
184
- 1
185
- </div>
186
- <div
187
- class="cm-gutterElement"
188
- style="height: 14px;"
189
- >
190
- 2
191
- </div>
192
- <div
193
- class="cm-gutterElement"
194
- style="height: 14px;"
195
- >
196
- 3
197
- </div>
198
- <div
199
- class="cm-gutterElement"
200
- style="height: 14px;"
201
- >
202
- 4
203
- </div>
204
- </div>
205
- <div
206
- class="cm-gutter cm-foldGutter"
207
- >
208
- <div
209
- class="cm-gutterElement"
210
- style="height: 0px; visibility: hidden; pointer-events: none;"
211
- >
212
- <span
213
- title="Unfold line"
214
- >
215
-
216
- </span>
217
- </div>
218
- <div
219
- class="cm-gutterElement cm-activeLineGutter"
220
- style="height: 14px;"
221
- >
222
- <span
223
- title="Fold line"
224
- >
225
-
226
- </span>
227
- </div>
228
- </div>
229
- </div>
230
- <div
231
- aria-autocomplete="list"
232
- aria-multiline="true"
233
- autocapitalize="off"
234
- autocorrect="off"
235
- class="cm-content"
236
- contenteditable="true"
237
- data-language="json"
238
- role="textbox"
239
- spellcheck="false"
240
- style="tab-size: 4"
241
- translate="no"
242
- >
243
- <div
244
- class="cm-activeLine cm-line"
245
- >
246
- {
247
- </div>
248
- <div
249
- class="cm-line"
250
- >
251
- "spark.executor.memory":
252
- <span
253
- class="ͼe"
254
- >
255
- "16g"
256
- </span>
257
- ,
258
- </div>
259
- <div
260
- class="cm-line"
261
- >
262
- "spark.memory.fraction":
263
- <span
264
- class="ͼe"
265
- >
266
- "0.3"
267
- </span>
268
- </div>
269
- <div
270
- class="cm-line"
271
- >
272
- }
273
- </div>
274
- </div>
275
- <div
276
- aria-hidden="true"
277
- class="cm-selectionLayer"
278
- />
279
- <div
280
- aria-hidden="true"
281
- class="cm-cursorLayer"
282
- style="animation-duration: 1200ms;"
283
- />
284
- </div>
285
- </div>
286
- </div>
287
- </div>
288
- </details>
289
-
290
- </div>
291
- <button
292
- aria-hidden="false"
293
- aria-label="SaveEditColumn"
294
- class="add_node"
295
- type="submit"
296
- >
297
- Save
298
- </button>
299
- </form>
300
- </div>
301
- <div
302
- class="message alert"
303
- style="margin-top: 10px;"
304
- >
305
- No materialization workflows configured for this node.
306
- </div>
307
- </div>
308
- <div>
309
- <h2>
310
- Materialized Datasets
311
- </h2>
312
- <div
313
- class="message alert"
314
- style="margin-top: 10px;"
315
- >
316
- No materialized datasets available for this node.
317
- </div>
318
- </div>
319
- </div>
320
- `;
@@ -12,6 +12,7 @@ import DJClientContext from '../../providers/djclient';
12
12
  import NodeValidateTab from './NodeValidateTab';
13
13
  import NodeMaterializationTab from './NodeMaterializationTab';
14
14
  import ClientCodePopover from './ClientCodePopover';
15
+ import WatchButton from './WatchNodeButton';
15
16
  import NodesWithDimension from './NodesWithDimension';
16
17
  import NodeColumnLineage from './NodeLineageTab';
17
18
  import EditIcon from '../../icons/EditIcon';
@@ -150,44 +151,66 @@ export function NodePage() {
150
151
  case 'dependencies':
151
152
  tabToDisplay = <NodeDependenciesTab node={node} djClient={djClient} />;
152
153
  break;
153
- default: /* istanbul ignore next */
154
+ default:
155
+ /* istanbul ignore next */
154
156
  tabToDisplay = <NodeInfoTab node={node} />;
155
157
  }
158
+
159
+ const NodeButtons = () => {
160
+ return (
161
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
162
+ <button
163
+ className="button-3"
164
+ onClick={() => navigate(`/nodes/${node?.name}/edit`)}
165
+ >
166
+ <EditIcon /> Edit
167
+ </button>
168
+
169
+ <WatchButton node={node} />
170
+
171
+ <ClientCodePopover code={node?.createNodeClientCode} />
172
+ {node?.type === 'cube' && <NotebookDownload node={node} />}
173
+ </div>
174
+ );
175
+ };
176
+
156
177
  // @ts-ignore
157
178
  return (
158
179
  <div className="node__header">
159
180
  <NamespaceHeader namespace={name.split('.').slice(0, -1).join('.')} />
160
181
  <div className="card">
161
182
  {node?.message === undefined ? (
162
- <div className="card-header">
163
- <h3
164
- className="card-title align-items-start flex-column"
165
- style={{ display: 'inline-block' }}
183
+ <div className="card-header" style={{}}>
184
+ <div
185
+ style={{
186
+ display: 'flex',
187
+ flexDirection: 'row',
188
+ justifyContent: 'space-between',
189
+ }}
166
190
  >
167
- <span
168
- className="card-label fw-bold text-gray-800"
169
- role="dialog"
170
- aria-hidden="false"
171
- aria-label="DisplayName"
191
+ <h3
192
+ className="card-title align-items-start flex-column"
193
+ style={{ display: 'inline-block' }}
172
194
  >
173
- {node?.display_name}{' '}
174
195
  <span
175
- className={'node_type__' + node?.type + ' badge node_type'}
196
+ className="card-label fw-bold text-gray-800"
176
197
  role="dialog"
177
198
  aria-hidden="false"
178
- aria-label="NodeType"
199
+ aria-label="DisplayName"
179
200
  >
180
- {node?.type}
201
+ {node?.display_name}{' '}
202
+ <span
203
+ className={'node_type__' + node?.type + ' badge node_type'}
204
+ role="dialog"
205
+ aria-hidden="false"
206
+ aria-label="NodeType"
207
+ >
208
+ {node?.type}
209
+ </span>
181
210
  </span>
182
- </span>
183
- </h3>
184
- <a
185
- href={`/nodes/${node?.name}/edit`}
186
- style={{ marginLeft: '0.5rem' }}
187
- >
188
- <EditIcon />
189
- </a>
190
- <ClientCodePopover code={node?.createNodeClientCode} />
211
+ </h3>
212
+ <NodeButtons />
213
+ </div>
191
214
  <div>
192
215
  <a
193
216
  href={'/nodes/' + node?.name}
@@ -204,7 +227,6 @@ export function NodePage() {
204
227
  >
205
228
  {node?.version}
206
229
  </span>
207
- {node?.type === 'cube' ? <NotebookDownload node={node} /> : <></>}
208
230
  </div>
209
231
  <div className="align-items-center row">
210
232
  {tabsList(node).map(buildTabs)}
@@ -1137,4 +1137,61 @@ export const DataJunctionAPI = {
1137
1137
  })
1138
1138
  ).json();
1139
1139
  },
1140
+ // GET /notifications/
1141
+ getNotificationPreferences: async function (params = {}) {
1142
+ const url = new URL(`${DJ_URL}/notifications/`);
1143
+ Object.entries(params).forEach(([key, value]) =>
1144
+ url.searchParams.append(key, value),
1145
+ );
1146
+
1147
+ return await (
1148
+ await fetch(url, {
1149
+ credentials: 'include',
1150
+ })
1151
+ ).json();
1152
+ },
1153
+
1154
+ // POST /notifications/subscribe
1155
+ subscribeToNotifications: async function ({
1156
+ entity_type,
1157
+ entity_name,
1158
+ activity_types,
1159
+ alert_types,
1160
+ }) {
1161
+ const response = await fetch(`${DJ_URL}/notifications/subscribe`, {
1162
+ method: 'POST',
1163
+ headers: {
1164
+ 'Content-Type': 'application/json',
1165
+ },
1166
+ credentials: 'include',
1167
+ body: JSON.stringify({
1168
+ entity_type,
1169
+ entity_name,
1170
+ activity_types,
1171
+ alert_types,
1172
+ }),
1173
+ });
1174
+
1175
+ return {
1176
+ status: response.status,
1177
+ json: await response.json(),
1178
+ };
1179
+ },
1180
+
1181
+ // DELETE /notifications/unsubscribe
1182
+ unsubscribeFromNotifications: async function ({ entity_type, entity_name }) {
1183
+ const url = new URL(`${DJ_URL}/notifications/unsubscribe`);
1184
+ url.searchParams.append('entity_type', entity_type);
1185
+ url.searchParams.append('entity_name', entity_name);
1186
+
1187
+ const response = await fetch(url, {
1188
+ method: 'DELETE',
1189
+ credentials: 'include',
1190
+ });
1191
+
1192
+ return {
1193
+ status: response.status,
1194
+ json: await response.json(),
1195
+ };
1196
+ },
1140
1197
  };
@@ -939,7 +939,7 @@ pre {
939
939
  }
940
940
 
941
941
  .centerPopover {
942
- position: absolute !important;
942
+ position: fixed !important;
943
943
  top: 1%;
944
944
  left: 25%;
945
945
 
@@ -1,49 +0,0 @@
1
- import React from 'react';
2
- import { render, fireEvent, screen, waitFor } from '@testing-library/react';
3
- import ClientCodePopover from '../ClientCodePopover';
4
- import userEvent from '@testing-library/user-event';
5
-
6
- describe('<ClientCodePopover />', () => {
7
- const defaultProps = {
8
- code: "print('Hello, World!')",
9
- };
10
-
11
- it('toggles the code popover visibility when the button is clicked', async () => {
12
- render(<ClientCodePopover {...defaultProps} />);
13
-
14
- const button = screen.getByRole('button', 'code-button');
15
-
16
- // Initially, the popover should be hidden
17
- expect(screen.getByRole('dialog', { hidden: true })).toHaveStyle(
18
- 'display: none',
19
- );
20
-
21
- // Clicking the button should display the popover
22
- fireEvent.click(button);
23
- expect(screen.getByRole('dialog', { hidden: true })).not.toHaveStyle(
24
- 'display: none',
25
- );
26
-
27
- // Clicking the button again should hide the popover
28
- fireEvent.click(button);
29
- expect(screen.getByRole('dialog', { hidden: true })).toHaveStyle(
30
- 'display: none',
31
- );
32
-
33
- // Trigger onClose by pressing <escape>
34
- userEvent.keyboard('{Escape}');
35
- // fireEvent.click(screen.getByTestId('body').firstChild());
36
- await waitFor(() => {
37
- expect(screen.getByRole('dialog', { hidden: true })).toHaveStyle(
38
- 'display: none',
39
- );
40
- });
41
- });
42
-
43
- it('renders the provided code within the SyntaxHighlighter', () => {
44
- render(<ClientCodePopover {...defaultProps} />);
45
- expect(screen.getByRole('dialog', { hidden: true })).toHaveTextContent(
46
- defaultProps.code,
47
- );
48
- });
49
- });