[UX] #577 show VPD submenu below primary navigation

This commit is contained in:
devmrko
2026-07-01 09:38:05 +09:00
parent 1218ae1719
commit c52890e721
5 changed files with 93 additions and 109 deletions

View File

@@ -104,6 +104,6 @@ UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않
| 별도 Filter 관리 | 고급 접근 조건 | 예외 정책 기능 | | 별도 Filter 관리 | 고급 접근 조건 | 예외 정책 기능 |
| 운영 상태 / 연결 설정 | 운영 현황 / 시스템 설정 | 운영성·연동 설정 기능 | | 운영 상태 / 연결 설정 | 운영 현황 / 시스템 설정 | 운영성·연동 설정 기능 |
상단 메뉴는 DDS와 같은 네 업무 영역인 `권한 관리 · 보호 정책 관리 · 지식자료 관리 · 접근 검증`으로 구성한다. ORDS·MCP·Filter·시스템 설정은 기본 제품 메뉴에서 빼고 관련 화면의 고급 링크에서만 연다. 절차나 구현 방식이 아니라 사용자가 하려는 일을 메뉴명으로 쓴다. 메뉴와 기본 화면에는 `1.`, `2.`, 단계 레일, `01/02` 배지를 두지 않는다. 순서가 실제 입력 제약인 wizard에서만 단계 번호를 사용한다. 상단 메뉴는 DDS와 같은 네 업무 영역인 `권한 관리 · 보호 정책 관리 · 지식자료 관리 · 접근 검증`으로 구성한다. ORDS·MCP·Filter·시스템 설정은 기본 제품 메뉴에서 빼고 관련 화면의 고급 링크에서만 연다. 1차 메뉴를 누르면 하위 메뉴가 팝업으로 뜨지 않고 바로 아래 전폭 줄에 표시된다. 현재 페이지가 속한 메뉴는 처음부터 아래 줄을 열어 둔다. 절차나 구현 방식이 아니라 사용자가 하려는 일을 메뉴명으로 쓴다. 메뉴와 기본 화면에는 `1.`, `2.`, 단계 레일, `01/02` 배지를 두지 않는다. 순서가 실제 입력 제약인 wizard에서만 단계 번호를 사용한다.
기본 페이지는 제목과 현재 설정·주요 행동만 노출한다. VPD/ORDS 처리 원리, 배경 설명, 주의사항은 상단의 공통 `도움말` 버튼을 눌렀을 때만 펼친다. 이 규칙으로 제품 화면이 관리 매뉴얼처럼 보이는 문제를 줄이고, 설정 자체를 전면에 둔다. 기본 페이지는 제목과 현재 설정·주요 행동만 노출한다. VPD/ORDS 처리 원리, 배경 설명, 주의사항은 상단의 공통 `도움말` 버튼을 눌렀을 때만 펼친다. 이 규칙으로 제품 화면이 관리 매뉴얼처럼 보이는 문제를 줄이고, 설정 자체를 전면에 둔다.

View File

@@ -113,10 +113,6 @@ body {
gap: .35rem; gap: .35rem;
} }
.rw-menu-group {
position: relative;
}
.rw-menu-trigger { .rw-menu-trigger {
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@@ -130,36 +126,38 @@ body {
.rw-menu-trigger:hover, .rw-menu-trigger:hover,
.rw-menu-trigger:focus, .rw-menu-trigger:focus,
.rw-menu-group.open .rw-menu-trigger { .rw-menu-trigger.active {
background: var(--rw-surface-muted); background: var(--rw-surface-muted);
border-color: var(--rw-border); border-color: var(--rw-border);
color: var(--rw-text); color: var(--rw-text);
} }
.rw-menu-panel { .rw-submenu-bar {
background: var(--rw-surface); background: var(--rw-surface-muted);
border: 1px solid var(--rw-border); border-top: 1px solid var(--rw-border);
border-radius: 8px; width: 100%;
box-shadow: var(--rw-shadow);
display: none;
left: 0;
min-width: 190px;
padding: .35rem;
position: absolute;
top: calc(100% + .35rem);
z-index: 30;
} }
.rw-menu-group.open .rw-menu-panel { .rw-submenu-bar .container {
display: grid;
gap: .15rem;
}
.rw-menu-panel .nav-link {
border-radius: 8px;
color: var(--rw-text);
display: block; display: block;
padding: .55rem .65rem !important; }
.rw-submenu {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: .2rem;
min-height: 2.75rem;
padding: .25rem 0;
}
.rw-submenu[hidden] {
display: none;
}
.rw-submenu .nav-link {
color: var(--rw-text);
padding: .4rem .7rem !important;
white-space: nowrap; white-space: nowrap;
} }
@@ -181,14 +179,6 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.rw-menu-panel {
position: static;
}
.rw-menu-group.open .rw-menu-panel {
display: grid;
}
.architecture-strip { .architecture-strip {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1786,10 +1776,6 @@ body {
width: 100%; width: 100%;
} }
.rw-menu-group {
min-width: 0;
}
.rw-menu-trigger { .rw-menu-trigger {
padding-left: .65rem; padding-left: .65rem;
padding-right: .65rem; padding-right: .65rem;

View File

@@ -73,45 +73,39 @@ function syncPolicyTemplate(select) {
); );
} }
function closeMenuGroup(group) { function initPersistentMenus() {
if (!group) { const productNav = document.querySelector('[data-product-nav]');
if (!productNav) {
return; return;
} }
group.classList.remove('open'); const submenuBar = productNav.querySelector('[data-submenu-bar]');
group.querySelector('.rw-menu-trigger')?.setAttribute('aria-expanded', 'false'); const triggers = Array.from(productNav.querySelectorAll('[data-submenu-trigger]'));
} const panels = Array.from(productNav.querySelectorAll('[data-submenu-panel]'));
const path = window.location.pathname;
function closeAllMenuGroups(exceptGroup = null) { const activate = (target) => {
document.querySelectorAll('.rw-menu-group.open').forEach((group) => { const hasTarget = panels.some((panel) => panel.dataset.submenuPanel === target);
if (group !== exceptGroup) { submenuBar.hidden = !hasTarget;
closeMenuGroup(group); triggers.forEach((trigger) => {
} const active = trigger.dataset.submenuTrigger === target;
}); trigger.classList.toggle('active', active);
} trigger.setAttribute('aria-expanded', String(active));
function initPersistentMenus() {
document.querySelectorAll('.rw-menu-trigger').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
const group = button.closest('.rw-menu-group');
if (!group) {
return;
}
const nextOpen = !group.classList.contains('open');
closeAllMenuGroups(group);
group.classList.toggle('open', nextOpen);
button.setAttribute('aria-expanded', String(nextOpen));
}); });
}); panels.forEach((panel) => {
document.addEventListener('click', (event) => { panel.hidden = panel.dataset.submenuPanel !== target;
if (!event.target.closest('.rw-menu')) { });
closeAllMenuGroups(); };
}
}); const currentPanel = panels.find((panel) => Array.from(panel.querySelectorAll('a[href]')).some((link) => {
document.addEventListener('keydown', (event) => { const href = new URL(link.href, window.location.origin).pathname;
if (event.key === 'Escape') { return path === href || (href !== '/' && path.startsWith(`${href}/`));
closeAllMenuGroups(); }));
} if (currentPanel) {
activate(currentPanel.dataset.submenuPanel);
}
triggers.forEach((trigger) => {
trigger.addEventListener('click', () => activate(trigger.dataset.submenuTrigger));
}); });
} }

View File

@@ -11,49 +11,45 @@
<script defer src="/js/app.js"></script> <script defer src="/js/app.js"></script>
</head> </head>
<body> <body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg"> <nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container"> <div class="container">
<a class="navbar-brand" href="/">VPD 권한 운영</a> <a class="navbar-brand" href="/">VPD 권한 운영</a>
<div class="rw-menu"> <div class="rw-menu" aria-label="주요 메뉴">
<div class="rw-menu-group"> <button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">권한 관리</button>
<button class="rw-menu-trigger" type="button" aria-expanded="false">권한 관리</button> <button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호 정책 관리</button>
<div class="rw-menu-panel"> <button class="rw-menu-trigger" type="button" data-submenu-trigger="knowledge" aria-controls="submenu-knowledge" aria-expanded="false">지식자료 관리</button>
<a class="nav-link" href="/users">사용자 관리</a> <button class="rw-menu-trigger" type="button" data-submenu-trigger="verification" aria-controls="submenu-verification" aria-expanded="false">접근 검증</button>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">접근 규칙</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">보호 정책 관리</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/objects">보호 대상 관리</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">지식자료 관리</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/vector-knowledge">지식자료 등록 및 검색</a>
<a class="nav-link" href="/mcp-chatbot">대화형 검색</a>
<a class="nav-link" href="/mcp-reasoning">검색 해석</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">접근 검증</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/tokens">검증 세션</a>
<a class="nav-link" href="/probe">접근 검증</a>
<a class="nav-link" href="/operation-status">검증 현황</a>
</div>
</div>
</div> </div>
<form method="post" action="/logout" class="ms-auto"> <form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button> <button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
</form> </form>
</div> </div>
<div class="rw-submenu-bar" data-submenu-bar hidden>
<div class="container">
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="권한 관리 메뉴" hidden>
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">접근 규칙</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a>
</div>
<div id="submenu-protection" class="rw-submenu" data-submenu-panel="protection" role="navigation" aria-label="보호 정책 관리 메뉴" hidden>
<a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/objects">보호 대상 관리</a>
</div>
<div id="submenu-knowledge" class="rw-submenu" data-submenu-panel="knowledge" role="navigation" aria-label="지식자료 관리 메뉴" hidden>
<a class="nav-link" href="/vector-knowledge">지식자료 등록 및 검색</a>
<a class="nav-link" href="/mcp-chatbot">대화형 검색</a>
<a class="nav-link" href="/mcp-reasoning">검색 해석</a>
</div>
<div id="submenu-verification" class="rw-submenu" data-submenu-panel="verification" role="navigation" aria-label="접근 검증 메뉴" hidden>
<a class="nav-link" href="/tokens">검증 세션</a>
<a class="nav-link" href="/probe">접근 검증</a>
<a class="nav-link" href="/operation-status">검증 현황</a>
</div>
</div>
</div>
</nav> </nav>
</body> </body>

View File

@@ -14,6 +14,7 @@ class GuidedFlowTemplateTest {
void dashboardPrioritizesActionsAndKeepsDetailCollapsed() throws IOException { void dashboardPrioritizesActionsAndKeepsDetailCollapsed() throws IOException {
String html = template("dashboard.html"); String html = template("dashboard.html");
String layout = template("fragments/layout.html"); String layout = template("fragments/layout.html");
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
assertThat(html) assertThat(html)
.contains("th:replace=\"~{fragments/layout :: head('VPD 권한 운영')}\"") .contains("th:replace=\"~{fragments/layout :: head('VPD 권한 운영')}\"")
@@ -31,7 +32,14 @@ class GuidedFlowTemplateTest {
.contains("보호 정책 관리") .contains("보호 정책 관리")
.contains("접근 검증") .contains("접근 검증")
.contains("지식자료 관리") .contains("지식자료 관리")
.contains("data-submenu-trigger")
.contains("data-submenu-bar")
.contains("data-submenu-panel")
.doesNotContain("VPD Backoffice"); .doesNotContain("VPD Backoffice");
assertThat(javascript)
.contains("data-submenu-trigger")
.contains("data-submenu-panel")
.doesNotContain("closeAllMenuGroups");
} }
@Test @Test