datajunction-ui 0.0.56 → 0.0.58

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.56",
3
+ "version": "0.0.58",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -21,6 +21,7 @@ export default function NamespaceHeader({
21
21
 
22
22
  // Git config state
23
23
  const [gitConfig, setGitConfig] = useState(null);
24
+ const [gitConfigLoading, setGitConfigLoading] = useState(true);
24
25
  const [parentGitConfig, setParentGitConfig] = useState(null);
25
26
  const [existingPR, setExistingPR] = useState(null);
26
27
 
@@ -32,6 +33,9 @@ export default function NamespaceHeader({
32
33
  const [showDeleteBranch, setShowDeleteBranch] = useState(false);
33
34
 
34
35
  useEffect(() => {
36
+ // Reset loading state when namespace changes
37
+ setGitConfigLoading(true);
38
+
35
39
  const fetchData = async () => {
36
40
  if (namespace) {
37
41
  // Fetch deployment sources
@@ -84,6 +88,8 @@ export default function NamespaceHeader({
84
88
  if (onGitConfigLoaded) {
85
89
  onGitConfigLoaded(null);
86
90
  }
91
+ } finally {
92
+ setGitConfigLoading(false);
87
93
  }
88
94
  }
89
95
  };
@@ -114,6 +120,12 @@ export default function NamespaceHeader({
114
120
  }
115
121
  return result;
116
122
  };
123
+ const handleRemoveGitConfig = async () => {
124
+ const result = await djClient.deleteNamespaceGitConfig(namespace);
125
+ if (!result?._error) {
126
+ setGitConfig(null);
127
+ }
128
+ };
117
129
 
118
130
  const handleCreateBranch = async branchName => {
119
131
  return await djClient.createBranch(namespace, branchName);
@@ -368,7 +380,7 @@ export default function NamespaceHeader({
368
380
  <circle cx="6" cy="18" r="3"></circle>
369
381
  <path d="M18 9a9 9 0 0 1-9 9"></path>
370
382
  </svg>
371
- Git Managed
383
+ Deployed from Git
372
384
  </>
373
385
  ) : (
374
386
  <>
@@ -642,55 +654,33 @@ export default function NamespaceHeader({
642
654
  {/* Right side: git actions + children */}
643
655
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
644
656
  {/* Git controls for non-branch namespaces */}
645
- {namespace && !isBranchNamespace && (
657
+ {namespace && !isBranchNamespace && !gitConfigLoading && (
646
658
  <>
659
+ <button
660
+ style={buttonStyle}
661
+ onClick={() => setShowGitSettings(true)}
662
+ title="Git Settings"
663
+ >
664
+ <svg
665
+ xmlns="http://www.w3.org/2000/svg"
666
+ width="14"
667
+ height="14"
668
+ viewBox="0 0 24 24"
669
+ fill="none"
670
+ stroke="currentColor"
671
+ strokeWidth="2"
672
+ strokeLinecap="round"
673
+ strokeLinejoin="round"
674
+ >
675
+ <circle cx="12" cy="12" r="3" />
676
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
677
+ </svg>
678
+ Git Settings
679
+ </button>
647
680
  {hasGitConfig ? (
648
- <>
649
- <button
650
- style={buttonStyle}
651
- onClick={() => setShowGitSettings(true)}
652
- title="Git Settings"
653
- >
654
- <svg
655
- xmlns="http://www.w3.org/2000/svg"
656
- width="14"
657
- height="14"
658
- viewBox="0 0 24 24"
659
- fill="none"
660
- stroke="currentColor"
661
- strokeWidth="2"
662
- strokeLinecap="round"
663
- strokeLinejoin="round"
664
- >
665
- <circle cx="12" cy="12" r="3" />
666
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
667
- </svg>
668
- </button>
669
- <button
670
- style={primaryButtonStyle}
671
- onClick={() => setShowCreateBranch(true)}
672
- >
673
- <svg
674
- xmlns="http://www.w3.org/2000/svg"
675
- width="14"
676
- height="14"
677
- viewBox="0 0 24 24"
678
- fill="none"
679
- stroke="currentColor"
680
- strokeWidth="2"
681
- strokeLinecap="round"
682
- strokeLinejoin="round"
683
- >
684
- <line x1="12" y1="5" x2="12" y2="19" />
685
- <line x1="5" y1="12" x2="19" y2="12" />
686
- </svg>
687
- New Branch
688
- </button>
689
- </>
690
- ) : (
691
681
  <button
692
- style={buttonStyle}
693
- onClick={() => setShowGitSettings(true)}
682
+ style={primaryButtonStyle}
683
+ onClick={() => setShowCreateBranch(true)}
694
684
  >
695
685
  <svg
696
686
  xmlns="http://www.w3.org/2000/svg"
@@ -703,13 +693,13 @@ export default function NamespaceHeader({
703
693
  strokeLinecap="round"
704
694
  strokeLinejoin="round"
705
695
  >
706
- <line x1="6" y1="3" x2="6" y2="15" />
707
- <circle cx="18" cy="6" r="3" />
708
- <circle cx="6" cy="18" r="3" />
709
- <path d="M18 9a9 9 0 0 1-9 9" />
696
+ <line x1="12" y1="5" x2="12" y2="19" />
697
+ <line x1="5" y1="12" x2="19" y2="12" />
710
698
  </svg>
711
- Configure Git
699
+ New Branch
712
700
  </button>
701
+ ) : (
702
+ <></>
713
703
  )}
714
704
  </>
715
705
  )}
@@ -824,6 +814,7 @@ export default function NamespaceHeader({
824
814
  isOpen={showGitSettings}
825
815
  onClose={() => setShowGitSettings(false)}
826
816
  onSave={handleSaveGitConfig}
817
+ onRemove={handleRemoveGitConfig}
827
818
  currentConfig={gitConfig}
828
819
  namespace={namespace}
829
820
  />
@@ -42,8 +42,8 @@ describe('<NamespaceHeader />', () => {
42
42
  );
43
43
  });
44
44
 
45
- // Should render Git Managed badge for git source
46
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
45
+ // Should render Deployed from Git badge for git source
46
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
47
47
  });
48
48
 
49
49
  it('should render git source badge when source type is git without branch', async () => {
@@ -73,8 +73,8 @@ describe('<NamespaceHeader />', () => {
73
73
  );
74
74
  });
75
75
 
76
- // Should render Git Managed badge for git source even without branch
77
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
76
+ // Should render Deployed from Git badge for git source even without branch
77
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
78
78
  });
79
79
 
80
80
  it('should render local source badge when source type is local', async () => {
@@ -131,7 +131,7 @@ describe('<NamespaceHeader />', () => {
131
131
  });
132
132
 
133
133
  // Should not render any source badge
134
- expect(screen.queryByText(/Git Managed/)).not.toBeInTheDocument();
134
+ expect(screen.queryByText(/Deployed from Git/)).not.toBeInTheDocument();
135
135
  expect(screen.queryByText(/Local Deploy/)).not.toBeInTheDocument();
136
136
  });
137
137
 
@@ -158,7 +158,7 @@ describe('<NamespaceHeader />', () => {
158
158
  // Should still render breadcrumb without badge
159
159
  expect(screen.getByText('test')).toBeInTheDocument();
160
160
  expect(screen.getByText('namespace')).toBeInTheDocument();
161
- expect(screen.queryByText(/Git Managed/)).not.toBeInTheDocument();
161
+ expect(screen.queryByText(/Deployed from Git/)).not.toBeInTheDocument();
162
162
  });
163
163
 
164
164
  it('should open dropdown when clicking the git managed button', async () => {
@@ -195,11 +195,11 @@ describe('<NamespaceHeader />', () => {
195
195
  );
196
196
 
197
197
  await waitFor(() => {
198
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
198
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
199
199
  });
200
200
 
201
201
  // Click the dropdown button
202
- fireEvent.click(screen.getByText(/Git Managed/));
202
+ fireEvent.click(screen.getByText(/Deployed from Git/));
203
203
 
204
204
  // Should show repository link in dropdown
205
205
  await waitFor(() => {
@@ -297,10 +297,10 @@ describe('<NamespaceHeader />', () => {
297
297
  );
298
298
 
299
299
  await waitFor(() => {
300
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
300
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
301
301
  });
302
302
 
303
- fireEvent.click(screen.getByText(/Git Managed/));
303
+ fireEvent.click(screen.getByText(/Deployed from Git/));
304
304
 
305
305
  // Should show branch names in deployment list
306
306
  await waitFor(() => {
@@ -375,11 +375,11 @@ describe('<NamespaceHeader />', () => {
375
375
  );
376
376
 
377
377
  await waitFor(() => {
378
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
378
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
379
379
  });
380
380
 
381
381
  // Open dropdown
382
- fireEvent.click(screen.getByText(/Git Managed/));
382
+ fireEvent.click(screen.getByText(/Deployed from Git/));
383
383
 
384
384
  await waitFor(() => {
385
385
  expect(screen.getByText(/github.com\/test\/repo/)).toBeInTheDocument();
@@ -418,14 +418,14 @@ describe('<NamespaceHeader />', () => {
418
418
  );
419
419
 
420
420
  await waitFor(() => {
421
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
421
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
422
422
  });
423
423
 
424
424
  // Initially shows down arrow
425
425
  expect(screen.getByText('▼')).toBeInTheDocument();
426
426
 
427
427
  // Click to open
428
- fireEvent.click(screen.getByText(/Git Managed/));
428
+ fireEvent.click(screen.getByText(/Deployed from Git/));
429
429
 
430
430
  // Should show up arrow when open
431
431
  await waitFor(() => {
@@ -455,10 +455,10 @@ describe('<NamespaceHeader />', () => {
455
455
  );
456
456
 
457
457
  await waitFor(() => {
458
- expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
458
+ expect(screen.getByText(/Deployed from Git/)).toBeInTheDocument();
459
459
  });
460
460
 
461
- fireEvent.click(screen.getByText(/Git Managed/));
461
+ fireEvent.click(screen.getByText(/Deployed from Git/));
462
462
 
463
463
  await waitFor(() => {
464
464
  // Find link by its text content (repository URL)
@@ -509,7 +509,7 @@ describe('<NamespaceHeader />', () => {
509
509
  });
510
510
  });
511
511
 
512
- it('should show Configure Git button and open modal', async () => {
512
+ it('should show Git Settings button and open modal', async () => {
513
513
  const mockDjClient = {
514
514
  namespaceSources: jest.fn().mockResolvedValue({
515
515
  total_deployments: 0,
@@ -528,10 +528,10 @@ describe('<NamespaceHeader />', () => {
528
528
  );
529
529
 
530
530
  await waitFor(() => {
531
- expect(screen.getByText('Configure Git')).toBeInTheDocument();
531
+ expect(screen.getByText('Git Settings')).toBeInTheDocument();
532
532
  });
533
533
 
534
- fireEvent.click(screen.getByText('Configure Git'));
534
+ fireEvent.click(screen.getByText('Git Settings'));
535
535
 
536
536
  await waitFor(() => {
537
537
  expect(screen.getByText('Git Configuration')).toBeInTheDocument();
@@ -725,10 +725,10 @@ describe('<NamespaceHeader />', () => {
725
725
  );
726
726
 
727
727
  await waitFor(() => {
728
- expect(screen.getByText('Configure Git')).toBeInTheDocument();
728
+ expect(screen.getByText('Git Settings')).toBeInTheDocument();
729
729
  });
730
730
 
731
- fireEvent.click(screen.getByText('Configure Git'));
731
+ fireEvent.click(screen.getByText('Git Settings'));
732
732
 
733
733
  await waitFor(() => {
734
734
  expect(screen.getByLabelText('Repository')).toBeInTheDocument();
@@ -1272,4 +1272,129 @@ describe('<NamespaceHeader />', () => {
1272
1272
  expect(onGitConfigLoaded).toHaveBeenCalledWith(null);
1273
1273
  });
1274
1274
  });
1275
+
1276
+ it('should call deleteNamespaceGitConfig when removing git settings', async () => {
1277
+ // Mock window.confirm for this test
1278
+ global.confirm = jest.fn(() => true);
1279
+
1280
+ const mockDjClient = {
1281
+ namespaceSources: jest.fn().mockResolvedValue({
1282
+ total_deployments: 0,
1283
+ primary_source: null,
1284
+ }),
1285
+ listDeployments: jest.fn().mockResolvedValue([]),
1286
+ getNamespaceGitConfig: jest.fn().mockResolvedValue({
1287
+ github_repo_path: 'test/repo',
1288
+ git_branch: 'main',
1289
+ git_path: 'nodes/',
1290
+ git_only: false,
1291
+ }),
1292
+ deleteNamespaceGitConfig: jest.fn().mockResolvedValue({ success: true }),
1293
+ };
1294
+
1295
+ render(
1296
+ <MemoryRouter>
1297
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
1298
+ <NamespaceHeader namespace="test.namespace" />
1299
+ </DJClientContext.Provider>
1300
+ </MemoryRouter>,
1301
+ );
1302
+
1303
+ await waitFor(() => {
1304
+ expect(screen.getByText('Git Settings')).toBeInTheDocument();
1305
+ });
1306
+
1307
+ fireEvent.click(screen.getByText('Git Settings'));
1308
+
1309
+ await waitFor(() => {
1310
+ expect(screen.getByText('Git Configuration')).toBeInTheDocument();
1311
+ });
1312
+
1313
+ // Click reset button in the modal (button text is "Reset")
1314
+ const removeButton = screen.getByText('Reset');
1315
+ fireEvent.click(removeButton);
1316
+
1317
+ await waitFor(() => {
1318
+ expect(mockDjClient.deleteNamespaceGitConfig).toHaveBeenCalledWith(
1319
+ 'test.namespace',
1320
+ );
1321
+ });
1322
+
1323
+ // Clean up mock
1324
+ jest.restoreAllMocks();
1325
+ });
1326
+
1327
+ it('should handle sync error in handleCreatePR', async () => {
1328
+ const mockDjClient = {
1329
+ namespaceSources: jest.fn().mockResolvedValue({
1330
+ total_deployments: 1,
1331
+ primary_source: {
1332
+ type: 'git',
1333
+ repository: 'test/repo',
1334
+ branch: 'feature',
1335
+ },
1336
+ }),
1337
+ listDeployments: jest.fn().mockResolvedValue([]),
1338
+ getNamespaceGitConfig: jest
1339
+ .fn()
1340
+ .mockResolvedValueOnce({
1341
+ github_repo_path: 'test/repo',
1342
+ git_branch: 'feature',
1343
+ git_path: 'nodes/',
1344
+ git_only: false,
1345
+ parent_namespace: 'test.main',
1346
+ })
1347
+ .mockResolvedValueOnce({
1348
+ github_repo_path: 'test/repo',
1349
+ git_branch: 'main',
1350
+ git_path: 'nodes/',
1351
+ }),
1352
+ getPullRequest: jest.fn().mockResolvedValue(null),
1353
+ syncNamespaceToGit: jest.fn().mockResolvedValue({
1354
+ _error: true,
1355
+ message: 'Sync failed: merge conflict',
1356
+ }),
1357
+ };
1358
+
1359
+ render(
1360
+ <MemoryRouter>
1361
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
1362
+ <NamespaceHeader namespace="test.feature" />
1363
+ </DJClientContext.Provider>
1364
+ </MemoryRouter>,
1365
+ );
1366
+
1367
+ await waitFor(() => {
1368
+ expect(screen.getByText('Create PR')).toBeInTheDocument();
1369
+ });
1370
+
1371
+ fireEvent.click(screen.getByText('Create PR'));
1372
+
1373
+ await waitFor(() => {
1374
+ expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
1375
+ });
1376
+
1377
+ fireEvent.change(screen.getByLabelText(/Title/), {
1378
+ target: { value: 'My PR Title' },
1379
+ });
1380
+
1381
+ const createPRButtons = screen.getAllByRole('button', {
1382
+ name: 'Create PR',
1383
+ });
1384
+ fireEvent.click(createPRButtons[createPRButtons.length - 1]);
1385
+
1386
+ await waitFor(() => {
1387
+ expect(mockDjClient.syncNamespaceToGit).toHaveBeenCalledWith(
1388
+ 'test.feature',
1389
+ 'My PR Title',
1390
+ );
1391
+ });
1392
+
1393
+ // Should show error message from sync failure
1394
+ await waitFor(() => {
1395
+ expect(
1396
+ screen.getByText(/Sync failed: merge conflict/),
1397
+ ).toBeInTheDocument();
1398
+ });
1399
+ });
1275
1400
  });
@@ -156,67 +156,13 @@ exports[`<NamespaceHeader /> should render and match the snapshot 1`] = `
156
156
  "gap": "8px",
157
157
  }
158
158
  }
159
- >
160
- <React.Fragment>
161
- <button
162
- onClick={[Function]}
163
- style={
164
- Object {
165
- "alignItems": "center",
166
- "backgroundColor": "#ffffff",
167
- "border": "1px solid #e2e8f0",
168
- "borderRadius": "4px",
169
- "color": "#475569",
170
- "cursor": "pointer",
171
- "display": "flex",
172
- "fontSize": "12px",
173
- "gap": "4px",
174
- "height": "28px",
175
- "padding": "0 10px",
176
- "whiteSpace": "nowrap",
177
- }
178
- }
179
- >
180
- <svg
181
- fill="none"
182
- height="14"
183
- stroke="currentColor"
184
- strokeLinecap="round"
185
- strokeLinejoin="round"
186
- strokeWidth="2"
187
- viewBox="0 0 24 24"
188
- width="14"
189
- xmlns="http://www.w3.org/2000/svg"
190
- >
191
- <line
192
- x1="6"
193
- x2="6"
194
- y1="3"
195
- y2="15"
196
- />
197
- <circle
198
- cx="18"
199
- cy="6"
200
- r="3"
201
- />
202
- <circle
203
- cx="6"
204
- cy="18"
205
- r="3"
206
- />
207
- <path
208
- d="M18 9a9 9 0 0 1-9 9"
209
- />
210
- </svg>
211
- Configure Git
212
- </button>
213
- </React.Fragment>
214
- </div>
159
+ />
215
160
  <GitSettingsModal
216
161
  currentConfig={null}
217
162
  isOpen={false}
218
163
  namespace="shared.dimensions.accounts"
219
164
  onClose={[Function]}
165
+ onRemove={[Function]}
220
166
  onSave={[Function]}
221
167
  />
222
168
  <CreateBranchModal
@@ -7,6 +7,7 @@ export function GitSettingsModal({
7
7
  isOpen,
8
8
  onClose,
9
9
  onSave,
10
+ onRemove,
10
11
  currentConfig,
11
12
  namespace,
12
13
  }) {
@@ -15,8 +16,10 @@ export function GitSettingsModal({
15
16
  const [path, setPath] = useState('');
16
17
  const [gitOnly, setGitOnly] = useState(true);
17
18
  const [saving, setSaving] = useState(false);
19
+ const [removing, setRemoving] = useState(false);
18
20
  const [error, setError] = useState(null);
19
21
  const [success, setSuccess] = useState(false);
22
+ const [wasRemoved, setWasRemoved] = useState(false);
20
23
 
21
24
  useEffect(() => {
22
25
  if (currentConfig) {
@@ -32,14 +35,17 @@ export function GitSettingsModal({
32
35
  setPath('nodes/');
33
36
  setGitOnly(true);
34
37
  }
35
- setSuccess(false);
38
+ // Don't reset success here - it gets reset when modal closes
39
+ // Otherwise the success banner disappears when currentConfig updates after save
36
40
  setError(null);
41
+ setWasRemoved(false);
37
42
  }, [currentConfig]);
38
43
 
39
44
  const handleSubmit = async e => {
40
45
  e.preventDefault();
41
46
  setError(null);
42
47
  setSuccess(false);
48
+ setWasRemoved(false);
43
49
  setSaving(true);
44
50
 
45
51
  try {
@@ -65,9 +71,50 @@ export function GitSettingsModal({
65
71
  }
66
72
  };
67
73
 
74
+ const handleRemove = async () => {
75
+ if (
76
+ !window.confirm(
77
+ 'Remove git configuration? This will disconnect this namespace from git but will not delete any files.',
78
+ )
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ setError(null);
84
+ setSuccess(false);
85
+ setWasRemoved(false);
86
+ setRemoving(true);
87
+
88
+ try {
89
+ const config = {
90
+ github_repo_path: null,
91
+ git_branch: null,
92
+ git_path: null,
93
+ git_only: false,
94
+ };
95
+
96
+ const result = await onRemove(config);
97
+ if (result?._error) {
98
+ setError(result.message);
99
+ } else {
100
+ setSuccess(true);
101
+ setWasRemoved(true);
102
+ // Close modal after successful removal
103
+ setTimeout(() => {
104
+ onClose();
105
+ }, 1500);
106
+ }
107
+ } catch (err) {
108
+ setError(err.message || 'Failed to remove git settings');
109
+ } finally {
110
+ setRemoving(false);
111
+ }
112
+ };
113
+
68
114
  const handleClose = () => {
69
115
  setError(null);
70
116
  setSuccess(false);
117
+ setWasRemoved(false);
71
118
  onClose();
72
119
  };
73
120
 
@@ -250,38 +297,74 @@ export function GitSettingsModal({
250
297
  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
251
298
  <polyline points="22 4 12 14.01 9 11.01" />
252
299
  </svg>
253
- Git configuration saved successfully!
300
+ {wasRemoved
301
+ ? 'Git configuration removed successfully!'
302
+ : 'Git configuration saved successfully!'}
254
303
  </div>
255
304
  )}
256
305
  </div>
257
306
 
258
- <div className="modal-actions">
259
- <button
260
- type="button"
261
- className="btn-secondary"
262
- onClick={handleClose}
263
- disabled={saving}
264
- >
265
- {success ? 'Close' : 'Cancel'}
266
- </button>
267
- {!success && (
307
+ <div
308
+ className="modal-actions"
309
+ style={{
310
+ display: 'flex',
311
+ justifyContent: 'space-between',
312
+ alignItems: 'center',
313
+ }}
314
+ >
315
+ {/* Left side: Remove button (only show if git is configured) */}
316
+ <div>
317
+ {currentConfig?.github_repo_path && !success && (
318
+ <button
319
+ type="button"
320
+ onClick={handleRemove}
321
+ disabled={saving || removing}
322
+ style={{
323
+ padding: '8px 16px',
324
+ fontSize: '13px',
325
+ fontWeight: 500,
326
+ border: '1px solid #fca5a5',
327
+ borderRadius: '6px',
328
+ backgroundColor: removing ? '#fee2e2' : '#ffffff',
329
+ color: removing ? '#991b1b' : '#dc2626',
330
+ cursor: saving || removing ? 'not-allowed' : 'pointer',
331
+ opacity: saving || removing ? 0.6 : 1,
332
+ }}
333
+ >
334
+ {removing ? 'Removing...' : 'Reset'}
335
+ </button>
336
+ )}
337
+ </div>
338
+
339
+ {/* Right side: Save/Cancel buttons */}
340
+ <div style={{ display: 'flex', gap: '8px' }}>
268
341
  <button
269
- type="submit"
270
- className="btn-primary"
271
- disabled={saving}
272
- style={
273
- saving
274
- ? {
275
- opacity: 0.7,
276
- cursor: 'wait',
277
- backgroundColor: '#9ca3af',
278
- }
279
- : {}
280
- }
342
+ type="button"
343
+ className="btn-secondary"
344
+ onClick={handleClose}
345
+ disabled={saving || removing}
281
346
  >
282
- {saving ? 'Saving...' : 'Save Settings'}
347
+ {success ? 'Close' : 'Cancel'}
283
348
  </button>
284
- )}
349
+ {!success && (
350
+ <button
351
+ type="submit"
352
+ className="btn-primary"
353
+ disabled={saving || removing}
354
+ style={
355
+ saving
356
+ ? {
357
+ opacity: 0.7,
358
+ cursor: 'wait',
359
+ backgroundColor: '#9ca3af',
360
+ }
361
+ : {}
362
+ }
363
+ >
364
+ {saving ? 'Saving...' : 'Save Settings'}
365
+ </button>
366
+ )}
367
+ </div>
285
368
  </div>
286
369
  </form>
287
370
  </div>
@@ -0,0 +1,301 @@
1
+ import * as React from 'react';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
+ import GitSettingsModal from '../GitSettingsModal';
4
+
5
+ describe('<GitSettingsModal />', () => {
6
+ const mockOnClose = jest.fn();
7
+ const mockOnSave = jest.fn();
8
+ const mockOnRemove = jest.fn();
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks();
12
+ // Mock window.confirm
13
+ global.confirm = jest.fn(() => true);
14
+ });
15
+
16
+ afterEach(() => {
17
+ jest.restoreAllMocks();
18
+ });
19
+
20
+ it('should render with existing config', () => {
21
+ const currentConfig = {
22
+ github_repo_path: 'test/repo',
23
+ git_branch: 'main',
24
+ git_path: 'nodes/',
25
+ git_only: false,
26
+ };
27
+
28
+ render(
29
+ <GitSettingsModal
30
+ isOpen={true}
31
+ onClose={mockOnClose}
32
+ onSave={mockOnSave}
33
+ onRemove={mockOnRemove}
34
+ currentConfig={currentConfig}
35
+ namespace="test.namespace"
36
+ />,
37
+ );
38
+
39
+ expect(screen.getByDisplayValue('test/repo')).toBeInTheDocument();
40
+ expect(screen.getByDisplayValue('main')).toBeInTheDocument();
41
+ expect(screen.getByDisplayValue('nodes/')).toBeInTheDocument();
42
+ });
43
+
44
+ it('should call onRemove when Reset button is clicked and confirmed', async () => {
45
+ const currentConfig = {
46
+ github_repo_path: 'test/repo',
47
+ git_branch: 'main',
48
+ git_path: 'nodes/',
49
+ git_only: false,
50
+ };
51
+
52
+ mockOnRemove.mockResolvedValue({ success: true });
53
+
54
+ render(
55
+ <GitSettingsModal
56
+ isOpen={true}
57
+ onClose={mockOnClose}
58
+ onSave={mockOnSave}
59
+ onRemove={mockOnRemove}
60
+ currentConfig={currentConfig}
61
+ namespace="test.namespace"
62
+ />,
63
+ );
64
+
65
+ const removeButton = screen.getByText('Reset');
66
+ fireEvent.click(removeButton);
67
+
68
+ // Verify confirmation dialog was shown
69
+ expect(global.confirm).toHaveBeenCalledWith(
70
+ expect.stringContaining('Remove git configuration?'),
71
+ );
72
+
73
+ await waitFor(() => {
74
+ expect(mockOnRemove).toHaveBeenCalledWith({
75
+ github_repo_path: null,
76
+ git_branch: null,
77
+ git_path: null,
78
+ git_only: false,
79
+ });
80
+ });
81
+ });
82
+
83
+ it('should not call onRemove when user cancels confirmation', async () => {
84
+ global.confirm = jest.fn(() => false);
85
+
86
+ const currentConfig = {
87
+ github_repo_path: 'test/repo',
88
+ git_branch: 'main',
89
+ git_path: 'nodes/',
90
+ git_only: false,
91
+ };
92
+
93
+ render(
94
+ <GitSettingsModal
95
+ isOpen={true}
96
+ onClose={mockOnClose}
97
+ onSave={mockOnSave}
98
+ onRemove={mockOnRemove}
99
+ currentConfig={currentConfig}
100
+ namespace="test.namespace"
101
+ />,
102
+ );
103
+
104
+ const removeButton = screen.getByText('Reset');
105
+ fireEvent.click(removeButton);
106
+
107
+ expect(global.confirm).toHaveBeenCalled();
108
+ expect(mockOnRemove).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('should show error message when remove fails', async () => {
112
+ const currentConfig = {
113
+ github_repo_path: 'test/repo',
114
+ git_branch: 'main',
115
+ git_path: 'nodes/',
116
+ git_only: false,
117
+ };
118
+
119
+ mockOnRemove.mockResolvedValue({
120
+ _error: true,
121
+ message: 'Failed to remove git configuration',
122
+ });
123
+
124
+ render(
125
+ <GitSettingsModal
126
+ isOpen={true}
127
+ onClose={mockOnClose}
128
+ onSave={mockOnSave}
129
+ onRemove={mockOnRemove}
130
+ currentConfig={currentConfig}
131
+ namespace="test.namespace"
132
+ />,
133
+ );
134
+
135
+ const removeButton = screen.getByText('Reset');
136
+ fireEvent.click(removeButton);
137
+
138
+ await waitFor(() => {
139
+ expect(
140
+ screen.getByText(/Failed to remove git configuration/),
141
+ ).toBeInTheDocument();
142
+ });
143
+
144
+ // Modal should stay open on error
145
+ expect(mockOnClose).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('should show success message and close modal after successful removal', async () => {
149
+ jest.useFakeTimers();
150
+
151
+ const currentConfig = {
152
+ github_repo_path: 'test/repo',
153
+ git_branch: 'main',
154
+ git_path: 'nodes/',
155
+ git_only: false,
156
+ };
157
+
158
+ mockOnRemove.mockResolvedValue({ success: true });
159
+
160
+ render(
161
+ <GitSettingsModal
162
+ isOpen={true}
163
+ onClose={mockOnClose}
164
+ onSave={mockOnSave}
165
+ onRemove={mockOnRemove}
166
+ currentConfig={currentConfig}
167
+ namespace="test.namespace"
168
+ />,
169
+ );
170
+
171
+ const removeButton = screen.getByText('Reset');
172
+ fireEvent.click(removeButton);
173
+
174
+ await waitFor(() => {
175
+ expect(mockOnRemove).toHaveBeenCalled();
176
+ });
177
+
178
+ // Success message should appear
179
+ await waitFor(() => {
180
+ expect(
181
+ screen.getByText(/Git configuration removed successfully/),
182
+ ).toBeInTheDocument();
183
+ });
184
+
185
+ // Fast-forward time to trigger modal close
186
+ jest.advanceTimersByTime(1500);
187
+
188
+ await waitFor(() => {
189
+ expect(mockOnClose).toHaveBeenCalled();
190
+ });
191
+
192
+ jest.useRealTimers();
193
+ });
194
+
195
+ it('should handle exception during remove', async () => {
196
+ const currentConfig = {
197
+ github_repo_path: 'test/repo',
198
+ git_branch: 'main',
199
+ git_path: 'nodes/',
200
+ git_only: false,
201
+ };
202
+
203
+ mockOnRemove.mockRejectedValue(new Error('Network error'));
204
+
205
+ render(
206
+ <GitSettingsModal
207
+ isOpen={true}
208
+ onClose={mockOnClose}
209
+ onSave={mockOnSave}
210
+ onRemove={mockOnRemove}
211
+ currentConfig={currentConfig}
212
+ namespace="test.namespace"
213
+ />,
214
+ );
215
+
216
+ const removeButton = screen.getByText('Reset');
217
+ fireEvent.click(removeButton);
218
+
219
+ await waitFor(() => {
220
+ expect(screen.getByText(/Network error/)).toBeInTheDocument();
221
+ });
222
+ });
223
+
224
+ it('should show removing state while operation is in progress', async () => {
225
+ const currentConfig = {
226
+ github_repo_path: 'test/repo',
227
+ git_branch: 'main',
228
+ git_path: 'nodes/',
229
+ git_only: false,
230
+ };
231
+
232
+ // Create a promise that we can control
233
+ let resolveRemove;
234
+ const removePromise = new Promise(resolve => {
235
+ resolveRemove = resolve;
236
+ });
237
+ mockOnRemove.mockReturnValue(removePromise);
238
+
239
+ render(
240
+ <GitSettingsModal
241
+ isOpen={true}
242
+ onClose={mockOnClose}
243
+ onSave={mockOnSave}
244
+ onRemove={mockOnRemove}
245
+ currentConfig={currentConfig}
246
+ namespace="test.namespace"
247
+ />,
248
+ );
249
+
250
+ const removeButton = screen.getByText('Reset');
251
+ fireEvent.click(removeButton);
252
+
253
+ // Button text should change to "Removing..." and be disabled
254
+ await waitFor(() => {
255
+ expect(screen.getByText('Removing...')).toBeInTheDocument();
256
+ expect(screen.getByText('Removing...')).toBeDisabled();
257
+ });
258
+
259
+ // Resolve the promise
260
+ resolveRemove({ success: true });
261
+
262
+ // Button should disappear after success (replaced by success message)
263
+ await waitFor(() => {
264
+ expect(screen.queryByText('Reset')).not.toBeInTheDocument();
265
+ expect(screen.queryByText('Removing...')).not.toBeInTheDocument();
266
+ });
267
+ });
268
+
269
+ it('should call onSave when Save Settings is clicked', async () => {
270
+ mockOnSave.mockResolvedValue({ success: true });
271
+
272
+ render(
273
+ <GitSettingsModal
274
+ isOpen={true}
275
+ onClose={mockOnClose}
276
+ onSave={mockOnSave}
277
+ onRemove={mockOnRemove}
278
+ currentConfig={null}
279
+ namespace="test.namespace"
280
+ />,
281
+ );
282
+
283
+ fireEvent.change(screen.getByLabelText('Repository'), {
284
+ target: { value: 'myorg/repo' },
285
+ });
286
+ fireEvent.change(screen.getByLabelText('Branch'), {
287
+ target: { value: 'main' },
288
+ });
289
+
290
+ fireEvent.click(screen.getByText('Save Settings'));
291
+
292
+ await waitFor(() => {
293
+ expect(mockOnSave).toHaveBeenCalledWith(
294
+ expect.objectContaining({
295
+ github_repo_path: 'myorg/repo',
296
+ git_branch: 'main',
297
+ }),
298
+ );
299
+ });
300
+ });
301
+ });
@@ -2275,6 +2275,26 @@ export const DataJunctionAPI = {
2275
2275
  return result;
2276
2276
  },
2277
2277
 
2278
+ // Delete git configuration for a namespace
2279
+ deleteNamespaceGitConfig: async function (namespace) {
2280
+ const response = await fetch(`${DJ_URL}/namespaces/${namespace}/git`, {
2281
+ method: 'DELETE',
2282
+ credentials: 'include',
2283
+ });
2284
+ // DELETE returns 204 No Content on success, so no JSON body
2285
+ if (!response.ok) {
2286
+ const result = await response.json().catch(() => ({}));
2287
+ return {
2288
+ ...result,
2289
+ _error: true,
2290
+ _status: response.status,
2291
+ message: result.message || 'Failed to delete git config',
2292
+ };
2293
+ }
2294
+ // Success - return empty object since 204 has no body
2295
+ return {};
2296
+ },
2297
+
2278
2298
  // List branch namespaces for a parent namespace
2279
2299
  listBranches: async function (namespace) {
2280
2300
  const response = await fetch(`${DJ_URL}/namespaces/${namespace}/branches`, {