Skip to content

Commit 6ce8654

Browse files
authored
fix(calm-hub): display control/config titles and use slug-based URLs (finos#2751)
* feat(calm-hub): display control/config titles and use slug-based URLs Extract title from the latest version JSON in MongoControlStore and NitriteControlStore into ControlDetail and ControlConfigDetail DTOs. Rewire ControlResource.getConfigurationsForControl to return details (id, name, title) instead of bare ids. UI renders title ?? name ?? fallback and navigates to controls via name slug instead of numeric id, matching existing slug-routing patterns for other resource types. * fix(calm-hub): address code-review findings on control title and slug routing - Add controlTitle to ControlData; breadcrumbs now show title ?? name - Add numeric-id fallback in deep-link handlers (TreeNavigation, MobileNavMenu) so legacy /controls/1/detail URLs still resolve - Extract latestVersionKey to VersionKeySelector shared util, removing duplicate implementations from both store backends - Eliminate duplicate configs.find() calls in ControlDetailSection breadcrumbs by computing selectedCfgLabel once before render - Add tests for title display, numeric-id fallback, and title/name/id config label fallback chain * test(calm-hub): add unit tests for VersionKeySelector to satisfy JaCoCo coverage gate * test(calm-hub): update integration test assertion for ControlConfigDetail response shape
1 parent 9393ebb commit 6ce8654

20 files changed

Lines changed: 312 additions & 73 deletions

File tree

calm-hub-ui/src/components/navbar/ExplorerSearch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function ExplorerSearch({
169169
setResults(null);
170170

171171
if (type === 'controls') {
172-
navigate(`/${result.namespace}/controls/${result.id}/detail`);
172+
navigate(`/${result.namespace}/controls/${result.name}/detail`);
173173
return;
174174
}
175175

calm-hub-ui/src/components/navbar/GlobalSearchBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function GlobalSearchBar({ searchService, calmService: calmServiceProp, a
153153
setResults(null);
154154

155155
if (type === 'controls') {
156-
navigate(`/${result.namespace}/controls/${result.id}/detail`);
156+
navigate(`/${result.namespace}/controls/${result.name}/detail`);
157157
return;
158158
}
159159

calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.test.tsx

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { ControlDetailSection } from './ControlDetailSection.js';
44
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5-
import { ControlData } from '../../../model/control.js';
5+
import { ControlConfigDetail, ControlData } from '../../../model/control.js';
66

77
// ── Mocks ─────────────────────────────────────────────────
88

@@ -80,19 +80,19 @@ const configJson = { minKeyLength: 256, algorithm: 'AES' };
8080
function setupMocks({
8181
reqVersions = ['0.1.0'],
8282
reqSchema = requirementSchema,
83-
configIds = [10],
83+
configs = [{ id: 10 }] as ControlConfigDetail[],
8484
cfgVersions = ['1.0.0'],
8585
cfgJson = configJson,
8686
}: {
8787
reqVersions?: string[];
8888
reqSchema?: object;
89-
configIds?: number[];
89+
configs?: ControlConfigDetail[];
9090
cfgVersions?: string[];
9191
cfgJson?: object;
9292
} = {}) {
9393
mockFetchRequirementVersions.mockResolvedValue(reqVersions);
9494
mockFetchRequirementForVersion.mockResolvedValue(reqSchema);
95-
mockFetchConfigurationsForControl.mockResolvedValue(configIds);
95+
mockFetchConfigurationsForControl.mockResolvedValue(configs);
9696
mockFetchConfigurationVersions.mockResolvedValue(cfgVersions);
9797
mockFetchConfigurationForVersion.mockResolvedValue(cfgJson);
9898
}
@@ -121,6 +121,17 @@ describe('ControlDetailSection', () => {
121121
});
122122
});
123123

124+
it('renders the requirement breadcrumb header with control title when present', async () => {
125+
setupMocks();
126+
render(<ControlDetailSection controlData={{ ...controlData, controlTitle: 'Pretty Title' }} />);
127+
128+
await waitFor(() => {
129+
const headings = screen.getAllByRole('heading');
130+
expect(headings[0]).toHaveTextContent('Pretty Title');
131+
expect(headings[0]).not.toHaveTextContent('Access Control');
132+
});
133+
});
134+
124135
it('renders the configuration breadcrumb header with control name', async () => {
125136
setupMocks();
126137
render(<ControlDetailSection controlData={controlData} />);
@@ -135,7 +146,7 @@ describe('ControlDetailSection', () => {
135146
});
136147

137148
it('hides the configurations panel when no config IDs exist', async () => {
138-
setupMocks({ configIds: [] });
149+
setupMocks({ configs: [] });
139150
render(<ControlDetailSection controlData={controlData} />);
140151

141152
// Wait for requirement panel to render, then assert configurations heading is absent
@@ -269,8 +280,23 @@ describe('ControlDetailSection', () => {
269280
// Configuration tabs
270281
// ──────────────────────────────────────────────────
271282
describe('configuration tabs', () => {
283+
it('renders config tab labels using title then name then fallback', async () => {
284+
setupMocks({ configs: [
285+
{ id: 10, title: 'Rate Limit Config' },
286+
{ id: 20, name: 'config-b' },
287+
{ id: 30 },
288+
]});
289+
render(<ControlDetailSection controlData={controlData} />);
290+
291+
await waitFor(() => {
292+
expect(screen.getByRole('tab', { name: 'Rate Limit Config' })).toBeInTheDocument();
293+
expect(screen.getByRole('tab', { name: 'config-b' })).toBeInTheDocument();
294+
expect(screen.getByRole('tab', { name: 'Config 30' })).toBeInTheDocument();
295+
});
296+
});
297+
272298
it('renders config ID tabs', async () => {
273-
setupMocks({ configIds: [10, 20] });
299+
setupMocks({ configs: [{ id: 10 }, { id: 20 }] });
274300
render(<ControlDetailSection controlData={controlData} />);
275301

276302
await waitFor(() => {
@@ -280,7 +306,7 @@ describe('ControlDetailSection', () => {
280306
});
281307

282308
it('clicking a config ID tab fetches its versions', async () => {
283-
setupMocks({ configIds: [10, 20] });
309+
setupMocks({ configs: [{ id: 10 }, { id: 20 }] });
284310
const user = userEvent.setup();
285311
render(<ControlDetailSection controlData={controlData} />);
286312

@@ -294,7 +320,7 @@ describe('ControlDetailSection', () => {
294320
});
295321

296322
it('applies active style to the selected config tab', async () => {
297-
setupMocks({ configIds: [10, 20] });
323+
setupMocks({ configs: [{ id: 10 }, { id: 20 }] });
298324
const user = userEvent.setup();
299325
render(<ControlDetailSection controlData={controlData} />);
300326

@@ -305,7 +331,7 @@ describe('ControlDetailSection', () => {
305331
});
306332

307333
it('shows the selected config ID in the breadcrumb header', async () => {
308-
setupMocks({ configIds: [10] });
334+
setupMocks({ configs: [{ id: 10 }] });
309335
const user = userEvent.setup();
310336
render(<ControlDetailSection controlData={controlData} />);
311337

@@ -316,7 +342,7 @@ describe('ControlDetailSection', () => {
316342
});
317343

318344
it('renders config version tabs after selecting a config ID', async () => {
319-
setupMocks({ configIds: [10], cfgVersions: ['1.0.0', '1.1.0'] });
345+
setupMocks({ configs: [{ id: 10 }], cfgVersions: ['1.0.0', '1.1.0'] });
320346
const user = userEvent.setup();
321347
render(<ControlDetailSection controlData={controlData} />);
322348

@@ -329,7 +355,7 @@ describe('ControlDetailSection', () => {
329355
});
330356

331357
it('clicking a config version tab fetches the configuration JSON', async () => {
332-
setupMocks({ configIds: [10], cfgVersions: ['1.0.0'] });
358+
setupMocks({ configs: [{ id: 10 }], cfgVersions: ['1.0.0'] });
333359
const user = userEvent.setup();
334360
render(<ControlDetailSection controlData={controlData} />);
335361

@@ -350,7 +376,7 @@ describe('ControlDetailSection', () => {
350376
});
351377

352378
it('applies active style to the selected config version tab', async () => {
353-
setupMocks({ configIds: [10], cfgVersions: ['1.0.0', '1.1.0'] });
379+
setupMocks({ configs: [{ id: 10 }], cfgVersions: ['1.0.0', '1.1.0'] });
354380
const user = userEvent.setup();
355381
render(<ControlDetailSection controlData={controlData} />);
356382

@@ -366,7 +392,7 @@ describe('ControlDetailSection', () => {
366392
});
367393

368394
it('shows the selected config version in the breadcrumb header', async () => {
369-
setupMocks({ configIds: [10], cfgVersions: ['1.0.0'] });
395+
setupMocks({ configs: [{ id: 10 }], cfgVersions: ['1.0.0'] });
370396
const user = userEvent.setup();
371397
render(<ControlDetailSection controlData={controlData} />);
372398

@@ -381,7 +407,7 @@ describe('ControlDetailSection', () => {
381407
});
382408

383409
it('does not show config version tabs until a config ID is selected', async () => {
384-
setupMocks({ configIds: [10], cfgVersions: ['1.0.0'] });
410+
setupMocks({ configs: [{ id: 10 }], cfgVersions: ['1.0.0'] });
385411
render(<ControlDetailSection controlData={controlData} />);
386412

387413
await waitFor(() => {
@@ -425,7 +451,7 @@ describe('ControlDetailSection', () => {
425451
});
426452

427453
it('switches configuration panel to Raw JSON view independently', async () => {
428-
setupMocks();
454+
setupMocks({ configs: [{ id: 10 }] });
429455
const user = userEvent.setup();
430456
render(<ControlDetailSection controlData={controlData} />);
431457

@@ -507,7 +533,7 @@ describe('ControlDetailSection', () => {
507533
});
508534

509535
it('omits the Configuration tab when no configurations exist', async () => {
510-
setupMocks({ configIds: [] });
536+
setupMocks({ configs: [] });
511537
render(<ControlDetailSection controlData={controlData} />);
512538

513539
await waitFor(() => {
@@ -517,7 +543,7 @@ describe('ControlDetailSection', () => {
517543
});
518544

519545
it('switches to the configuration panel and shows its config tabs', async () => {
520-
setupMocks({ configIds: [10, 20] });
546+
setupMocks({ configs: [{ id: 10 }, { id: 20 }] });
521547
const user = userEvent.setup();
522548
render(<ControlDetailSection controlData={controlData} />);
523549

calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react';
22
import { IoShieldCheckmarkOutline } from 'react-icons/io5';
3-
import { ControlData } from '../../../model/control.js';
3+
import { ControlConfigDetail, ControlData } from '../../../model/control.js';
44
import { JsonRenderer } from '../json-renderer/JsonRenderer.js';
55
import { ReadableJsonView } from './ReadableJsonView.js';
66
import { ControlService } from '../../../service/control-service.js';
@@ -23,7 +23,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
2323
const [requirementJson, setRequirementJson] = useState<object | undefined>();
2424

2525
// Configuration state
26-
const [configIds, setConfigIds] = useState<number[]>([]);
26+
const [configs, setConfigs] = useState<ControlConfigDetail[]>([]);
2727
const [selectedConfigId, setSelectedConfigId] = useState<number | null>(null);
2828
const [configVersions, setConfigVersions] = useState<string[]>([]);
2929
const [selectedConfigVersion, setSelectedConfigVersion] = useState<string>('');
@@ -53,7 +53,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
5353
setRequirementVersions([]);
5454
setSelectedReqVersion('');
5555
setRequirementJson(undefined);
56-
setConfigIds([]);
56+
setConfigs([]);
5757
setSelectedConfigId(null);
5858
setConfigVersions([]);
5959
setSelectedConfigVersion('');
@@ -67,7 +67,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
6767
controlService.fetchConfigurationsForControl(
6868
controlData.domain,
6969
controlData.controlId,
70-
).then(setConfigIds);
70+
).then(setConfigs);
7171
}, [controlService, controlData.domain, controlData.controlId]);
7272

7373
// Auto-select first requirement version when versions load
@@ -134,14 +134,14 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
134134
</button>
135135
));
136136

137-
const configIdButtons = configIds.map((cid) => (
137+
const configIdButtons = configs.map((cfg) => (
138138
<button
139-
key={cid}
139+
key={cfg.id}
140140
role="tab"
141-
className={`tab gap-1 rounded-lg ${selectedConfigId === cid ? 'tab-active !bg-primary !text-primary-content' : ''}`}
142-
onClick={() => handleConfigClick(cid)}
141+
className={`tab gap-1 rounded-lg ${selectedConfigId === cfg.id ? 'tab-active !bg-primary !text-primary-content' : ''}`}
142+
onClick={() => handleConfigClick(cfg.id)}
143143
>
144-
Config {cid}
144+
{cfg.title ?? cfg.name ?? `Config ${cfg.id}`}
145145
</button>
146146
));
147147

@@ -168,10 +168,16 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
168168
<JsonRenderer json={configJson} />
169169
);
170170

171+
const controlLabel = controlData.controlTitle ?? controlData.controlName;
172+
const selectedCfg = configs.find((c) => c.id === selectedConfigId);
173+
const selectedCfgLabel = selectedCfg
174+
? (selectedCfg.title ?? selectedCfg.name ?? `Config ${selectedCfg.id}`)
175+
: selectedConfigId !== null ? `Config ${selectedConfigId}` : null;
176+
171177
// ── Mobile: a single full-bleed pane with Requirement / Configuration
172178
// tabs, stacked headers, and horizontally scrollable version pickers. ──
173179
if (isMobile) {
174-
const showConfig = configIds.length > 0;
180+
const showConfig = configs.length > 0;
175181
const panel = activePanel === 'configuration' && showConfig ? 'configuration' : 'requirement';
176182

177183
return (
@@ -180,7 +186,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
180186
<div className="bg-base-200 px-4 py-3 border-b border-base-300">
181187
<h2 className="text-base font-bold flex items-center gap-2 text-primary min-w-0">
182188
<IoShieldCheckmarkOutline className="text-primary shrink-0" />
183-
<span className="truncate">{controlData.controlName}</span>
189+
<span className="truncate">{controlLabel}</span>
184190
</h2>
185191
</div>
186192

@@ -230,7 +236,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
230236
<div className="bg-base-200 px-4 py-2 border-b border-base-300 flex items-center justify-between gap-2">
231237
<h2 className="text-xs font-semibold text-base-content/70 truncate">
232238
Configurations
233-
{selectedConfigId !== null ? ` / ${selectedConfigId}` : ''}
239+
{selectedCfgLabel ? ` / ${selectedCfgLabel}` : ''}
234240
{selectedConfigVersion ? ` / ${selectedConfigVersion}` : ''}
235241
</h2>
236242
{viewToggle(cfgViewMode, setCfgViewMode)}
@@ -268,7 +274,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
268274
<div className="bg-base-200 px-6 py-2 flex items-center justify-between border-b border-base-300">
269275
<h2 className="text-sm font-bold flex items-center gap-2 text-primary">
270276
<IoShieldCheckmarkOutline className="text-primary" />
271-
<span>{controlData.controlName}</span>
277+
<span>{controlLabel}</span>
272278
<span className="text-gray-400">/</span>
273279
<span>Requirement</span>
274280
{selectedReqVersion && (
@@ -298,19 +304,19 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps)
298304
</div>
299305

300306
{/* Bottom section: Configurations (only shown if any exist) */}
301-
{configIds.length > 0 && (
307+
{configs.length > 0 && (
302308
<div className="flex-1 min-h-0 bg-base-100 rounded-2xl overflow-hidden flex flex-col shadow-xl">
303309
{/* Configuration breadcrumb header */}
304310
<div className="bg-base-200 px-6 py-2 flex items-center justify-between border-b border-base-300">
305311
<h2 className="text-sm font-bold flex items-center gap-2">
306312
<IoShieldCheckmarkOutline className="text-accent" />
307-
<span>{controlData.controlName}</span>
313+
<span>{controlLabel}</span>
308314
<span className="text-gray-400">/</span>
309315
<span>Configurations</span>
310-
{selectedConfigId !== null && (
316+
{selectedCfgLabel && (
311317
<>
312318
<span className="text-gray-400">/</span>
313-
<span>{selectedConfigId}</span>
319+
<span>{selectedCfgLabel}</span>
314320
</>
315321
)}
316322
{selectedConfigVersion && (

calm-hub-ui/src/hub/components/tree-navigation/ControlItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function ControlItem({ control, isSelected, onControlClick }: ControlItem
1111
return (
1212
<li>
1313
<a className={isSelected ? 'active' : ''} onClick={() => onControlClick(control)}>
14-
{control.name}
14+
{control.title ?? control.name}
1515
</a>
1616
</li>
1717
);

calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,15 @@ export function MobileNavMenu({ onDataLoad, onAdrLoad, onControlLoad, onInterfac
114114
controlService
115115
.fetchControlsForDomain(namespace)
116116
.then((controls) => {
117-
const match = controls.find((c) => c.id === Number(params.id));
117+
const match = controls.find((c) => c.name === params.id)
118+
?? controls.find((c) => c.id === Number(params.id));
118119
if (match) {
119120
onControlLoadRef.current({
120121
domain: namespace,
121122
controlId: match.id,
122123
controlName: match.name,
123124
controlDescription: match.description,
125+
controlTitle: match.title,
124126
});
125127
}
126128
})
@@ -190,7 +192,7 @@ export function MobileNavMenu({ onDataLoad, onAdrLoad, onControlLoad, onInterfac
190192
controlService
191193
.fetchControlsForDomain(domain)
192194
.then((controls: ControlDetail[]) =>
193-
setLeafItems(controls.map((c) => ({ id: c.id.toString(), name: c.name })))
195+
setLeafItems(controls.map((c) => ({ id: c.name, name: c.title ?? c.name })))
194196
)
195197
.catch(() => setLeafItems([]))
196198
.finally(() => setLoading(false));

0 commit comments

Comments
 (0)