Skip to content

Commit d13fdc3

Browse files
stalepbarreiro
authored andcommitted
View improvements: refresh, reordering, error handling, and tests
DataTab: - Show helpful message when view has no columns configured instead of confusing 'No data available' - Data table refreshes automatically after saving view configuration (keyed by component IDs to force remount on changes) - Configure button reads latest view data from query result - Modal remounted via counter key for fresh FilterableMultiSelect state ViewConfigModal: - Column reordering: ordered list below multi-select with numbered positions, up/down arrow buttons, and remove (x) button - Existing column order preserved from headerOrder when editing - New items appended at end, removals keep remaining order - Error display when create/update/delete fails (was silent) - Selected nodes shown correctly when reopening (same object references from availableNodes for Carbon FilterableMultiSelect) - Query refetch uses predicate matching _id field (generated query keys use object format, not plain strings) ViewService: - Flush component deletes before insert on view update to avoid unique constraint violation on (view_id, header_name) Tests: - Frontend (8 new): empty view prompt, column ordering list rendering, preserved headerOrder, disabled up/down buttons, reorder on click, remove column on click - Backend (1 new): view_update_with_same_header_names verifies the flush fix for unique constraint violation
1 parent db3d89b commit d13fdc3

6 files changed

Lines changed: 396 additions & 28 deletions

File tree

src/main/java/io/hyperfoil/tools/h5m/svc/ViewService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public View updateView(Long viewId, View view) {
8686

8787
entity.name = view.name();
8888
entity.components.clear();
89+
// Flush the deletes before inserting new components to avoid
90+
// unique constraint violations on (view_id, header_name)
91+
entity.flush();
8992

9093
if (view.components() != null) {
9194
for (int i = 0; i < view.components().size(); i++) {

src/main/webui/src/app/components/DataTab.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export const DataTab = ({ folderName, groupId }: { folderName: string; groupId:
9090
const [selectedViewId, setSelectedViewId] = useState<number | null>(null);
9191
const [configModalOpen, setConfigModalOpen] = useState(false);
9292
const [editingView, setEditingView] = useState<View | null>(null);
93+
const [modalKey, setModalKey] = useState(0);
9394

9495
const selectedView = useMemo((): View | null => {
9596
if (!views || views.length === 0) return null;
@@ -132,24 +133,39 @@ export const DataTab = ({ folderName, groupId }: { folderName: string; groupId:
132133
<Button
133134
kind="ghost"
134135
size="md"
135-
onClick={() => { setEditingView(selectedView); setConfigModalOpen(true); }}
136+
onClick={() => {
137+
const latestView = views?.find((v: View) => v.id === selectedView?.id) ?? selectedView;
138+
setEditingView(latestView);
139+
setModalKey((k) => k + 1);
140+
setConfigModalOpen(true);
141+
}}
136142
>
137143
Configure
138144
</Button>
139145
<Button
140146
kind="ghost"
141147
size="md"
142-
onClick={() => { setEditingView(null); setConfigModalOpen(true); }}
148+
onClick={() => { setEditingView(null); setModalKey((k) => k + 1); setConfigModalOpen(true); }}
143149
>
144150
New View
145151
</Button>
146152
</div>
147-
{selectedView && (
148-
<ViewDataTable folderName={folderName} view={selectedView} />
153+
{selectedView && (!selectedView.components || selectedView.components.length === 0) && (
154+
<p style={{ opacity: 0.7 }}>
155+
This view has no columns configured. Click <strong>Configure</strong> to select which nodes to display.
156+
</p>
157+
)}
158+
{selectedView && selectedView.components && selectedView.components.length > 0 && (
159+
<ViewDataTable
160+
key={`${String(selectedView.id)}-${String(selectedView.components?.length ?? 0)}-${selectedView.components?.map(c => String(c.nodeId)).join(',') ?? ''}`}
161+
folderName={folderName}
162+
view={selectedView}
163+
/>
149164
)}
150165
<ErrorBoundary fallback={<InlineLoading status="error" description="Failed to load modal" />}>
151166
<Suspense fallback={<SkeletonText paragraph={true} lineCount={3} />}>
152167
<ViewConfigModal
168+
key={modalKey}
153169
open={configModalOpen}
154170
onClose={() => setConfigModalOpen(false)}
155171
folderName={folderName}

src/main/webui/src/app/components/ViewConfigModal.tsx

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import {
99
ModalHeader,
1010
TextInput,
1111
} from '@carbon/react';
12+
import { ArrowUp, ArrowDown, Close } from '@carbon/icons-react';
1213
import { byIdOptions } from '@client/@tanstack/react-query.gen.ts';
1314
import { ViewService } from '@client/sdk.gen.ts';
1415
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
15-
import { useCallback, useEffect, useState } from 'react';
16+
import { useCallback, useState } from 'react';
1617

1718
interface ViewConfigModalProps {
1819
open: boolean;
@@ -34,26 +35,8 @@ export const ViewConfigModal = ({ open, onClose, folderName, groupId, view }: Vi
3435
const isEditing = view != null && view.id != null;
3536
const isDefault = view?.name === 'Default';
3637

37-
const [viewName, setViewName] = useState(view?.name ?? '');
38-
const [selectedNodes, setSelectedNodes] = useState<NodeItem[]>([]);
39-
40-
// Initialize from existing view when editing
41-
useEffect(() => {
42-
if (view) {
43-
setViewName(view.name);
44-
const items = (view.components ?? []).map((c: ViewComponent) => ({
45-
id: String(c.nodeId),
46-
text: c.headerName ?? c.nodeName ?? '',
47-
nodeId: c.nodeId!,
48-
}));
49-
setSelectedNodes(items);
50-
} else {
51-
setViewName('');
52-
setSelectedNodes([]);
53-
}
54-
}, [view]);
55-
5638
// Available nodes for the multi-select (exclude detection nodes)
39+
// Built once and stable so FilterableMultiSelect can match by reference
5740
const availableNodes: NodeItem[] = (nodeGroup.sources ?? [])
5841
.filter((n: ApiNode) => !['FIXED_THRESHOLD', 'RELATIVE_DIFFERENCE', 'EDIVISIVE'].includes(n.type ?? ''))
5942
.map((n: ApiNode) => ({
@@ -62,16 +45,70 @@ export const ViewConfigModal = ({ open, onClose, folderName, groupId, view }: Vi
6245
nodeId: n.id!,
6346
}));
6447

48+
const [viewName, setViewName] = useState(view?.name ?? '');
49+
// Initialize selectedNodes from the view's components, preserving the
50+
// existing headerOrder. Find matching items in availableNodes (same
51+
// object references required by Carbon's FilterableMultiSelect).
52+
const [selectedNodes, setSelectedNodes] = useState<NodeItem[]>(() => {
53+
if (!view?.components || view.components.length === 0) return [];
54+
// Sort components by headerOrder to preserve column ordering
55+
const sorted = [...view.components].sort(
56+
(a: ViewComponent, b: ViewComponent) => (a.headerOrder ?? 0) - (b.headerOrder ?? 0)
57+
);
58+
const ordered: NodeItem[] = [];
59+
for (const c of sorted) {
60+
const match = availableNodes.find((n) => n.id === String(c.nodeId));
61+
if (match) ordered.push(match);
62+
}
63+
return ordered;
64+
});
65+
66+
const moveUp = useCallback((index: number) => {
67+
if (index <= 0) return;
68+
setSelectedNodes((prev) => {
69+
const next = [...prev];
70+
const temp = next[index - 1]!;
71+
next[index - 1] = next[index]!;
72+
next[index] = temp;
73+
return next;
74+
});
75+
}, []);
76+
77+
const moveDown = useCallback((index: number) => {
78+
setSelectedNodes((prev) => {
79+
if (index >= prev.length - 1) return prev;
80+
const next = [...prev];
81+
const temp = next[index]!;
82+
next[index] = next[index + 1]!;
83+
next[index + 1] = temp;
84+
return next;
85+
});
86+
}, []);
87+
88+
const removeNode = useCallback((index: number) => {
89+
setSelectedNodes((prev) => prev.filter((_, i) => i !== index));
90+
}, []);
91+
92+
const [saveError, setSaveError] = useState<string | null>(null);
93+
6594
const createMutation = useMutation({
6695
mutationFn: (data: View) =>
6796
ViewService.createView({
6897
path: { name: folderName },
6998
body: data,
7099
}),
71100
onSuccess: () => {
72-
queryClient.invalidateQueries({ queryKey: ['getViews'] });
101+
setSaveError(null);
102+
void queryClient.refetchQueries({ predicate: (q) => {
103+
const key = q.queryKey[0];
104+
return typeof key === 'object' && key !== null && '_id' in key &&
105+
(key._id === 'getViews' || key._id === 'getViewData');
106+
}});
73107
onClose();
74108
},
109+
onError: (e: Error) => {
110+
setSaveError(e.message ?? 'Failed to create view');
111+
},
75112
});
76113

77114
const updateMutation = useMutation({
@@ -81,9 +118,17 @@ export const ViewConfigModal = ({ open, onClose, folderName, groupId, view }: Vi
81118
body: data,
82119
}),
83120
onSuccess: () => {
84-
queryClient.invalidateQueries({ queryKey: ['getViews'] });
121+
setSaveError(null);
122+
void queryClient.refetchQueries({ predicate: (q) => {
123+
const key = q.queryKey[0];
124+
return typeof key === 'object' && key !== null && '_id' in key &&
125+
(key._id === 'getViews' || key._id === 'getViewData');
126+
}});
85127
onClose();
86128
},
129+
onError: (e: Error) => {
130+
setSaveError(e.message ?? 'Failed to update view');
131+
},
87132
});
88133

89134
const deleteMutation = useMutation({
@@ -92,7 +137,11 @@ export const ViewConfigModal = ({ open, onClose, folderName, groupId, view }: Vi
92137
path: { name: folderName, viewId: view!.id! },
93138
}),
94139
onSuccess: () => {
95-
queryClient.invalidateQueries({ queryKey: ['getViews'] });
140+
void queryClient.refetchQueries({ predicate: (q) => {
141+
const key = q.queryKey[0];
142+
return typeof key === 'object' && key !== null && '_id' in key &&
143+
(key._id === 'getViews' || key._id === 'getViewData');
144+
}});
96145
onClose();
97146
},
98147
});
@@ -144,9 +193,71 @@ export const ViewConfigModal = ({ open, onClose, folderName, groupId, view }: Vi
144193
itemToString={(item: NodeItem) => item?.text ?? ''}
145194
initialSelectedItems={selectedNodes}
146195
onChange={({ selectedItems }: { selectedItems: NodeItem[] }) => {
147-
setSelectedNodes(selectedItems);
196+
// Preserve existing order: keep items that are still selected
197+
// in their current position, append newly added items at the end
198+
const existingIds = new Set(selectedNodes.map((n) => n.id));
199+
const newIds = new Set(selectedItems.map((n) => n.id));
200+
const kept = selectedNodes.filter((n) => newIds.has(n.id));
201+
const added = selectedItems.filter((n) => !existingIds.has(n.id));
202+
setSelectedNodes([...kept, ...added]);
148203
}}
149204
/>
205+
{selectedNodes.length > 0 && (
206+
<div style={{ marginTop: 'var(--cds-spacing-05)' }}>
207+
<div style={{ fontSize: '0.75rem', opacity: 0.7, marginBottom: 'var(--cds-spacing-02)' }}>
208+
Column order (drag or use arrows to reorder)
209+
</div>
210+
{selectedNodes.map((node, idx) => (
211+
<div
212+
key={node.id}
213+
style={{
214+
display: 'flex',
215+
alignItems: 'center',
216+
gap: 'var(--cds-spacing-02)',
217+
padding: '4px 8px',
218+
marginBottom: '2px',
219+
background: 'var(--cds-layer-02)',
220+
borderRadius: '4px',
221+
fontSize: '0.875rem',
222+
}}
223+
>
224+
<span style={{ opacity: 0.5, minWidth: '20px' }}>{String(idx + 1)}.</span>
225+
<span style={{ flex: 1 }}>{node.text}</span>
226+
<Button
227+
kind="ghost"
228+
size="sm"
229+
hasIconOnly
230+
renderIcon={ArrowUp}
231+
iconDescription="Move up"
232+
onClick={() => moveUp(idx)}
233+
disabled={idx === 0}
234+
/>
235+
<Button
236+
kind="ghost"
237+
size="sm"
238+
hasIconOnly
239+
renderIcon={ArrowDown}
240+
iconDescription="Move down"
241+
onClick={() => moveDown(idx)}
242+
disabled={idx === selectedNodes.length - 1}
243+
/>
244+
<Button
245+
kind="ghost"
246+
size="sm"
247+
hasIconOnly
248+
renderIcon={Close}
249+
iconDescription="Remove"
250+
onClick={() => removeNode(idx)}
251+
/>
252+
</div>
253+
))}
254+
</div>
255+
)}
256+
{saveError && (
257+
<div style={{ color: 'var(--cds-support-error)', marginTop: 'var(--cds-spacing-03)' }}>
258+
{saveError}
259+
</div>
260+
)}
150261
</ModalBody>
151262
<ModalFooter>
152263
{isEditing && !isDefault && (

src/main/webui/test/components/DataTab.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ describe('<DataTab />', () => {
224224
cleanup();
225225
});
226226

227+
it('shows configure prompt when view has no columns', async () => {
228+
const views: View[] = [emptyDefaultView];
229+
230+
renderDataTab(views);
231+
232+
await waitFor(() => {
233+
expect(screen.getByText(/no columns configured/i)).toBeDefined();
234+
});
235+
236+
cleanup();
237+
});
238+
227239
it('opens config modal in create mode when New View is clicked', async () => {
228240
const user = userEvent.setup();
229241
renderDataTab();

0 commit comments

Comments
 (0)