datajunction-ui 0.0.144 → 0.0.146

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,75 +1,288 @@
1
1
  /**
2
2
  * A select component for picking metrics.
3
+ * Uses async search to efficiently handle large numbers of metrics.
4
+ * Results are grouped by namespace for easier navigation.
3
5
  */
4
- import { useField, useFormikContext } from 'formik';
5
- import Select from 'react-select';
6
- import React, { useContext, useEffect, useState } from 'react';
6
+ import AsyncSelect, { components } from 'react-select/async';
7
+ import React, { useContext, useEffect, useState, useCallback } from 'react';
7
8
  import DJClientContext from '../../providers/djclient';
8
9
 
9
- export const MetricsSelect = ({ cube }) => {
10
- const djClient = useContext(DJClientContext).DataJunctionAPI;
11
- const { values } = useFormikContext();
10
+ // Debounce helper
11
+ const debounce = (fn, ms) => {
12
+ let timer;
13
+ return (...args) => {
14
+ clearTimeout(timer);
15
+ return new Promise(resolve => {
16
+ timer = setTimeout(() => resolve(fn(...args)), ms);
17
+ });
18
+ };
19
+ };
12
20
 
13
- // eslint-disable-next-line no-unused-vars
14
- const [field, _, helpers] = useField('metrics');
15
- const { setValue } = helpers;
21
+ /**
22
+ * Extract namespace from a fully qualified metric name.
23
+ * e.g., "finance.total_revenue" -> "finance"
24
+ */
25
+ const getNamespace = metricName => {
26
+ if (!metricName) return 'default';
27
+ const parts = metricName.split('.');
28
+ return parts.slice(0, -1).join('.') || 'default';
29
+ };
16
30
 
17
- // All metrics options
18
- const [metrics, setMetrics] = useState([]);
31
+ /**
32
+ * Git branch icon SVG component
33
+ */
34
+ const GitBranchIcon = () => (
35
+ <svg
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ width="10"
38
+ height="10"
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="2"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ >
46
+ <line x1="6" y1="3" x2="6" y2="15" />
47
+ <circle cx="18" cy="6" r="3" />
48
+ <circle cx="6" cy="18" r="3" />
49
+ <path d="M18 9a9 9 0 0 1-9 9" />
50
+ </svg>
51
+ );
19
52
 
20
- // The existing cube's metrics, if editing a cube
21
- const [defaultMetrics, setDefaultMetrics] = useState([]);
53
+ /**
54
+ * Custom Group heading component matching Explorer styling.
55
+ */
56
+ const GroupHeading = props => {
57
+ const { data } = props;
58
+ const { namespace, gitInfo, count } = data;
22
59
 
23
- // Get metrics
24
- useEffect(() => {
25
- const fetchData = async () => {
26
- if (cube) {
27
- const cubeMetrics = cube?.current.cubeMetrics.map(metric => {
28
- return {
29
- value: metric.name,
30
- label: metric.name,
31
- };
32
- });
33
- setDefaultMetrics(cubeMetrics);
34
- await setValue(cubeMetrics.map(m => m.value));
35
- }
60
+ return (
61
+ <div
62
+ style={{
63
+ display: 'flex',
64
+ alignItems: 'center',
65
+ padding: '8px 12px',
66
+ backgroundColor: '#fafafa',
67
+ borderBottom: '1px solid #eee',
68
+ }}
69
+ >
70
+ <span style={{ fontWeight: 500, color: '#333' }}>{namespace}</span>
71
+ {gitInfo?.branch && (
72
+ <span
73
+ title={`Branch: ${gitInfo.branch}`}
74
+ style={{
75
+ marginLeft: '8px',
76
+ fontSize: '11px',
77
+ padding: '2px 6px',
78
+ borderRadius: '3px',
79
+ backgroundColor: gitInfo.isDefaultBranch ? '#d4edda' : '#fff3cd',
80
+ color: gitInfo.isDefaultBranch ? '#155724' : '#856404',
81
+ display: 'inline-flex',
82
+ alignItems: 'center',
83
+ gap: '4px',
84
+ }}
85
+ >
86
+ <GitBranchIcon />
87
+ {gitInfo.branch}
88
+ </span>
89
+ )}
90
+ <span style={{ marginLeft: '8px', color: '#999', fontSize: '12px' }}>
91
+ ({count})
92
+ </span>
93
+ </div>
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Group flat metrics array into react-select grouped format.
99
+ * Includes branch info for styled group headers.
100
+ */
101
+ const groupMetricsByNamespace = metrics => {
102
+ const grouped = {};
103
+ const gitInfoByNamespace = {};
36
104
 
37
- const metrics = await djClient.metrics();
38
- setMetrics(metrics.map(m => ({ value: m, label: m })));
39
- };
40
- fetchData().catch(console.error);
41
- }, [djClient, djClient.metrics, cube]);
42
-
43
- const getValue = options => {
44
- if (options) {
45
- return options.map(option => option.value);
46
- } else {
47
- return [];
105
+ metrics.forEach(metric => {
106
+ const namespace = getNamespace(metric.value);
107
+ if (!grouped[namespace]) {
108
+ grouped[namespace] = [];
109
+ gitInfoByNamespace[namespace] = metric.gitInfo;
48
110
  }
49
- };
111
+ grouped[namespace].push(metric);
112
+ });
113
+
114
+ // Sort namespaces alphabetically and build grouped options
115
+ return Object.keys(grouped)
116
+ .sort()
117
+ .map(namespace => {
118
+ const gitInfo = gitInfoByNamespace[namespace];
119
+ const count = grouped[namespace].length;
120
+
121
+ return {
122
+ label: namespace,
123
+ namespace,
124
+ gitInfo,
125
+ count,
126
+ options: grouped[namespace],
127
+ };
128
+ });
129
+ };
50
130
 
51
- const render = () => {
52
- if (
53
- metrics.length > 0 ||
54
- (cube !== undefined && defaultMetrics.length > 0 && metrics.length > 0)
55
- ) {
56
- return (
57
- <Select
58
- defaultValue={defaultMetrics}
59
- options={metrics}
60
- name="metrics"
61
- placeholder={`${metrics.length} Available Metrics`}
62
- onBlur={field.onBlur}
63
- onChange={selected => {
64
- setValue(getValue(selected));
131
+ /**
132
+ * Custom option component that shows display name and full node name.
133
+ */
134
+ const formatOptionLabel = (option, { context }) => {
135
+ if (context === 'menu') {
136
+ const displayName = option.label;
137
+ const nodeName = option.value;
138
+ const isDifferent = displayName !== nodeName;
139
+
140
+ return (
141
+ <div>
142
+ <div>{displayName}</div>
143
+ {isDifferent && (
144
+ <div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
145
+ {nodeName}
146
+ </div>
147
+ )}
148
+ </div>
149
+ );
150
+ }
151
+ // For selected chips: show display name with tooltip and optional branch badge
152
+ const displayName = option.label || option.value;
153
+ const gitInfo = option.gitInfo;
154
+ const showBranch = gitInfo?.branch && !gitInfo?.isDefaultBranch;
155
+
156
+ return (
157
+ <span
158
+ title={option.value}
159
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
160
+ >
161
+ <span>{displayName}</span>
162
+ {showBranch && (
163
+ <span
164
+ style={{
165
+ fontSize: '9px',
166
+ padding: '1px 4px',
167
+ borderRadius: '2px',
168
+ backgroundColor: 'rgba(255, 255, 255, 0.6)',
169
+ color: '#a2283e',
170
+ border: '1px solid rgba(162, 40, 62, 0.2)',
171
+ fontWeight: 500,
172
+ letterSpacing: '0.2px',
65
173
  }}
66
- noOptionsMessage={() => 'No metrics found.'}
67
- isMulti
68
- isClearable
69
- closeMenuOnSelect={false}
70
- />
71
- );
174
+ >
175
+ ⎇ {gitInfo.branch}
176
+ </span>
177
+ )}
178
+ </span>
179
+ );
180
+ };
181
+
182
+ export const MetricsSelect = React.memo(function MetricsSelect({
183
+ cube,
184
+ onChange,
185
+ }) {
186
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
187
+
188
+ // Currently selected metrics (for controlled component)
189
+ const [selectedMetrics, setSelectedMetrics] = useState([]);
190
+
191
+ // Load existing cube metrics when editing
192
+ useEffect(() => {
193
+ if (cube?.current?.cubeMetrics) {
194
+ const cubeMetrics = cube.current.cubeMetrics.map(metric => ({
195
+ value: metric.name,
196
+ label: metric.displayName || metric.name,
197
+ }));
198
+ setSelectedMetrics(cubeMetrics);
199
+ onChange(cubeMetrics.map(m => m.value));
200
+
201
+ // Fetch gitInfo for existing metrics so we can display branch badges
202
+ const names = cubeMetrics.map(m => m.value);
203
+ djClient.getMetricsInfo(names).then(enriched => {
204
+ if (enriched.length > 0) {
205
+ const infoByName = Object.fromEntries(
206
+ enriched.map(e => [e.value, e]),
207
+ );
208
+ setSelectedMetrics(prev =>
209
+ prev.map(m => (infoByName[m.value] ? infoByName[m.value] : m)),
210
+ );
211
+ }
212
+ });
72
213
  }
214
+ }, [cube, onChange, djClient]);
215
+
216
+ // Async load options - searches metrics via GraphQL, grouped by namespace
217
+ const loadOptions = useCallback(
218
+ debounce(async inputValue => {
219
+ if (!inputValue || inputValue.length < 2) {
220
+ return [];
221
+ }
222
+ try {
223
+ const results = await djClient.searchMetrics(inputValue, 50);
224
+ return groupMetricsByNamespace(results);
225
+ } catch (error) {
226
+ console.error('Error searching metrics:', error);
227
+ return [];
228
+ }
229
+ }, 300),
230
+ [djClient],
231
+ );
232
+
233
+ const handleChange = selected => {
234
+ setSelectedMetrics(selected || []);
235
+ onChange((selected || []).map(option => option.value));
73
236
  };
74
- return render();
75
- };
237
+
238
+ // Custom styles to color-code metric tags (matching Query Planner exactly)
239
+ const metricStyles = {
240
+ multiValue: base => ({
241
+ ...base,
242
+ backgroundColor: '#fad7dd',
243
+ border: '1px solid rgba(162, 40, 62, 0.3)',
244
+ borderRadius: '3px',
245
+ margin: '2px',
246
+ }),
247
+ multiValueLabel: base => ({
248
+ ...base,
249
+ color: '#a2283e',
250
+ fontSize: '10px',
251
+ fontWeight: 500,
252
+ padding: '2px 4px 2px 6px',
253
+ }),
254
+ multiValueRemove: base => ({
255
+ ...base,
256
+ color: '#a2283e',
257
+ padding: '0 4px',
258
+ ':hover': {
259
+ backgroundColor: '#f5c4cd',
260
+ color: '#a2283e',
261
+ },
262
+ }),
263
+ };
264
+
265
+ return (
266
+ <AsyncSelect
267
+ value={selectedMetrics}
268
+ loadOptions={loadOptions}
269
+ onChange={handleChange}
270
+ name="metrics"
271
+ placeholder="Type to search metrics..."
272
+ noOptionsMessage={({ inputValue }) =>
273
+ inputValue.length < 2
274
+ ? 'Type at least 2 characters to search'
275
+ : 'No metrics found'
276
+ }
277
+ loadingMessage={() => 'Searching...'}
278
+ formatOptionLabel={formatOptionLabel}
279
+ components={{ GroupHeading }}
280
+ styles={metricStyles}
281
+ isMulti
282
+ isClearable
283
+ closeMenuOnSelect={false}
284
+ cacheOptions
285
+ defaultOptions={false}
286
+ />
287
+ );
288
+ });
@@ -0,0 +1,108 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import { CubePreviewPanel } from '../CubePreviewPanel';
3
+ import DJClientContext from '../../../providers/djclient';
4
+ import React from 'react';
5
+
6
+ const renderPanel = ({ djClient, initialValues }) =>
7
+ render(
8
+ <DJClientContext.Provider value={{ DataJunctionAPI: djClient }}>
9
+ <CubePreviewPanel
10
+ metrics={initialValues.metrics}
11
+ dimensions={initialValues.dimensions}
12
+ />
13
+ </DJClientContext.Provider>,
14
+ );
15
+
16
+ describe('CubePreviewPanel', () => {
17
+ it('shows the empty state when no metrics or dimensions are selected', () => {
18
+ const djClient = { metricsV3: jest.fn() };
19
+ renderPanel({ djClient, initialValues: { metrics: [], dimensions: [] } });
20
+
21
+ expect(
22
+ screen.getByText('Select metrics and dimensions to preview SQL'),
23
+ ).toBeInTheDocument();
24
+ // No SQL fetch should be issued for an empty selection.
25
+ expect(djClient.metricsV3).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it('renders the generated SQL once metricsV3 resolves', async () => {
29
+ const djClient = {
30
+ metricsV3: jest.fn().mockResolvedValue({ sql: 'SELECT 1', errors: [] }),
31
+ };
32
+ renderPanel({
33
+ djClient,
34
+ initialValues: {
35
+ metrics: ['default.revenue'],
36
+ dimensions: ['default.date'],
37
+ },
38
+ });
39
+
40
+ // The fetch is debounced 500ms — wait for it via real time.
41
+ await waitFor(
42
+ () =>
43
+ expect(djClient.metricsV3).toHaveBeenCalledWith(
44
+ ['default.revenue'],
45
+ ['default.date'],
46
+ '',
47
+ ),
48
+ { timeout: 1500 },
49
+ );
50
+ // Once SQL arrives, the empty / loading / error states all disappear.
51
+ await waitFor(
52
+ () =>
53
+ expect(
54
+ screen.queryByText('Select metrics and dimensions to preview SQL'),
55
+ ).not.toBeInTheDocument(),
56
+ { timeout: 1500 },
57
+ );
58
+ expect(screen.queryByText('Generating SQL...')).not.toBeInTheDocument();
59
+ });
60
+
61
+ it('surfaces API errors returned alongside a 200 response', async () => {
62
+ const djClient = {
63
+ metricsV3: jest.fn().mockResolvedValue({ errors: ['boom'] }),
64
+ };
65
+ renderPanel({
66
+ djClient,
67
+ initialValues: {
68
+ metrics: ['default.revenue'],
69
+ dimensions: ['default.date'],
70
+ },
71
+ });
72
+ expect(
73
+ await screen.findByText('boom', {}, { timeout: 1500 }),
74
+ ).toBeInTheDocument();
75
+ });
76
+
77
+ it('surfaces the message field when the API returns it instead of sql', async () => {
78
+ const djClient = {
79
+ metricsV3: jest.fn().mockResolvedValue({ message: 'no metrics' }),
80
+ };
81
+ renderPanel({
82
+ djClient,
83
+ initialValues: {
84
+ metrics: ['default.revenue'],
85
+ dimensions: ['default.date'],
86
+ },
87
+ });
88
+ expect(
89
+ await screen.findByText('no metrics', {}, { timeout: 1500 }),
90
+ ).toBeInTheDocument();
91
+ });
92
+
93
+ it('surfaces thrown errors from metricsV3', async () => {
94
+ const djClient = {
95
+ metricsV3: jest.fn().mockRejectedValue(new Error('network down')),
96
+ };
97
+ renderPanel({
98
+ djClient,
99
+ initialValues: {
100
+ metrics: ['default.revenue'],
101
+ dimensions: ['default.date'],
102
+ },
103
+ });
104
+ expect(
105
+ await screen.findByText('network down', {}, { timeout: 1500 }),
106
+ ).toBeInTheDocument();
107
+ });
108
+ });
@@ -0,0 +1,229 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import { DimensionsSelect } from '../DimensionsSelect';
3
+ import DJClientContext from '../../../providers/djclient';
4
+ import React from 'react';
5
+
6
+ const renderInForm = ({
7
+ djClient,
8
+ cube,
9
+ initialValues = { metrics: [], dimensions: [] },
10
+ onChange = () => {},
11
+ }) =>
12
+ render(
13
+ <DJClientContext.Provider value={{ DataJunctionAPI: djClient }}>
14
+ <DimensionsSelect
15
+ cube={cube}
16
+ metrics={initialValues.metrics}
17
+ onChange={onChange}
18
+ />
19
+ </DJClientContext.Provider>,
20
+ );
21
+
22
+ describe('DimensionsSelect', () => {
23
+ it('renders nothing when no metrics are selected', () => {
24
+ const djClient = { commonDimensions: jest.fn() };
25
+ const { container } = renderInForm({ djClient });
26
+ expect(container.firstChild).toBeNull();
27
+ expect(djClient.commonDimensions).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('groups dimensions by hop distance', async () => {
31
+ const djClient = {
32
+ commonDimensions: jest.fn().mockResolvedValue([
33
+ // Direct dimension (path length 0)
34
+ {
35
+ name: 'default.event.event_type',
36
+ node_name: 'default.event',
37
+ node_display_name: 'Event',
38
+ attribute: 'event_type',
39
+ properties: [],
40
+ path: [],
41
+ },
42
+ // 2-hop dimension
43
+ {
44
+ name: 'default.user.country',
45
+ node_name: 'default.user',
46
+ node_display_name: 'User',
47
+ attribute: 'country',
48
+ properties: [],
49
+ path: ['default.event.user_id', 'default.user.id'],
50
+ },
51
+ ]),
52
+ };
53
+
54
+ renderInForm({
55
+ djClient,
56
+ initialValues: {
57
+ metrics: ['default.events'],
58
+ dimensions: [],
59
+ },
60
+ });
61
+
62
+ // Hop labels render once the fetch resolves.
63
+ expect(await screen.findByText('Direct Dimensions')).toBeInTheDocument();
64
+ expect(await screen.findByText('2 Hops Away')).toBeInTheDocument();
65
+
66
+ await waitFor(() =>
67
+ expect(djClient.commonDimensions).toHaveBeenCalledWith([
68
+ 'default.events',
69
+ ]),
70
+ );
71
+ });
72
+
73
+ it('uses singular "1 Hop Away" label for path length 1', async () => {
74
+ const djClient = {
75
+ commonDimensions: jest.fn().mockResolvedValue([
76
+ {
77
+ name: 'default.user.country',
78
+ node_name: 'default.user',
79
+ node_display_name: 'User',
80
+ attribute: 'country',
81
+ properties: [],
82
+ path: ['default.event.user_id'],
83
+ },
84
+ ]),
85
+ };
86
+
87
+ renderInForm({
88
+ djClient,
89
+ initialValues: { metrics: ['default.events'], dimensions: [] },
90
+ });
91
+
92
+ expect(await screen.findByText('1 Hop Away')).toBeInTheDocument();
93
+ });
94
+
95
+ it('pre-fills selected dimensions when editing an existing cube', async () => {
96
+ const djClient = {
97
+ commonDimensions: jest.fn().mockResolvedValue([
98
+ {
99
+ name: 'default.event.event_type',
100
+ node_name: 'default.event',
101
+ node_display_name: 'Event',
102
+ attribute: 'event_type',
103
+ properties: ['primary_key'],
104
+ path: [],
105
+ },
106
+ ]),
107
+ };
108
+ const cube = {
109
+ current: {
110
+ cubeDimensions: [
111
+ {
112
+ name: 'default.event.event_type',
113
+ attribute: 'event_type',
114
+ properties: ['primary_key'],
115
+ },
116
+ ],
117
+ },
118
+ };
119
+
120
+ renderInForm({
121
+ djClient,
122
+ cube,
123
+ initialValues: { metrics: ['default.events'], dimensions: [] },
124
+ });
125
+
126
+ // The PK suffix is appended to the chip label.
127
+ expect(
128
+ await screen.findByText(content => content.includes('(PK)')),
129
+ ).toBeInTheDocument();
130
+ });
131
+
132
+ it('shows the role suffix on chip labels for role-aliased cube dimensions', async () => {
133
+ const djClient = {
134
+ commonDimensions: jest.fn().mockResolvedValue([
135
+ {
136
+ name: 'default.user_dim.country_code[birth_country]',
137
+ node_name: 'default.user_dim',
138
+ node_display_name: 'User Dim',
139
+ attribute: 'country_code',
140
+ properties: [],
141
+ path: [],
142
+ },
143
+ ]),
144
+ };
145
+ const cube = {
146
+ current: {
147
+ cubeDimensions: [
148
+ {
149
+ name: 'default.user_dim.country_code[birth_country]',
150
+ attribute: 'country_code',
151
+ role: 'birth_country',
152
+ properties: [],
153
+ },
154
+ ],
155
+ },
156
+ };
157
+
158
+ renderInForm({
159
+ djClient,
160
+ cube,
161
+ initialValues: { metrics: ['default.users'], dimensions: [] },
162
+ });
163
+
164
+ // Role surfaces as a "[birth_country]" suffix on the labelized attribute.
165
+ expect(
166
+ await screen.findByText(content =>
167
+ content.includes('Country Code [birth_country]'),
168
+ ),
169
+ ).toBeInTheDocument();
170
+ });
171
+
172
+ it('renders the role suffix on the chip even for selected role-aliased options', async () => {
173
+ // Two role-aliased instances of the same attribute show as two distinct
174
+ // chips, distinguishable by the role suffix in the label.
175
+ const djClient = {
176
+ commonDimensions: jest.fn().mockResolvedValue([
177
+ {
178
+ name: 'default.user_dim.country_code[birth_country]',
179
+ node_name: 'default.user_dim',
180
+ node_display_name: 'User Dim',
181
+ attribute: 'country_code',
182
+ properties: [],
183
+ path: [],
184
+ },
185
+ {
186
+ name: 'default.user_dim.country_code[residence_country]',
187
+ node_name: 'default.user_dim',
188
+ node_display_name: 'User Dim',
189
+ attribute: 'country_code',
190
+ properties: [],
191
+ path: [],
192
+ },
193
+ ]),
194
+ };
195
+ const cube = {
196
+ current: {
197
+ cubeDimensions: [
198
+ {
199
+ name: 'default.user_dim.country_code[birth_country]',
200
+ attribute: 'country_code',
201
+ role: 'birth_country',
202
+ properties: [],
203
+ },
204
+ {
205
+ name: 'default.user_dim.country_code[residence_country]',
206
+ attribute: 'country_code',
207
+ role: 'residence_country',
208
+ properties: [],
209
+ },
210
+ ],
211
+ },
212
+ };
213
+
214
+ renderInForm({
215
+ djClient,
216
+ cube,
217
+ initialValues: { metrics: ['default.users'], dimensions: [] },
218
+ });
219
+
220
+ expect(
221
+ await screen.findByText(c => c.includes('Country Code [birth_country]')),
222
+ ).toBeInTheDocument();
223
+ expect(
224
+ await screen.findByText(c =>
225
+ c.includes('Country Code [residence_country]'),
226
+ ),
227
+ ).toBeInTheDocument();
228
+ });
229
+ });