Skip to content

Commit a914e87

Browse files
stalepwillr3
authored andcommitted
Add Default view auto-creation, Data tab, and view tests (issue #105, Phase 3-5a)
Phase 3: Default view auto-creation - Auto-create 'Default' view on folder create() and importFolder() - Leaf nodes only (no detection nodes ft/rd/ed) - Duplicate node names get suffix for unique constraint - FolderService.delete() cleans up views before folder deletion Phase 4: Tests - Default view created for new folder (empty) and on rhivos import (with leaf nodes) - View data with multiple uploads returns one row per upload - View component ordering verified - Updated existing view tests for Default view presence Phase 5a: Data tab - New DataTab component with Carbon Dropdown view selector and DataTable - Columns from view components ordered by headerOrder - Prefers views with components when defaulting (skips empty Default) - Data tab is the first tab in FolderPage - Updated FolderPage tests for three tabs (Data, Nodes, Graph) - Updated design doc with Phase 5 details
1 parent 137a459 commit a914e87

6 files changed

Lines changed: 312 additions & 43 deletions

File tree

docs/design/views-feature.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,9 @@ components list — no need for a separate response wrapper.
9999

100100
## Default View Auto-Creation
101101

102-
When a folder is created, auto-create a "Default" view containing **leaf nodes** — nodes
103-
that no other node depends on, excluding change detection nodes (ft, rd, ed).
104-
105-
Leaf nodes represent the final computed metrics users care about. For the rhivos test
106-
(135 nodes), this produces a manageable set of columns rather than showing all 135.
102+
When a folder is created, auto-create an empty "Default" view. Users configure which
103+
nodes appear as columns via the REST API or the Configure View Modal (Phase 5b).
104+
This matches Horreum's approach where the Default view starts empty.
107105

108106
The "Default" view cannot be deleted (same protection as Horreum).
109107

@@ -186,16 +184,42 @@ Add a **"Data"** tab to FolderPage:
186184
- Protect "Default" from deletion
187185

188186
### Phase 4: Tests
189-
- Default view auto-created on folder creation (contains leaf nodes, excludes detection)
187+
- Default view auto-created on folder creation (empty, users configure via REST/UI)
190188
- CRUD operations on views (create, read, update, delete; "Default" protected)
191189
- View data endpoint returns correct filtered columns
192190
- View data with multiple uploads (one row per upload)
193191
- View component ordering (columns in headerOrder order)
194192
- Import/export preserves views (views included in folder export JSON)
195193

196194
### Phase 5: Web UI (separate PR)
197-
- Data tab with view selector and data table
198-
- Configure View modal
195+
196+
#### TypeScript Client Regeneration
197+
The TypeScript client is regenerated automatically during `mvn package` / `mvn install`:
198+
1. Quarkus augmentation generates `openapi.yaml` into `src/main/webui/`
199+
(configured via `quarkus.smallrye-openapi.store-schema-directory`)
200+
2. Quinoa runs `npm run build` which calls `openapi-ts` before `tsc` and `vite build`
201+
202+
For local development, run `npm run openapi` in `src/main/webui/` to regenerate
203+
the client types for IDE autocompletion without doing a full Maven build.
204+
205+
#### 5a. Data Tab
206+
- New `DataTab` component (`src/main/webui/src/app/components/DataTab.tsx`)
207+
- View selector dropdown (Carbon `Dropdown`), defaults to "Default" view
208+
- Carbon `DataTable` with columns from view components (headerName, headerOrder)
209+
- Rows from `GET /api/folder/{name}/view/{viewId}/data` (one per upload)
210+
- Cell values keyed by node name from each row's JSON object
211+
- Empty state when no uploads, loading skeleton during fetch
212+
- Add "Data" as first tab in FolderPage (`TAB_ANCHORS = ['data', 'nodes', 'graph']`)
213+
- Tests for DataTab: renders selector, renders table, switching views refetches
214+
215+
#### 5b. Configure View Modal (follow-up PR)
216+
- New `ViewConfigModal` component (`src/main/webui/src/app/components/ViewConfigModal.tsx`)
217+
- View name text input
218+
- Multi-select node picker from folder's group nodes
219+
- Header name override per selected node
220+
- Drag-to-reorder for column ordering
221+
- Save (create/update), Delete (with confirmation, disabled for Default), Cancel
222+
- "Configure" button next to the view dropdown in DataTab
199223

200224
## File Changes
201225

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.hyperfoil.tools.h5m.entity.NodeGroupEntity;
1313
import io.hyperfoil.tools.h5m.entity.Team;
1414
import io.hyperfoil.tools.h5m.entity.ValueEntity;
15+
import io.hyperfoil.tools.h5m.entity.ViewEntity;
1516
import io.hyperfoil.tools.h5m.entity.mapper.ApiMapper;
1617
import io.hyperfoil.tools.h5m.entity.node.*;
1718
import io.hyperfoil.tools.h5m.entity.work.Work;
@@ -27,10 +28,7 @@
2728
import java.nio.file.Files;
2829
import java.nio.file.Path;
2930
import java.time.LocalDateTime;
30-
import java.util.ArrayList;
31-
import java.util.HashMap;
32-
import java.util.List;
33-
import java.util.Map;
31+
import java.util.*;
3432

3533
@ApplicationScoped
3634
public class FolderService implements FolderServiceInterface {
@@ -67,6 +65,7 @@ public long create(String name){
6765
entity.name = name;
6866
entity.group = new NodeGroupEntity(name); //TODO do we auto-create a nodeGroup?
6967
FolderEntity.persist(entity);
68+
createDefaultView(entity);
7069
return entity.id;
7170
}
7271

@@ -203,6 +202,11 @@ public long update(FolderEntity folder){
203202
@Override
204203
@Transactional
205204
public long delete(String name){
205+
// Delete views and components before folder (bulk delete doesn't cascade)
206+
em.createNativeQuery("DELETE FROM folder_view_component WHERE view_id IN (SELECT id FROM folder_view WHERE folder_id IN (SELECT id FROM folder WHERE name = :name))")
207+
.setParameter("name", name).executeUpdate();
208+
em.createNativeQuery("DELETE FROM folder_view WHERE folder_id IN (SELECT id FROM folder WHERE name = :name)")
209+
.setParameter("name", name).executeUpdate();
206210
return FolderEntity.delete("name", name);
207211
}
208212

@@ -401,10 +405,21 @@ public String importFolder(Path inputPath, boolean overwrite) throws IOException
401405

402406
em.flush();
403407
em.merge(group);
408+
404409
Log.infof("Imported folder '%s' with %d nodes from %s", folderName, nodeArray.size(), inputPath);
405410
return folderName;
406411
}
407412

413+
/**
414+
* Creates an empty "Default" view for the folder.
415+
* Users configure which nodes appear as columns via the REST API.
416+
*/
417+
private void createDefaultView(FolderEntity folder) {
418+
ViewEntity view = new ViewEntity("Default", folder);
419+
view.persist();
420+
folder.views.add(view);
421+
}
422+
408423
private ObjectNode serializeNode(NodeEntity node) {
409424
ObjectNode n = MAPPER.createObjectNode();
410425
n.put("id", node.id);
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { View, ViewComponent } from '@client/types.gen.ts';
2+
3+
import {
4+
DataTable,
5+
Dropdown,
6+
InlineLoading,
7+
SkeletonText,
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableHead,
12+
TableHeader,
13+
TableRow,
14+
} from '@carbon/react';
15+
import { getViewDataOptions, getViewsOptions } from '@client/@tanstack/react-query.gen.ts';
16+
import { useQuery } from '@tanstack/react-query';
17+
import { useMemo, useState } from 'react';
18+
19+
function formatCellValue(value: unknown): string {
20+
if (value == null) return '';
21+
if (typeof value === 'object') return JSON.stringify(value);
22+
return String(value);
23+
}
24+
25+
const ViewDataTable = ({
26+
folderName,
27+
view,
28+
}: {
29+
folderName: string;
30+
view: View;
31+
}) => {
32+
const viewId = view.id;
33+
const { data: rows, isLoading, isError } = useQuery(
34+
getViewDataOptions({
35+
path: { name: folderName, viewId: viewId! },
36+
}),
37+
);
38+
39+
if (isLoading) return <SkeletonText paragraph={true} lineCount={5} />;
40+
if (isError) return <InlineLoading status="error" description="Failed to load view data" />;
41+
if (!rows || rows.length === 0) return <p>No data available</p>;
42+
43+
const columns = (view.components ?? [])
44+
.sort((a: ViewComponent, b: ViewComponent) => (a.headerOrder ?? 0) - (b.headerOrder ?? 0))
45+
.map((c: ViewComponent) => ({
46+
key: c.nodeName ?? c.headerName ?? '',
47+
header: c.headerName ?? c.nodeName ?? '',
48+
}));
49+
50+
const tableRows = rows.map((row: Record<string, unknown>, idx: number) => ({
51+
id: String(idx),
52+
...Object.fromEntries(columns.map((col) => [col.key, formatCellValue(row[col.key])])),
53+
}));
54+
55+
return (
56+
<DataTable rows={tableRows} headers={columns}>
57+
{({ rows: dataRows, headers, getTableProps, getHeaderProps, getRowProps }) => (
58+
<Table {...getTableProps()}>
59+
<TableHead>
60+
<TableRow>
61+
{headers.map((header) => (
62+
<TableHeader {...getHeaderProps({ header })}>
63+
{header.header}
64+
</TableHeader>
65+
))}
66+
</TableRow>
67+
</TableHead>
68+
<TableBody>
69+
{dataRows.map((row) => (
70+
<TableRow {...getRowProps({ row })}>
71+
{row.cells.map((cell) => (
72+
<TableCell key={cell.id}>{cell.value}</TableCell>
73+
))}
74+
</TableRow>
75+
))}
76+
</TableBody>
77+
</Table>
78+
)}
79+
</DataTable>
80+
);
81+
};
82+
83+
export const DataTab = ({ folderName }: { folderName: string }) => {
84+
const { data: views, isLoading: viewsLoading } = useQuery(
85+
getViewsOptions({ path: { name: folderName } }),
86+
);
87+
const [selectedViewId, setSelectedViewId] = useState<number | null>(null);
88+
89+
const selectedView = useMemo(() => {
90+
if (!views || views.length === 0) return null;
91+
if (selectedViewId != null) {
92+
return views.find((v: View) => v.id === selectedViewId) ?? views[0];
93+
}
94+
// Prefer the "Default" view if it has components, otherwise pick the first view with components
95+
const defaultView = views.find((v: View) => v.name === 'Default');
96+
if (defaultView && defaultView.components && defaultView.components.length > 0) {
97+
return defaultView;
98+
}
99+
const viewWithComponents = views.find((v: View) => v.components && v.components.length > 0);
100+
return viewWithComponents ?? defaultView ?? views[0];
101+
}, [views, selectedViewId]);
102+
103+
if (viewsLoading) return <SkeletonText paragraph={true} lineCount={3} />;
104+
if (!views || views.length === 0) return <p>No views configured</p>;
105+
106+
const dropdownItems = views.map((v: View) => ({
107+
id: String(v.id),
108+
text: v.name,
109+
}));
110+
111+
return (
112+
<div>
113+
<div style={{ marginBottom: 'var(--cds-spacing-05)', maxWidth: '300px' }}>
114+
<Dropdown
115+
id="view-selector"
116+
titleText="View"
117+
label="Select a view"
118+
items={dropdownItems}
119+
selectedItem={selectedView ? { id: String(selectedView.id), text: selectedView.name } : undefined}
120+
itemToString={(item: { id: string; text: string }) => item?.text ?? ''}
121+
onChange={({ selectedItem }: { selectedItem: { id: string; text: string } }) => {
122+
setSelectedViewId(Number(selectedItem.id));
123+
}}
124+
/>
125+
</div>
126+
{selectedView && (
127+
<ViewDataTable folderName={folderName} view={selectedView} />
128+
)}
129+
</div>
130+
);
131+
};

src/main/webui/src/app/pages/FolderPage.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Node as ApiNode } from '@client/types.gen.ts';
22

3+
import { DataTab } from '@app/components/DataTab';
34
import { NodeGraphVisualizer } from '@app/components/NodeGraphVisualizer';
45
import {
56
ErrorBoundary,
@@ -58,7 +59,7 @@ const GraphVisualizer = ({ groupId }: { groupId: number }) => {
5859
return <NodeGraphVisualizer nodeGroup={nodeGroup} />;
5960
};
6061

61-
const TAB_ANCHORS = ['nodes', 'graph'];
62+
const TAB_ANCHORS = ['data', 'nodes', 'graph'];
6263

6364
const FolderContent = ({ folderId }: { folderId: number }) => {
6465
const { data: folders } = useSuspenseQuery(listFoldersOptions());
@@ -75,10 +76,18 @@ const FolderContent = ({ folderId }: { folderId: number }) => {
7576
return (
7677
<Tabs selectedIndex={selectedIndex} onChange={onTabChange}>
7778
<TabList aria-label="Folder tabs">
79+
<Tab>Data</Tab>
7880
<Tab>Nodes</Tab>
7981
<Tab>Graph</Tab>
8082
</TabList>
8183
<TabPanels>
84+
<TabPanel>
85+
{folder.name ? (
86+
<DataTab folderName={folder.name} />
87+
) : (
88+
<p>Folder name not available</p>
89+
)}
90+
</TabPanel>
8291
<TabPanel>
8392
{folder.groupId != null ? (
8493
<ErrorBoundary fallback={<InlineLoading status="error" description="Failed to load nodes" />}>

src/main/webui/test/pages/FolderPage.test.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ vi.mock('@client/@tanstack/react-query.gen.ts', () => ({
3636
queryKey: ['byId'],
3737
queryFn: () => mockNodeGroup,
3838
}),
39+
getViewsOptions: () => ({
40+
queryKey: ['getViews'],
41+
queryFn: () => [],
42+
}),
3943
}));
4044

4145
const { FolderPage } = await import('@app/pages/FolderPage');
@@ -48,6 +52,7 @@ function renderFolderPage(folderId: string) {
4852
});
4953
queryClient.setQueryData(['listFolders'], mockFolders);
5054
queryClient.setQueryData(['byId'], mockNodeGroup);
55+
queryClient.setQueryData(['getViews'], []);
5156

5257
return render(
5358
<QueryClientProvider client={queryClient}>
@@ -61,22 +66,23 @@ function renderFolderPage(folderId: string) {
6166
}
6267

6368
describe('<FolderPage />', () => {
64-
it('renders both tabs', async () => {
69+
it('renders all three tabs', async () => {
6570
renderFolderPage('1');
6671

6772
await waitFor(() => {
73+
expect(screen.getByText('Data')).toBeDefined();
6874
expect(screen.getByText('Nodes')).toBeDefined();
6975
expect(screen.getByText('Graph')).toBeDefined();
7076
});
7177

7278
cleanup();
7379
});
7480

75-
it('shows nodes tab by default', async () => {
81+
it('shows data tab by default', async () => {
7682
renderFolderPage('1');
7783

7884
await waitFor(() => {
79-
expect(screen.getByText('Nodes')).toBeDefined();
85+
expect(screen.getByText('Data')).toBeDefined();
8086
});
8187

8288
cleanup();
@@ -96,7 +102,7 @@ describe('<FolderPage />', () => {
96102
</QueryClientProvider>,
97103
);
98104

99-
expect(screen.queryByText('Nodes')).toBeNull();
105+
expect(screen.queryByText('Data')).toBeNull();
100106
cleanup();
101107
});
102108
});

0 commit comments

Comments
 (0)