datajunction-ui 0.0.143 → 0.0.145

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,21 +1,114 @@
1
- import React, { useContext, useEffect, useState } from 'react';
1
+ import React, { useCallback, useContext, useEffect, useState } from 'react';
2
+ import Select from 'react-select';
2
3
  import NamespaceHeader from '../../components/NamespaceHeader';
3
4
  import { DataJunctionAPI } from '../../services/DJService';
4
5
  import DJClientContext from '../../providers/djclient';
6
+ import { useCurrentUser } from '../../providers/UserProvider';
5
7
  import 'react-querybuilder/dist/query-builder.scss';
6
8
  import 'styles/styles.scss';
7
- import { ErrorMessage, Field, Form, Formik } from 'formik';
9
+ import './styles.css';
10
+ import '../QueryPlannerPage/styles.css';
11
+ import { ErrorMessage, FastField, Field, Form, Formik, useField } from 'formik';
8
12
  import { displayMessageAfterSubmit } from '../../../utils/form';
9
- import { useParams } from 'react-router-dom';
13
+ import { useParams, useNavigate } from 'react-router-dom';
10
14
  import { Action } from '../../components/forms/Action';
11
15
  import NodeNameField from '../../components/forms/NodeNameField';
12
16
  import { MetricsSelect } from './MetricsSelect';
13
17
  import { DimensionsSelect } from './DimensionsSelect';
14
- import { TagsField } from '../AddEditNodePage/TagsField';
15
- import { OwnersField } from '../AddEditNodePage/OwnersField';
18
+ import { CubePreviewPanel } from './CubePreviewPanel';
19
+
20
+ // Description textarea using FastField so typing here doesn't trigger
21
+ // re-renders of unrelated form sections (heavy ones like the SQL preview).
22
+ const DescriptionField = () => (
23
+ <FastField
24
+ as="textarea"
25
+ id="Description"
26
+ name="description"
27
+ placeholder="Describe your cube"
28
+ />
29
+ );
30
+
31
+ // Simple Tags select matching MetricsSelect styling
32
+ const CubeTagsSelect = ({ defaultValue }) => {
33
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
34
+ const [field, , helpers] = useField('tags');
35
+ const [options, setOptions] = useState([]);
36
+ const [selected, setSelected] = useState(defaultValue || []);
37
+
38
+ useEffect(() => {
39
+ djClient.listTags().then(tags => {
40
+ setOptions(tags.map(t => ({ value: t.name, label: t.display_name })));
41
+ });
42
+ }, [djClient]);
43
+
44
+ useEffect(() => {
45
+ if (defaultValue) setSelected(defaultValue);
46
+ }, [defaultValue]);
47
+
48
+ const handleChange = sel => {
49
+ setSelected(sel || []);
50
+ helpers.setValue((sel || []).map(s => s.value));
51
+ };
52
+
53
+ return (
54
+ <Select
55
+ value={selected}
56
+ options={options}
57
+ onChange={handleChange}
58
+ isMulti
59
+ placeholder="Select tags..."
60
+ />
61
+ );
62
+ };
63
+
64
+ // Simple Owners select matching MetricsSelect styling
65
+ const CubeOwnersSelect = ({ defaultValue, isEdit = false }) => {
66
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
67
+ const { currentUser } = useCurrentUser();
68
+ const [field, , helpers] = useField('owners');
69
+ const [options, setOptions] = useState([]);
70
+ const [selected, setSelected] = useState(defaultValue || []);
71
+
72
+ useEffect(() => {
73
+ djClient.users().then(users => {
74
+ setOptions(users.map(u => ({ value: u.username, label: u.username })));
75
+ });
76
+ }, [djClient]);
77
+
78
+ // Only auto-default to current user in Add mode; in Edit mode wait for defaultValue
79
+ useEffect(() => {
80
+ if (defaultValue) {
81
+ setSelected(defaultValue);
82
+ helpers.setValue(defaultValue.map(d => d.value));
83
+ } else if (!isEdit && currentUser) {
84
+ const def = [
85
+ { value: currentUser.username, label: currentUser.username },
86
+ ];
87
+ setSelected(def);
88
+ helpers.setValue([currentUser.username]);
89
+ }
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [defaultValue, currentUser, isEdit]);
92
+
93
+ const handleChange = sel => {
94
+ setSelected(sel || []);
95
+ helpers.setValue((sel || []).map(s => s.value));
96
+ };
97
+
98
+ return (
99
+ <Select
100
+ value={selected}
101
+ options={options}
102
+ onChange={handleChange}
103
+ isMulti
104
+ placeholder="Select owners..."
105
+ />
106
+ );
107
+ };
16
108
 
17
109
  export function CubeBuilderPage() {
18
110
  const djClient = useContext(DJClientContext).DataJunctionAPI;
111
+ const navigate = useNavigate();
19
112
 
20
113
  let { nodeType, initialNamespace, name } = useParams();
21
114
  const action = name !== undefined ? Action.Edit : Action.Add;
@@ -64,12 +157,8 @@ export function CubeBuilderPage() {
64
157
  await djClient.tagsNode(values.name, values.tags);
65
158
  }
66
159
  setStatus({
67
- success: (
68
- <>
69
- Successfully created {json.type} node{' '}
70
- <a href={`/nodes/${json.name}`}>{json.name}</a>!
71
- </>
72
- ),
160
+ success: true,
161
+ savedName: json.name,
73
162
  });
74
163
  } else {
75
164
  setStatus({
@@ -95,12 +184,8 @@ export function CubeBuilderPage() {
95
184
  );
96
185
  if ((status === 200 || status === 201) && tagsResponse.status === 200) {
97
186
  setStatus({
98
- success: (
99
- <>
100
- Successfully updated {json.type} node{' '}
101
- <a href={`/nodes/${json.name}`}>{json.name}</a>!
102
- </>
103
- ),
187
+ success: true,
188
+ savedName: json.name,
104
189
  });
105
190
  } else {
106
191
  setStatus({
@@ -122,61 +207,50 @@ export function CubeBuilderPage() {
122
207
  'tags',
123
208
  data.tags.map(tag => tag.name),
124
209
  );
125
- // For react-select fields, we have to explicitly set the entire
126
- // field rather than just the values
210
+ // Store default values as arrays for the Select components
127
211
  setSelectTags(
128
- <TagsField
129
- defaultValue={data.tags.map(t => {
130
- return { value: t.name, label: t.displayName };
131
- })}
132
- />,
212
+ data.tags.map(t => ({ value: t.name, label: t.displayName })),
133
213
  );
134
214
  if (data.owners) {
135
215
  setSelectOwners(
136
- <OwnersField
137
- defaultValue={data.owners.map(owner => {
138
- return { value: owner.username, label: owner.username };
139
- })}
140
- />,
216
+ data.owners.map(owner => ({
217
+ value: owner.username,
218
+ label: owner.username,
219
+ })),
141
220
  );
142
221
  }
143
222
  };
144
223
 
145
- const staticFieldsInEdit = () => (
146
- <>
147
- <div className="NodeNameInput NodeCreationInput">
148
- <label htmlFor="name">Name</label> {name}
149
- </div>
150
- <div className="NodeNameInput NodeCreationInput">
151
- <label htmlFor="name">Type</label> cube
152
- </div>
153
- <div className="DisplayNameInput NodeCreationInput">
154
- <ErrorMessage name="display_name" component="span" />
155
- <label htmlFor="displayName">Display Name</label>
156
- <Field
157
- type="text"
158
- name="display_name"
159
- id="displayName"
160
- placeholder="Human readable display name"
161
- />
162
- </div>
163
- </>
164
- );
165
-
166
224
  // @ts-ignore
167
225
  return (
168
226
  <>
169
227
  <div className="mid">
170
- <NamespaceHeader namespace="" />
228
+ <NamespaceHeader
229
+ namespace={
230
+ initialNamespace
231
+ ? initialNamespace
232
+ : name
233
+ ? name.substring(0, name.lastIndexOf('.'))
234
+ : ''
235
+ }
236
+ />
171
237
  <Formik
172
238
  initialValues={initialValues}
173
239
  validate={validator}
174
240
  onSubmit={handleSubmit}
175
241
  >
176
- {function Render({ isSubmitting, status, setFieldValue, props }) {
242
+ {function Render({
243
+ isSubmitting,
244
+ status,
245
+ setFieldValue,
246
+ values,
247
+ props,
248
+ }) {
177
249
  const [node, setNode] = useState([]);
178
250
  const [selectTags, setSelectTags] = useState(null);
179
251
  const [selectOwners, setSelectOwners] = useState(null);
252
+ const [justSaved, setJustSaved] = useState(false);
253
+ const [saveError, setSaveError] = useState(null);
180
254
 
181
255
  // Get cube
182
256
  useEffect(() => {
@@ -195,86 +269,254 @@ export function CubeBuilderPage() {
195
269
  fetchData().catch(console.error);
196
270
  }, [setFieldValue]);
197
271
 
272
+ // Stable callbacks so the memoized child components don't re-render
273
+ // on every Formik state change (e.g. typing in display_name).
274
+ const setMetrics = useCallback(
275
+ v => setFieldValue('metrics', v),
276
+ [setFieldValue],
277
+ );
278
+ const setDimensions = useCallback(
279
+ v => setFieldValue('dimensions', v),
280
+ [setFieldValue],
281
+ );
282
+
283
+ // Briefly show "Saved" state on the button, then redirect to the cube page
284
+ useEffect(() => {
285
+ if (status?.success) {
286
+ setJustSaved(true);
287
+ setSaveError(null);
288
+ const savedName = status.savedName;
289
+ const timer = setTimeout(() => {
290
+ if (savedName) {
291
+ navigate(`/nodes/${savedName}`);
292
+ }
293
+ }, 1200);
294
+ return () => clearTimeout(timer);
295
+ }
296
+ if (status?.failure) {
297
+ setSaveError(status.failure);
298
+ setJustSaved(false);
299
+ }
300
+ }, [status, navigate]);
301
+
198
302
  return (
199
- <Form>
200
- <div className="card">
201
- <div className="card-header">
202
- <h2>
203
- {action === Action.Edit ? 'Edit' : 'Create'}{' '}
204
- <span
205
- className={`node_type__cube node_type_creation_heading`}
206
- >
207
- Cube
208
- </span>
209
- </h2>
210
- {displayMessageAfterSubmit(status)}
211
- {action === Action.Add ? (
212
- <NodeNameField />
213
- ) : (
214
- staticFieldsInEdit(node)
215
- )}
216
- <div className="DescriptionInput NodeCreationInput">
217
- <ErrorMessage name="description" component="span" />
218
- <label htmlFor="Description">Description</label>
219
- <Field
220
- type="textarea"
221
- as="textarea"
222
- name="description"
223
- id="Description"
224
- placeholder="Describe your node"
225
- />
226
- </div>
227
- <div className="CubeCreationInput">
228
- <label>Metrics *</label>
229
- <p>Select metrics to include in the cube.</p>
230
- <span
231
- data-testid="select-metrics"
232
- style={{ marginTop: '15px' }}
233
- >
234
- {action === Action.Edit ? (
235
- <MetricsSelect cube={node} />
303
+ <Form className="cube-builder">
304
+ {/* Header */}
305
+ <div className="cube-builder-header">
306
+ <h2>
307
+ {action === Action.Edit ? 'Edit' : 'Create'}{' '}
308
+ <span className="node_type__cube node_type_creation_heading">
309
+ Cube
310
+ </span>
311
+ </h2>
312
+ </div>
313
+
314
+ {/* Two-column layout */}
315
+ <div className="cube-builder-layout">
316
+ {/* Left: Main form */}
317
+ <div className="cube-builder-main">
318
+ {/* Details Section */}
319
+ <div className="cube-form-section">
320
+ <div className="cube-form-section-body">
321
+ {action === Action.Add ? (
322
+ <>
323
+ {/* Add mode uses NodeNameField for namespace/name */}
324
+ <NodeNameField />
325
+
326
+ {/* Row: Description | Mode */}
327
+ <div className="cube-field-row">
328
+ <div className="cube-field cube-field-grow">
329
+ <ErrorMessage
330
+ name="description"
331
+ component="span"
332
+ />
333
+ <label
334
+ className="cube-field-label"
335
+ htmlFor="Description"
336
+ >
337
+ Description
338
+ </label>
339
+ <DescriptionField />
340
+ </div>
341
+ <div className="cube-field cube-field-small">
342
+ <label
343
+ className="cube-field-label"
344
+ htmlFor="Mode"
345
+ >
346
+ Mode
347
+ </label>
348
+ <Field as="select" name="mode" id="Mode">
349
+ <option value="draft">Draft</option>
350
+ <option value="published">Published</option>
351
+ </Field>
352
+ </div>
353
+ </div>
354
+
355
+ {/* Row: Tags | Owners */}
356
+ <div className="cube-field-row">
357
+ <div className="cube-field cube-field-half">
358
+ <label className="cube-field-label">Tags</label>
359
+ <CubeTagsSelect />
360
+ </div>
361
+ <div className="cube-field cube-field-half">
362
+ <label className="cube-field-label">
363
+ Owners
364
+ </label>
365
+ <CubeOwnersSelect />
366
+ </div>
367
+ </div>
368
+ </>
236
369
  ) : (
237
- <MetricsSelect />
370
+ <>
371
+ {/* Row 1: Name (full width) */}
372
+ <div className="cube-field">
373
+ <label className="cube-field-label">Name</label>
374
+ <div className="cube-field-static">{name}</div>
375
+ </div>
376
+
377
+ {/* Row 2: Display Name | Mode */}
378
+ <div className="cube-field-row">
379
+ <div className="cube-field cube-field-grow">
380
+ <ErrorMessage
381
+ name="display_name"
382
+ component="span"
383
+ />
384
+ <label
385
+ className="cube-field-label"
386
+ htmlFor="displayName"
387
+ >
388
+ Display Name
389
+ </label>
390
+ <FastField
391
+ type="text"
392
+ name="display_name"
393
+ id="displayName"
394
+ placeholder="Human readable name"
395
+ />
396
+ </div>
397
+ <div className="cube-field cube-field-small">
398
+ <label
399
+ className="cube-field-label"
400
+ htmlFor="Mode"
401
+ >
402
+ Mode
403
+ </label>
404
+ <Field as="select" name="mode" id="Mode">
405
+ <option value="draft">Draft</option>
406
+ <option value="published">Published</option>
407
+ </Field>
408
+ </div>
409
+ </div>
410
+
411
+ {/* Row 3: Description (full width) */}
412
+ <div className="cube-field">
413
+ <ErrorMessage
414
+ name="description"
415
+ component="span"
416
+ />
417
+ <label
418
+ className="cube-field-label"
419
+ htmlFor="Description"
420
+ >
421
+ Description
422
+ </label>
423
+ <DescriptionField />
424
+ </div>
425
+
426
+ {/* Row 4: Tags | Owners */}
427
+ <div className="cube-field-row">
428
+ <div className="cube-field cube-field-half">
429
+ <label className="cube-field-label">Tags</label>
430
+ <CubeTagsSelect defaultValue={selectTags} />
431
+ </div>
432
+ <div className="cube-field cube-field-half">
433
+ <label className="cube-field-label">
434
+ Owners
435
+ </label>
436
+ <CubeOwnersSelect
437
+ defaultValue={selectOwners}
438
+ isEdit={true}
439
+ />
440
+ </div>
441
+ </div>
442
+ </>
238
443
  )}
239
- </span>
444
+ </div>
240
445
  </div>
241
- <br />
242
- <br />
243
- <div className="CubeCreationInput">
244
- <label>Dimensions *</label>
245
- <p>
246
- Select dimensions to include in the cube. As metrics are
247
- selected above, the list of available dimensions will be
248
- filtered to those shared by the selected metrics. If the
249
- dimensions list is empty, no shared dimensions were
250
- discovered.
251
- </p>
252
- <span data-testid="select-dimensions">
253
- {action === Action.Edit ? (
254
- <DimensionsSelect cube={node} />
446
+
447
+ {/* Metrics Section */}
448
+ <div className="cube-form-section">
449
+ <div className="cube-form-section-header">
450
+ <h3>Metrics</h3>
451
+ </div>
452
+ <div className="cube-form-section-body">
453
+ <div data-testid="select-metrics">
454
+ {action === Action.Edit ? (
455
+ <MetricsSelect cube={node} onChange={setMetrics} />
456
+ ) : (
457
+ <MetricsSelect onChange={setMetrics} />
458
+ )}
459
+ </div>
460
+ </div>
461
+ </div>
462
+
463
+ {/* Dimensions Section */}
464
+ <div className="cube-form-section">
465
+ <div className="cube-form-section-header">
466
+ <h3>Dimensions</h3>
467
+ </div>
468
+ <div className="cube-form-section-body">
469
+ <div data-testid="select-dimensions">
470
+ {action === Action.Edit ? (
471
+ <DimensionsSelect
472
+ cube={node}
473
+ metrics={values.metrics}
474
+ onChange={setDimensions}
475
+ />
476
+ ) : (
477
+ <DimensionsSelect
478
+ metrics={values.metrics}
479
+ onChange={setDimensions}
480
+ />
481
+ )}
482
+ </div>
483
+ </div>
484
+ </div>
485
+ </div>
486
+
487
+ {/* Right: Sidebar */}
488
+ <div className="cube-builder-sidebar">
489
+ <CubePreviewPanel
490
+ metrics={values.metrics}
491
+ dimensions={values.dimensions}
492
+ />
493
+
494
+ <div className="cube-settings">
495
+ {saveError && (
496
+ <div className="save-error-message">{saveError}</div>
497
+ )}
498
+ <button
499
+ type="submit"
500
+ disabled={isSubmitting || justSaved}
501
+ aria-label="CreateCube"
502
+ className={`save-cube-btn${
503
+ justSaved ? ' save-cube-btn--saved' : ''
504
+ }${isSubmitting ? ' save-cube-btn--loading' : ''}`}
505
+ >
506
+ {isSubmitting ? (
507
+ <>
508
+ <span className="save-spinner" aria-hidden="true" />
509
+ Saving...
510
+ </>
511
+ ) : justSaved ? (
512
+ '✓ Saved'
513
+ ) : action === Action.Add ? (
514
+ 'Create Cube'
255
515
  ) : (
256
- <DimensionsSelect />
516
+ 'Save'
257
517
  )}
258
- </span>
259
- </div>
260
- <div className="NodeModeInput NodeCreationInput">
261
- <ErrorMessage name="mode" component="span" />
262
- <label htmlFor="Mode">Mode</label>
263
- <Field as="select" name="mode" id="Mode">
264
- <option value="draft">Draft</option>
265
- <option value="published">Published</option>
266
- </Field>
518
+ </button>
267
519
  </div>
268
- {action === Action.Edit ? selectTags : <TagsField />}
269
- {action === Action.Edit ? selectOwners : <OwnersField />}
270
- <button
271
- type="submit"
272
- disabled={isSubmitting}
273
- aria-label="CreateCube"
274
- >
275
- {action === Action.Add ? 'Create Cube' : 'Save'}{' '}
276
- {nodeType}
277
- </button>
278
520
  </div>
279
521
  </div>
280
522
  </Form>