datajunction-ui 0.0.15 → 0.0.16

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.
@@ -0,0 +1,526 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react';
2
+ import * as React from 'react';
3
+ import DJClientContext from '../../providers/djclient';
4
+ import { ErrorMessage, Field, Form, Formik } from 'formik';
5
+ import { FormikSelect } from '../AddEditNodePage/FormikSelect';
6
+ import EditIcon from '../../icons/EditIcon';
7
+ import { displayMessageAfterSubmit } from '../../../utils/form';
8
+ import LoadingIcon from '../../icons/LoadingIcon';
9
+
10
+ export default function ManageDimensionLinksDialog({
11
+ column,
12
+ node,
13
+ dimensions,
14
+ fkLinks,
15
+ referenceLink,
16
+ onSubmit,
17
+ }) {
18
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
19
+ const [isOpen, setIsOpen] = useState(false);
20
+ const [activeTab, setActiveTab] = useState('fk');
21
+ const ref = useRef(null);
22
+ const [dimensionColumns, setDimensionColumns] = useState([]);
23
+ const [selectedDimension, setSelectedDimension] = useState(
24
+ referenceLink?.dimension || '',
25
+ );
26
+
27
+ useEffect(() => {
28
+ const handleClickOutside = event => {
29
+ if (ref.current && !ref.current.contains(event.target)) {
30
+ setIsOpen(false);
31
+ }
32
+ };
33
+ document.addEventListener('click', handleClickOutside, true);
34
+ return () => {
35
+ document.removeEventListener('click', handleClickOutside, true);
36
+ };
37
+ }, []);
38
+
39
+ // Fetch columns for the selected dimension
40
+ useEffect(() => {
41
+ const fetchDimensionColumns = async () => {
42
+ if (selectedDimension) {
43
+ try {
44
+ const dimensionNode = await djClient.node(selectedDimension);
45
+ const columns = dimensionNode.columns.map(col => ({
46
+ value: col.name,
47
+ label: col.display_name || col.name,
48
+ }));
49
+ setDimensionColumns(columns);
50
+ } catch (error) {
51
+ console.error('Failed to fetch dimension columns:', error);
52
+ }
53
+ }
54
+ };
55
+ fetchDimensionColumns();
56
+ }, [selectedDimension, djClient]);
57
+
58
+ const handleFKSubmit = async (values, { setSubmitting, setStatus }) => {
59
+ try {
60
+ // Add FK links that are not already present
61
+ const existingDims = new Set(fkLinks);
62
+ const newDims = new Set(values.fkDimensions);
63
+
64
+ // Links to add
65
+ const toAdd = Array.from(newDims).filter(dim => !existingDims.has(dim));
66
+ // Links to remove
67
+ const toRemove = Array.from(existingDims).filter(
68
+ dim => !newDims.has(dim),
69
+ );
70
+
71
+ const addPromises = toAdd.map(dim =>
72
+ djClient.linkDimension(node.name, column.name, dim),
73
+ );
74
+ const removePromises = toRemove.map(dim =>
75
+ djClient.unlinkDimension(node.name, column.name, dim),
76
+ );
77
+
78
+ await Promise.all([...addPromises, ...removePromises]);
79
+
80
+ setStatus({ success: 'FK links updated successfully!' });
81
+ setTimeout(() => {
82
+ onSubmit();
83
+ }, 500);
84
+ } catch (error) {
85
+ setStatus({ failure: error.message });
86
+ } finally {
87
+ setSubmitting(false);
88
+ }
89
+ };
90
+
91
+ const handleReferenceSubmit = async (
92
+ values,
93
+ { setSubmitting, setStatus },
94
+ ) => {
95
+ try {
96
+ const response = await djClient.addReferenceDimensionLink(
97
+ node.name,
98
+ column.name,
99
+ values.dimensionNode,
100
+ values.dimensionColumn,
101
+ values.role || null,
102
+ );
103
+
104
+ if (response.status === 200 || response.status === 201) {
105
+ setStatus({ success: 'Reference link updated successfully!' });
106
+ setTimeout(() => {
107
+ onSubmit();
108
+ }, 500);
109
+ } else {
110
+ setStatus({ failure: response.json?.message || 'Failed to add link' });
111
+ }
112
+ } catch (error) {
113
+ setStatus({ failure: error.message });
114
+ } finally {
115
+ setSubmitting(false);
116
+ }
117
+ };
118
+
119
+ const handleRemoveReference = async () => {
120
+ if (
121
+ !window.confirm('Are you sure you want to remove this reference link?')
122
+ ) {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ const response = await djClient.removeReferenceDimensionLink(
128
+ node.name,
129
+ column.name,
130
+ );
131
+
132
+ if (response.status === 200 || response.status === 201) {
133
+ alert('Reference dimension link removed successfully!');
134
+ window.location.reload();
135
+ } else {
136
+ alert(response.json?.message || 'Failed to remove link');
137
+ }
138
+ } catch (error) {
139
+ alert(error.message);
140
+ }
141
+ };
142
+
143
+ return (
144
+ <>
145
+ <button
146
+ className="edit_button dimension-link-edit"
147
+ aria-label="ManageDimensionLinksToggle"
148
+ tabIndex="0"
149
+ onClick={() => setIsOpen(!isOpen)}
150
+ title="Manage dimension links for this column"
151
+ style={{
152
+ marginLeft: '0.35rem',
153
+ padding: '0',
154
+ opacity: 0.5,
155
+ transition: 'opacity 0.2s ease',
156
+ background: 'transparent',
157
+ border: 'none',
158
+ cursor: 'pointer',
159
+ fontSize: '12px',
160
+ color: '#999',
161
+ display: 'inline-flex',
162
+ alignItems: 'center',
163
+ }}
164
+ >
165
+ <EditIcon />
166
+ </button>
167
+ {isOpen && (
168
+ <>
169
+ {/* Backdrop overlay */}
170
+ <div
171
+ style={{
172
+ position: 'fixed',
173
+ top: 0,
174
+ left: 0,
175
+ right: 0,
176
+ bottom: 0,
177
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
178
+ zIndex: 1000,
179
+ display: 'flex',
180
+ alignItems: 'center',
181
+ justifyContent: 'center',
182
+ padding: '2rem',
183
+ }}
184
+ onClick={() => setIsOpen(false)}
185
+ >
186
+ {/* Modal */}
187
+ <div
188
+ role="dialog"
189
+ aria-label="ManageDimensionLinksDialog"
190
+ ref={ref}
191
+ onClick={e => e.stopPropagation()}
192
+ style={{
193
+ backgroundColor: 'white',
194
+ borderRadius: '8px',
195
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
196
+ width: '550px',
197
+ maxWidth: '100%',
198
+ maxHeight: '100%',
199
+ overflow: 'visible',
200
+ padding: '1.5rem',
201
+ textTransform: 'none',
202
+ fontWeight: 'normal',
203
+ position: 'relative',
204
+ zIndex: 1000,
205
+ }}
206
+ >
207
+ <div
208
+ style={{ maxHeight: 'calc(85vh - 4rem)', overflowY: 'auto' }}
209
+ >
210
+ <div style={{ display: 'flex', marginBottom: '1rem' }}>
211
+ <h3
212
+ style={{
213
+ margin: '0 1em 0 0',
214
+ fontSize: '1.25rem',
215
+ fontWeight: '600',
216
+ }}
217
+ >
218
+ Manage Dimension Links
219
+ </h3>
220
+ <span
221
+ style={{
222
+ display: 'flex',
223
+ alignItems: 'center',
224
+ gap: '0.5rem',
225
+ }}
226
+ >
227
+ <span
228
+ style={{
229
+ fontSize: '1rem',
230
+ fontWeight: '500',
231
+ color: '#333',
232
+ }}
233
+ >
234
+ {column.name}
235
+ </span>
236
+ <span
237
+ className="rounded-pill badge bg-secondary-soft"
238
+ style={{ fontSize: '0.75rem', fontWeight: 'normal' }}
239
+ >
240
+ {column.type}
241
+ </span>
242
+ </span>
243
+ </div>
244
+
245
+ {/* Tab Navigation */}
246
+ <div
247
+ style={{
248
+ display: 'flex',
249
+ borderBottom: '2px solid #e0e0e0',
250
+ marginBottom: '1rem',
251
+ }}
252
+ >
253
+ <button
254
+ type="button"
255
+ onClick={() => setActiveTab('fk')}
256
+ style={{
257
+ flex: 1,
258
+ padding: '0.5rem 1rem',
259
+ border: 'none',
260
+ background: 'transparent',
261
+ borderBottom:
262
+ activeTab === 'fk' ? '3px solid #007bff' : 'none',
263
+ color: activeTab === 'fk' ? '#007bff' : '#6c757d',
264
+ fontWeight: activeTab === 'fk' ? 'bold' : 'normal',
265
+ cursor: 'pointer',
266
+ }}
267
+ >
268
+ FK Links
269
+ </button>
270
+ <button
271
+ type="button"
272
+ onClick={() => setActiveTab('reference')}
273
+ style={{
274
+ flex: 1,
275
+ padding: '0.5rem 1rem',
276
+ border: 'none',
277
+ background: 'transparent',
278
+ borderBottom:
279
+ activeTab === 'reference'
280
+ ? '3px solid #007bff'
281
+ : 'none',
282
+ color: activeTab === 'reference' ? '#007bff' : '#6c757d',
283
+ fontWeight: activeTab === 'reference' ? 'bold' : 'normal',
284
+ cursor: 'pointer',
285
+ }}
286
+ >
287
+ Reference Links
288
+ </button>
289
+ </div>
290
+
291
+ {/* FK Links Tab */}
292
+ {activeTab === 'fk' && (
293
+ <div>
294
+ <p
295
+ style={{
296
+ fontSize: '0.875rem',
297
+ color: '#6c757d',
298
+ marginBottom: '1rem',
299
+ }}
300
+ >
301
+ FK Links automatically join via the dimension's primary
302
+ key. Select one or more dimensions to link to this column.
303
+ </p>
304
+ <Formik
305
+ initialValues={{
306
+ fkDimensions: fkLinks,
307
+ }}
308
+ onSubmit={handleFKSubmit}
309
+ >
310
+ {function Render({ isSubmitting, status }) {
311
+ return (
312
+ <Form>
313
+ {displayMessageAfterSubmit(status)}
314
+ <div style={{ marginBottom: '1rem' }}>
315
+ <label htmlFor="fkDimensions">
316
+ Select Dimensions
317
+ </label>
318
+ <FormikSelect
319
+ selectOptions={dimensions}
320
+ formikFieldName="fkDimensions"
321
+ placeholder="Select dimensions"
322
+ isMulti={true}
323
+ defaultValue={fkLinks.map(dim => ({
324
+ value: dim,
325
+ label: dim,
326
+ }))}
327
+ menuPosition="fixed"
328
+ />
329
+ </div>
330
+ <button
331
+ className="add_node"
332
+ type="submit"
333
+ disabled={isSubmitting}
334
+ style={{ marginLeft: '0' }}
335
+ >
336
+ {isSubmitting ? <LoadingIcon /> : 'Save'}
337
+ </button>
338
+ </Form>
339
+ );
340
+ }}
341
+ </Formik>
342
+ </div>
343
+ )}
344
+
345
+ {/* Reference Links Tab */}
346
+ {activeTab === 'reference' && (
347
+ <div>
348
+ <p
349
+ style={{
350
+ fontSize: '0.875rem',
351
+ color: '#6c757d',
352
+ marginBottom: '1rem',
353
+ }}
354
+ >
355
+ Reference Links explicitly map this column to a specific
356
+ dimension attribute. Use when the relationship is not
357
+ through a primary key.
358
+ </p>
359
+ <Formik
360
+ initialValues={{
361
+ dimensionNode: referenceLink?.dimension || '',
362
+ dimensionColumn: referenceLink?.dimension_column || '',
363
+ role: referenceLink?.role || '',
364
+ }}
365
+ onSubmit={handleReferenceSubmit}
366
+ validate={values => {
367
+ const errors = {};
368
+ if (!values.dimensionNode) {
369
+ errors.dimensionNode = 'Required';
370
+ }
371
+ if (!values.dimensionColumn) {
372
+ errors.dimensionColumn = 'Required';
373
+ }
374
+ return errors;
375
+ }}
376
+ >
377
+ {function Render({
378
+ isSubmitting,
379
+ status,
380
+ setFieldValue,
381
+ }) {
382
+ return (
383
+ <Form>
384
+ {displayMessageAfterSubmit(status)}
385
+ <div style={{ marginBottom: '1rem' }}>
386
+ <ErrorMessage
387
+ name="dimensionNode"
388
+ component="span"
389
+ />
390
+ <label htmlFor="dimensionNode">
391
+ Dimension Node *
392
+ </label>
393
+ <FormikSelect
394
+ selectOptions={dimensions}
395
+ formikFieldName="dimensionNode"
396
+ placeholder="Select dimension"
397
+ defaultValue={
398
+ referenceLink
399
+ ? {
400
+ value: referenceLink.dimension,
401
+ label: referenceLink.dimension,
402
+ }
403
+ : null
404
+ }
405
+ onChange={option => {
406
+ setFieldValue(
407
+ 'dimensionNode',
408
+ option?.value || '',
409
+ );
410
+ setSelectedDimension(option?.value || '');
411
+ setFieldValue('dimensionColumn', '');
412
+ }}
413
+ />
414
+ </div>
415
+
416
+ <div style={{ marginBottom: '1rem' }}>
417
+ <ErrorMessage
418
+ name="dimensionColumn"
419
+ component="span"
420
+ />
421
+ <label htmlFor="dimensionColumn">
422
+ Dimension Column *
423
+ </label>
424
+ {dimensionColumns.length > 0 ? (
425
+ <FormikSelect
426
+ selectOptions={dimensionColumns}
427
+ formikFieldName="dimensionColumn"
428
+ placeholder="Select column"
429
+ defaultValue={
430
+ referenceLink
431
+ ? {
432
+ value: referenceLink.dimension_column,
433
+ label: referenceLink.dimension_column,
434
+ }
435
+ : null
436
+ }
437
+ />
438
+ ) : (
439
+ <Field
440
+ type="text"
441
+ name="dimensionColumn"
442
+ id="dimensionColumn"
443
+ placeholder="Enter dimension column name"
444
+ style={{
445
+ width: '100%',
446
+ padding: '0.5rem',
447
+ }}
448
+ />
449
+ )}
450
+ </div>
451
+
452
+ <div style={{ marginBottom: '1rem' }}>
453
+ <label htmlFor="role">Role (Optional)</label>
454
+ <Field
455
+ type="text"
456
+ name="role"
457
+ id="role"
458
+ placeholder="e.g., birth_date, registration_date"
459
+ style={{
460
+ width: '100%',
461
+ padding: '0.5rem',
462
+ }}
463
+ />
464
+ <small
465
+ style={{
466
+ color: '#6c757d',
467
+ fontSize: '0.75rem',
468
+ }}
469
+ >
470
+ Optional role if linking the same dimension
471
+ multiple times
472
+ </small>
473
+ </div>
474
+
475
+ <div
476
+ style={{
477
+ display: 'flex',
478
+ gap: '0.5rem',
479
+ marginTop: '1rem',
480
+ }}
481
+ >
482
+ <button
483
+ className="add_node"
484
+ type="submit"
485
+ disabled={isSubmitting}
486
+ >
487
+ {isSubmitting ? (
488
+ <LoadingIcon />
489
+ ) : referenceLink ? (
490
+ 'Update Link'
491
+ ) : (
492
+ 'Add Link'
493
+ )}
494
+ </button>
495
+ {referenceLink && (
496
+ <button
497
+ type="button"
498
+ onClick={handleRemoveReference}
499
+ style={{
500
+ padding: '0.5rem 1rem',
501
+ background: '#dc3545',
502
+ color: 'white',
503
+ border: 'none',
504
+ borderRadius: '4px',
505
+ cursor: 'pointer',
506
+ textTransform: 'none',
507
+ }}
508
+ >
509
+ Remove Link
510
+ </button>
511
+ )}
512
+ </div>
513
+ </Form>
514
+ );
515
+ }}
516
+ </Formik>
517
+ </div>
518
+ )}
519
+ </div>
520
+ </div>
521
+ </div>
522
+ </>
523
+ )}
524
+ </>
525
+ );
526
+ }