datajunction-ui 0.0.27 → 0.0.29

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.
@@ -9,6 +9,7 @@ import AddNodeDropdown from '../../components/AddNodeDropdown';
9
9
  import NodeListActions from '../../components/NodeListActions';
10
10
  import LoadingIcon from '../../icons/LoadingIcon';
11
11
  import CompactSelect from './CompactSelect';
12
+ import { getDJUrl } from '../../services/DJService';
12
13
 
13
14
  import 'styles/node-list.css';
14
15
  import 'styles/sorted-table.css';
@@ -166,6 +167,8 @@ export function NamespacePage() {
166
167
  const [retrieved, setRetrieved] = useState(false);
167
168
 
168
169
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
170
+ const [namespaceSources, setNamespaceSources] = useState({});
171
+ const [currentNamespaceSources, setCurrentNamespaceSources] = useState(null);
169
172
 
170
173
  const [sortConfig, setSortConfig] = useState({
171
174
  key: 'updatedAt',
@@ -230,10 +233,32 @@ export function NamespacePage() {
230
233
  const namespaces = await djClient.namespaces();
231
234
  const hierarchy = createNamespaceHierarchy(namespaces);
232
235
  setNamespaceHierarchy(hierarchy);
236
+
237
+ // Fetch sources for all namespaces in bulk
238
+ const allNamespaceNames = namespaces.map(ns => ns.namespace);
239
+ if (allNamespaceNames.length > 0) {
240
+ const sourcesResponse = await djClient.namespaceSourcesBulk(
241
+ allNamespaceNames,
242
+ );
243
+ if (sourcesResponse && sourcesResponse.sources) {
244
+ setNamespaceSources(sourcesResponse.sources);
245
+ }
246
+ }
233
247
  };
234
248
  fetchData().catch(console.error);
235
249
  }, [djClient, djClient.namespaces]);
236
250
 
251
+ // Fetch sources for the current namespace (for the header badge)
252
+ useEffect(() => {
253
+ const fetchCurrentSources = async () => {
254
+ if (namespace) {
255
+ const sources = await djClient.namespaceSources(namespace);
256
+ setCurrentNamespaceSources(sources);
257
+ }
258
+ };
259
+ fetchCurrentSources().catch(console.error);
260
+ }, [djClient, namespace]);
261
+
237
262
  useEffect(() => {
238
263
  const fetchData = async () => {
239
264
  setRetrieved(false);
@@ -458,7 +483,6 @@ export function NamespacePage() {
458
483
  }}
459
484
  >
460
485
  <h2 style={{ margin: 0 }}>Explore</h2>
461
- <AddNodeDropdown namespace={namespace} />
462
486
  </div>
463
487
 
464
488
  {/* Unified Filter Bar */}
@@ -466,7 +490,7 @@ export function NamespacePage() {
466
490
  style={{
467
491
  marginBottom: '1rem',
468
492
  padding: '1rem',
469
- backgroundColor: '#f8f9fa',
493
+ backgroundColor: '#f8fafc',
470
494
  borderRadius: '8px',
471
495
  }}
472
496
  >
@@ -482,7 +506,17 @@ export function NamespacePage() {
482
506
  <div
483
507
  style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
484
508
  >
485
- <span style={{ fontSize: '12px', color: '#555' }}>Quick:</span>
509
+ <span
510
+ style={{
511
+ fontSize: '11px',
512
+ fontWeight: '600',
513
+ textTransform: 'uppercase',
514
+ letterSpacing: '0.5px',
515
+ color: '#64748b',
516
+ }}
517
+ >
518
+ Quick
519
+ </span>
486
520
  {presets.map(preset => (
487
521
  <button
488
522
  key={preset.id}
@@ -745,18 +779,23 @@ export function NamespacePage() {
745
779
  </div>
746
780
 
747
781
  <div className="table-responsive">
748
- <div className={`sidebar`}>
782
+ <div
783
+ className={`sidebar`}
784
+ style={{ borderRight: '1px solid #e2e8f0', paddingRight: '1rem' }}
785
+ >
749
786
  <div
750
787
  style={{
751
- padding: '1rem 1rem 1rem 0',
788
+ paddingBottom: '12px',
789
+ marginBottom: '8px',
752
790
  }}
753
791
  >
754
792
  <span
755
793
  style={{
756
- textTransform: 'uppercase',
757
- fontSize: '0.8125rem',
794
+ fontSize: '11px',
758
795
  fontWeight: '600',
759
- color: '#95aac9',
796
+ textTransform: 'uppercase',
797
+ letterSpacing: '0.5px',
798
+ color: '#64748b',
760
799
  }}
761
800
  >
762
801
  Namespaces
@@ -770,54 +809,284 @@ export function NamespacePage() {
770
809
  defaultExpand={true}
771
810
  isTopLevel={true}
772
811
  key={child.namespace}
812
+ namespaceSources={namespaceSources}
773
813
  />
774
814
  ))
775
815
  : null}
776
816
  </div>
777
- <table className="card-table table">
778
- <thead>
779
- <tr>
780
- {fields.map(field => {
781
- return (
782
- <th key={field}>
783
- <button
784
- type="button"
785
- onClick={() => requestSort(field)}
786
- className={'sortable ' + getClassNamesFor(field)}
817
+ <div style={{ flex: 1, minWidth: 0, marginLeft: '1.5rem' }}>
818
+ {/* Namespace Header */}
819
+ <div
820
+ style={{
821
+ display: 'flex',
822
+ justifyContent: 'space-between',
823
+ alignItems: 'center',
824
+ paddingBottom: '12px',
825
+ marginBottom: '16px',
826
+ borderBottom: '1px solid #e2e8f0',
827
+ }}
828
+ >
829
+ <div
830
+ style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
831
+ >
832
+ <a href="/" style={{ display: 'flex', alignItems: 'center' }}>
833
+ <svg
834
+ xmlns="http://www.w3.org/2000/svg"
835
+ width="16"
836
+ height="16"
837
+ fill="currentColor"
838
+ viewBox="0 0 16 16"
839
+ >
840
+ <path d="M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z" />
841
+ </svg>
842
+ </a>
843
+ <span style={{ color: '#6c757d' }}>/</span>
844
+ {namespace ? (
845
+ namespace.split('.').map((part, index, arr) => (
846
+ <span
847
+ key={index}
848
+ style={{
849
+ display: 'flex',
850
+ alignItems: 'center',
851
+ gap: '8px',
852
+ }}
853
+ >
854
+ <a
855
+ href={`/namespaces/${arr
856
+ .slice(0, index + 1)
857
+ .join('.')}`}
858
+ style={{
859
+ fontWeight: '400',
860
+ color: '#1e293b',
861
+ textDecoration: 'none',
862
+ }}
787
863
  >
788
- {field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
789
- </button>
790
- </th>
791
- );
792
- })}
793
- <th>Actions</th>
794
- </tr>
795
- </thead>
796
- <tbody>{nodesList}</tbody>
797
- <tfoot>
798
- <tr>
799
- <td>
800
- {retrieved && hasPrevPage ? (
864
+ {part}
865
+ </a>
866
+ {index < arr.length - 1 && (
867
+ <span style={{ color: '#94a3b8', fontWeight: '400' }}>
868
+ /
869
+ </span>
870
+ )}
871
+ </span>
872
+ ))
873
+ ) : (
874
+ <span style={{ fontWeight: '600', color: '#1e293b' }}>
875
+ All Namespaces
876
+ </span>
877
+ )}
878
+ {currentNamespaceSources &&
879
+ currentNamespaceSources.total_deployments > 0 &&
880
+ (currentNamespaceSources.primary_source?.type === 'git' ? (
801
881
  <a
802
- onClick={loadPrev}
803
- className="previous round pagination"
882
+ href={`https://${currentNamespaceSources.primary_source.repository}`}
883
+ target="_blank"
884
+ rel="noopener noreferrer"
885
+ title={`${
886
+ currentNamespaceSources.primary_source.repository
887
+ }${
888
+ currentNamespaceSources.primary_source.branch
889
+ ? ` (${currentNamespaceSources.primary_source.branch})`
890
+ : ''
891
+ }`}
892
+ style={{
893
+ display: 'inline-flex',
894
+ alignItems: 'center',
895
+ gap: '4px',
896
+ padding: '4px 8px',
897
+ marginLeft: '8px',
898
+ fontSize: '13px',
899
+ borderRadius: '12px',
900
+ backgroundColor: '#fff4de',
901
+ border: '1px solid #d4edda',
902
+ color: '#155724',
903
+ textDecoration: 'none',
904
+ cursor: 'pointer',
905
+ }}
804
906
  >
805
- ← Previous
806
- </a>
807
- ) : (
808
- ''
809
- )}
810
- {retrieved && hasNextPage ? (
811
- <a onClick={loadNext} className="next round pagination">
812
- Next →
907
+ <svg
908
+ xmlns="http://www.w3.org/2000/svg"
909
+ width="12"
910
+ height="12"
911
+ viewBox="0 0 24 24"
912
+ fill="none"
913
+ stroke="currentColor"
914
+ strokeWidth="2"
915
+ strokeLinecap="round"
916
+ strokeLinejoin="round"
917
+ style={{ marginRight: '2px' }}
918
+ >
919
+ <line x1="6" y1="3" x2="6" y2="15"></line>
920
+ <circle cx="18" cy="6" r="3"></circle>
921
+ <circle cx="6" cy="18" r="3"></circle>
922
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
923
+ </svg>
924
+ Git Managed
813
925
  </a>
814
926
  ) : (
815
- ''
816
- )}
817
- </td>
818
- </tr>
819
- </tfoot>
820
- </table>
927
+ <span
928
+ title={
929
+ currentNamespaceSources.has_multiple_sources
930
+ ? `Warning: ${currentNamespaceSources.sources.length} deployment sources`
931
+ : currentNamespaceSources.sources?.[0]
932
+ ?.last_deployed_by
933
+ ? `Last deployed by ${currentNamespaceSources.sources[0].last_deployed_by}`
934
+ : 'Local/adhoc deployment'
935
+ }
936
+ style={{
937
+ display: 'inline-flex',
938
+ alignItems: 'center',
939
+ gap: '4px',
940
+ padding: '2px 8px',
941
+ marginLeft: '8px',
942
+ fontSize: '11px',
943
+ borderRadius: '12px',
944
+ backgroundColor:
945
+ currentNamespaceSources.has_multiple_sources
946
+ ? '#fff3cd'
947
+ : '#e2e3e5',
948
+ color: currentNamespaceSources.has_multiple_sources
949
+ ? '#856404'
950
+ : '#383d41',
951
+ cursor: 'help',
952
+ }}
953
+ >
954
+ {currentNamespaceSources.has_multiple_sources
955
+ ? `⚠️ ${currentNamespaceSources.sources.length} sources`
956
+ : currentNamespaceSources.sources?.[0]
957
+ ?.last_deployed_by
958
+ ? `Local deploy by ${currentNamespaceSources.sources[0].last_deployed_by}`
959
+ : 'Local'}
960
+ </span>
961
+ ))}
962
+ </div>
963
+ <div
964
+ style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
965
+ >
966
+ <a
967
+ href={`${getDJUrl()}/namespaces/${namespace}/export/yaml`}
968
+ download
969
+ style={{
970
+ display: 'inline-flex',
971
+ alignItems: 'center',
972
+ gap: '4px',
973
+ // padding: '6px 12px',
974
+ fontSize: '13px',
975
+ fontWeight: '500',
976
+ color: '#475569',
977
+ // backgroundColor: '#f8fafc',
978
+ // border: '1px solid #e2e8f0',
979
+ borderRadius: '6px',
980
+ textDecoration: 'none',
981
+ cursor: 'pointer',
982
+ transition: 'all 0.15s ease',
983
+ margin: '0.5em 0px 0px 1em',
984
+ }}
985
+ onMouseOver={e => {
986
+ e.currentTarget.style.color = '#333333';
987
+ }}
988
+ onMouseOut={e => {
989
+ e.currentTarget.style.color = '#475569';
990
+ }}
991
+ title="Export namespace to YAML"
992
+ >
993
+ <svg
994
+ width="14"
995
+ height="14"
996
+ viewBox="0 0 24 24"
997
+ fill="none"
998
+ stroke="currentColor"
999
+ strokeWidth="2"
1000
+ strokeLinecap="round"
1001
+ strokeLinejoin="round"
1002
+ >
1003
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
1004
+ <polyline points="7 10 12 15 17 10"></polyline>
1005
+ <line x1="12" y1="15" x2="12" y2="3"></line>
1006
+ </svg>
1007
+ </a>
1008
+ <AddNodeDropdown namespace={namespace} />
1009
+ </div>
1010
+ </div>
1011
+ <table className="card-table table" style={{ marginBottom: 0 }}>
1012
+ <thead>
1013
+ <tr>
1014
+ {fields.map(field => {
1015
+ const thStyle = {
1016
+ fontFamily:
1017
+ "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
1018
+ fontSize: '11px',
1019
+ fontWeight: '600',
1020
+ textTransform: 'uppercase',
1021
+ letterSpacing: '0.5px',
1022
+ color: '#64748b',
1023
+ padding: '12px 16px',
1024
+ borderBottom: '1px solid #e2e8f0',
1025
+ backgroundColor: 'transparent',
1026
+ };
1027
+ return (
1028
+ <th key={field} style={thStyle}>
1029
+ <button
1030
+ type="button"
1031
+ onClick={() => requestSort(field)}
1032
+ className={'sortable ' + getClassNamesFor(field)}
1033
+ style={{
1034
+ fontSize: 'inherit',
1035
+ fontWeight: 'inherit',
1036
+ letterSpacing: 'inherit',
1037
+ textTransform: 'inherit',
1038
+ fontFamily: 'inherit',
1039
+ }}
1040
+ >
1041
+ {field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
1042
+ </button>
1043
+ </th>
1044
+ );
1045
+ })}
1046
+ <th
1047
+ style={{
1048
+ fontFamily:
1049
+ "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
1050
+ fontSize: '11px',
1051
+ fontWeight: '600',
1052
+ textTransform: 'uppercase',
1053
+ letterSpacing: '0.5px',
1054
+ color: '#64748b',
1055
+ padding: '12px 16px',
1056
+ borderBottom: '1px solid #e2e8f0',
1057
+ backgroundColor: 'transparent',
1058
+ }}
1059
+ >
1060
+ Actions
1061
+ </th>
1062
+ </tr>
1063
+ </thead>
1064
+ <tbody className="nodes-table-body">{nodesList}</tbody>
1065
+ <tfoot>
1066
+ <tr>
1067
+ <td>
1068
+ {retrieved && hasPrevPage ? (
1069
+ <a
1070
+ onClick={loadPrev}
1071
+ className="previous round pagination"
1072
+ >
1073
+ ← Previous
1074
+ </a>
1075
+ ) : (
1076
+ ''
1077
+ )}
1078
+ {retrieved && hasNextPage ? (
1079
+ <a onClick={loadNext} className="next round pagination">
1080
+ Next →
1081
+ </a>
1082
+ ) : (
1083
+ ''
1084
+ )}
1085
+ </td>
1086
+ </tr>
1087
+ </tfoot>
1088
+ </table>
1089
+ </div>
821
1090
  </div>
822
1091
  </div>
823
1092
  </div>
@@ -284,4 +284,32 @@ describe('<NotificationsPage />', () => {
284
284
 
285
285
  consoleSpy.mockRestore();
286
286
  });
287
+
288
+ it('handles null response from getSubscribedHistory', async () => {
289
+ const mockDjClient = createMockDjClient({
290
+ getSubscribedHistory: jest.fn().mockResolvedValue(null),
291
+ });
292
+ renderWithContext(mockDjClient);
293
+
294
+ await waitFor(() => {
295
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
296
+ });
297
+
298
+ // Should show empty state when API returns null
299
+ expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
300
+ });
301
+
302
+ it('handles undefined response from getSubscribedHistory', async () => {
303
+ const mockDjClient = createMockDjClient({
304
+ getSubscribedHistory: jest.fn().mockResolvedValue(undefined),
305
+ });
306
+ renderWithContext(mockDjClient);
307
+
308
+ await waitFor(() => {
309
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
310
+ });
311
+
312
+ // Should show empty state when API returns undefined
313
+ expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
314
+ });
287
315
  });
@@ -2517,26 +2517,6 @@ export function PreAggDetailsPanel({
2517
2517
  </div>
2518
2518
  </div>
2519
2519
 
2520
- {/* Metrics Using This */}
2521
- <div className="details-section">
2522
- <h3 className="section-title">
2523
- <span className="section-icon">◈</span>
2524
- Metrics Using This
2525
- </h3>
2526
- <div className="metrics-list">
2527
- {relatedMetrics.length > 0 ? (
2528
- relatedMetrics.map((m, i) => (
2529
- <div key={i} className="related-metric">
2530
- <span className="metric-name">{m.short_name}</span>
2531
- {m.is_derived && <span className="derived-badge">Derived</span>}
2532
- </div>
2533
- ))
2534
- ) : (
2535
- <span className="empty-text">No metrics found</span>
2536
- )}
2537
- </div>
2538
- </div>
2539
-
2540
2520
  {/* Components Table */}
2541
2521
  <div
2542
2522
  className="details-section details-section-full"
@@ -2585,6 +2565,26 @@ export function PreAggDetailsPanel({
2585
2565
  </div>
2586
2566
  </div>
2587
2567
 
2568
+ {/* Metrics Using This */}
2569
+ <div className="details-section">
2570
+ <h3 className="section-title">
2571
+ <span className="section-icon">◈</span>
2572
+ Metrics Using This
2573
+ </h3>
2574
+ <div className="metrics-list">
2575
+ {relatedMetrics.length > 0 ? (
2576
+ relatedMetrics.map((m, i) => (
2577
+ <div key={i} className="related-metric">
2578
+ <span className="metric-name">{m.short_name}</span>
2579
+ {m.is_derived && <span className="derived-badge">Derived</span>}
2580
+ </div>
2581
+ ))
2582
+ ) : (
2583
+ <span className="empty-text">No metrics found</span>
2584
+ )}
2585
+ </div>
2586
+ </div>
2587
+
2588
2588
  {/* SQL Section */}
2589
2589
  {preAgg.sql && (
2590
2590
  <div className="details-section details-section-full details-sql-section">
@@ -1026,7 +1026,7 @@ export function QueryPlannerPage() {
1026
1026
  selectedMetrics,
1027
1027
  selectedDimensions,
1028
1028
  '',
1029
- false, // use_materialized = false for raw SQL
1029
+ false, // useMaterialized = false for raw SQL
1030
1030
  );
1031
1031
  return result.sql;
1032
1032
  } catch (err) {
@@ -70,7 +70,7 @@ describe('<Root />', () => {
70
70
  expect(navRight).toBeInTheDocument();
71
71
  });
72
72
 
73
- it('handles notification dropdown toggle', async () => {
73
+ it('handles notification dropdown toggle and closes user menu', async () => {
74
74
  renderRoot();
75
75
 
76
76
  await waitFor(() => {
@@ -80,16 +80,21 @@ describe('<Root />', () => {
80
80
  // Find the notification bell button and click it
81
81
  const bellButton = document.querySelector('[aria-label="Notifications"]');
82
82
  if (bellButton) {
83
+ // First click opens notifications
84
+ fireEvent.click(bellButton);
85
+ await waitFor(() => {
86
+ expect(bellButton).toBeInTheDocument();
87
+ });
88
+
89
+ // Click again to close
83
90
  fireEvent.click(bellButton);
84
- // The dropdown should open
85
91
  await waitFor(() => {
86
- // After clicking, the dropdown state changes
87
92
  expect(bellButton).toBeInTheDocument();
88
93
  });
89
94
  }
90
95
  });
91
96
 
92
- it('handles user menu dropdown toggle', async () => {
97
+ it('handles user menu dropdown toggle and closes notification dropdown', async () => {
93
98
  renderRoot();
94
99
 
95
100
  await waitFor(() => {
@@ -99,6 +104,17 @@ describe('<Root />', () => {
99
104
  // The nav-right container should be present for auth-enabled mode
100
105
  const navRight = document.querySelector('.nav-right');
101
106
  expect(navRight).toBeInTheDocument();
107
+
108
+ // Find the user menu button (look for avatar or user icon)
109
+ const userMenuBtn = document.querySelector(
110
+ '.user-menu-button, .user-avatar, [aria-label*="user"], [aria-label*="menu"]',
111
+ );
112
+ if (userMenuBtn) {
113
+ fireEvent.click(userMenuBtn);
114
+ await waitFor(() => {
115
+ expect(userMenuBtn).toBeInTheDocument();
116
+ });
117
+ }
102
118
  });
103
119
 
104
120
  it('renders logo link correctly', async () => {
@@ -112,4 +128,83 @@ describe('<Root />', () => {
112
128
  const logoLink = screen.getByRole('link', { name: /data.*junction/i });
113
129
  expect(logoLink).toHaveAttribute('href', '/');
114
130
  });
131
+
132
+ it('toggles between notification and user dropdowns exclusively', async () => {
133
+ renderRoot();
134
+
135
+ await waitFor(() => {
136
+ expect(document.title).toEqual('DataJunction');
137
+ });
138
+
139
+ const navRight = document.querySelector('.nav-right');
140
+ expect(navRight).toBeInTheDocument();
141
+
142
+ // Find both dropdown triggers
143
+ const bellButton = document.querySelector('[aria-label="Notifications"]');
144
+ const userMenuTrigger = navRight?.querySelector('button, [role="button"]');
145
+
146
+ if (bellButton && userMenuTrigger && bellButton !== userMenuTrigger) {
147
+ // Click notification first
148
+ fireEvent.click(bellButton);
149
+ await waitFor(() => {
150
+ expect(bellButton).toBeInTheDocument();
151
+ });
152
+
153
+ // Now click user menu - should close notifications
154
+ fireEvent.click(userMenuTrigger);
155
+ await waitFor(() => {
156
+ expect(userMenuTrigger).toBeInTheDocument();
157
+ });
158
+
159
+ // Click notifications again - should close user menu
160
+ fireEvent.click(bellButton);
161
+ await waitFor(() => {
162
+ expect(bellButton).toBeInTheDocument();
163
+ });
164
+ }
165
+ });
166
+
167
+ it('sets openDropdown state correctly for notification toggle', async () => {
168
+ renderRoot();
169
+
170
+ await waitFor(() => {
171
+ expect(document.title).toEqual('DataJunction');
172
+ });
173
+
174
+ const bellButton = document.querySelector('[aria-label="Notifications"]');
175
+ if (bellButton) {
176
+ // Open notifications dropdown
177
+ fireEvent.click(bellButton);
178
+
179
+ // The dropdown toggle handler should have been called with isOpen=true
180
+ // which sets openDropdown to 'notifications'
181
+ await waitFor(() => {
182
+ expect(bellButton).toBeInTheDocument();
183
+ });
184
+ }
185
+ });
186
+
187
+ it('sets openDropdown state correctly for user menu toggle', async () => {
188
+ renderRoot();
189
+
190
+ await waitFor(() => {
191
+ expect(document.title).toEqual('DataJunction');
192
+ });
193
+
194
+ const navRight = document.querySelector('.nav-right');
195
+ if (navRight) {
196
+ // Find user menu element (usually second clickable element in nav-right)
197
+ const buttons = navRight.querySelectorAll('button, [role="button"]');
198
+ if (buttons.length > 0) {
199
+ const userButton = buttons[buttons.length - 1]; // Last button is usually user menu
200
+ fireEvent.click(userButton);
201
+
202
+ // The dropdown toggle handler should have been called with isOpen=true
203
+ // which sets openDropdown to 'user'
204
+ await waitFor(() => {
205
+ expect(userButton).toBeInTheDocument();
206
+ });
207
+ }
208
+ }
209
+ });
115
210
  });