From f39cc49021800dadff596aad699c10e22cf90aa3 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 26 Jun 2026 10:13:45 +0900 Subject: [PATCH] fix #489: add vpd target filtering --- .../design/489-vpd-target-filtering/README.md | 47 +++++++ src/main/resources/static/css/app.css | 40 ++++++ src/main/resources/static/js/app.js | 129 ++++++++++++++++++ .../resources/templates/vpd-policies.html | 61 ++++++++- 4 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 docs/design/489-vpd-target-filtering/README.md diff --git a/docs/design/489-vpd-target-filtering/README.md b/docs/design/489-vpd-target-filtering/README.md new file mode 100644 index 0000000..f5205f5 --- /dev/null +++ b/docs/design/489-vpd-target-filtering/README.md @@ -0,0 +1,47 @@ +# Redmine #489 - VPD 적용 대상 목록 필터/검색 설계 + +## 프로젝트 개요 + +VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. 운영자는 Oracle catalog의 TABLE/VIEW 중 VPD 적용 대상과 권한 등록 상태를 확인하고, 개별 또는 벌크로 VPD policy를 적용한다. + +## 목표 + +`VPD 설정` 화면의 `VPD 적용 대상 TABLE/VIEW` 목록에서 업무 객체를 빠르게 찾을 수 있게 한다. DB catalog 원본은 유지하되, 기본 화면에서는 백오피스 지원 테이블을 숨기고 검색/필터/표시 건수 제어를 제공한다. + +## 문제 + +캡처 기준: + +- `/tmp/vpd-ux-audit/screenshots/desktop-vpd-policies.png` +- `/tmp/vpd-ux-audit/screenshots/mobile-vpd-policies.png` + +확인된 문제: + +- 지원 테이블(`CB_APP_*`, `CB_PERMISSION*`, `CB_PROTECTED_*`, `CB_USER_*` 등)과 업무 TABLE/VIEW가 섞여 보인다. +- 목록이 길어 개별 적용 폼까지 이동하기 전에 운영자가 맥락을 잃는다. +- schema 조회는 있으나 object 검색, type, VPD 상태, 권한 등록 상태 필터가 없다. + +## 설계 + +- 서버의 catalog 조회 결과는 그대로 둔다. +- 클라이언트 필터를 추가해 현재 페이지의 목록을 즉시 좁힌다. +- 기본값은 `백오피스 지원 테이블 제외`로 둔다. +- 지원 테이블 포함 여부, 검색어, object type, VPD 적용 상태, 권한 등록 상태, 표시 건수를 조합한다. +- 숨김은 primary row와 filter detail row를 한 쌍으로 처리한다. + +## 완료 기준 + +- 기본 화면에서 백오피스 지원 테이블이 숨겨진다. +- 사용자는 checkbox로 지원 테이블을 다시 포함할 수 있다. +- object 검색, TABLE/VIEW, VPD 상태, 권한 등록 상태, 표시 건수 필터가 동작한다. +- 필터 결과 건수와 전체 건수가 표시된다. +- 모바일 390px에서 body horizontal overflow가 없어야 한다. + +## 검증 + +- `mvn test` +- Playwright: + - `/vpd-policies`에서 기본 표시 건수와 지원 테이블 숨김 확인 + - 지원 테이블 포함 checkbox 동작 확인 + - 검색어 `BOARD` 입력 시 matching row 확인 + - 390px viewport overflow 확인 diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index b4faad1..03d0c82 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -526,6 +526,38 @@ body { min-height: 2rem; } +.vpd-target-controls { + align-items: end; + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + display: grid; + gap: .75rem; + grid-template-columns: minmax(min(220px, 100%), 1.5fr) repeat(4, minmax(min(120px, 100%), 1fr)) auto auto; + margin-bottom: .75rem; + min-width: 0; + padding: .75rem; +} + +.vpd-target-controls label { + color: var(--rw-muted); + font-size: .78rem; + font-weight: 700; +} + +.vpd-support-toggle { + align-items: center; + display: flex; + gap: .45rem; + min-height: 2.2rem; +} + +.vpd-target-count { + color: var(--rw-muted); + font-size: .82rem; + white-space: nowrap; +} + .question-presets { display: flex; flex-wrap: wrap; @@ -1146,6 +1178,14 @@ body { flex: 1 1 auto; } + .vpd-target-controls { + grid-template-columns: 1fr; + } + + .vpd-target-count { + white-space: normal; + } + .table-responsive > .table { min-width: 960px; } diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index e04db4b..bf6261c 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -492,9 +492,138 @@ function initPermissionWizard() { activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1')); } +const backofficeSupportObjectPatterns = [ + /^CB_APP_/, + /^CB_PERMISSION(_|$)/, + /^CB_PROTECTED_/, + /^CB_USER_/, + /^CB_GROUP_/, + /^CB_ORDS_/, + /^CB_AGENT_BEARER_/, + /^CB_BACKOFFICE_/, + /^APP_(GROUP|ROLE|USER)$/, + /^DB_SOURCE$/, + /^PERMISSION$/, + /^USER_GROUP$/ +]; + +function isBackofficeSupportObject(objectName) { + const normalized = (objectName || '').trim().toUpperCase(); + return backofficeSupportObjectPatterns.some((pattern) => pattern.test(normalized)); +} + +function initVpdTargetFilters() { + const controls = document.querySelector('[data-vpd-target-controls]'); + const body = document.querySelector('[data-vpd-target-body]'); + if (!controls || !body) { + return; + } + const rows = Array.from(body.querySelectorAll('[data-vpd-target-row]')).map((row) => ({ + row, + detail: row.nextElementSibling?.matches('[data-vpd-target-detail]') ? row.nextElementSibling : null, + object: (row.dataset.object || '').toUpperCase(), + objectName: (row.dataset.objectName || '').toUpperCase(), + objectType: (row.dataset.objectType || '').toUpperCase(), + vpdApplied: row.dataset.vpdApplied || 'false', + protectedObject: row.dataset.protectedObject || 'false' + })); + const search = controls.querySelector('[data-vpd-target-search]'); + const type = controls.querySelector('[data-vpd-target-type]'); + const vpd = controls.querySelector('[data-vpd-target-vpd]'); + const protectedObject = controls.querySelector('[data-vpd-target-protected]'); + const includeSupport = controls.querySelector('[data-vpd-target-include-support]'); + const pageSize = controls.querySelector('[data-vpd-target-page-size]'); + const visibleCount = controls.querySelector('[data-vpd-target-visible]'); + const matchedCount = controls.querySelector('[data-vpd-target-matched]'); + const totalCount = controls.querySelector('[data-vpd-target-total]'); + const reset = controls.querySelector('[data-vpd-target-reset]'); + const empty = document.createElement('tr'); + empty.className = 'vpd-target-empty-runtime'; + empty.innerHTML = '필터 조건에 맞는 TABLE/VIEW가 없습니다.'; + body.appendChild(empty); + + const matchesFilters = (entry) => { + const query = (search?.value || '').trim().toUpperCase(); + if (query && !entry.object.includes(query)) { + return false; + } + if (type?.value && entry.objectType !== type.value) { + return false; + } + if (vpd?.value && entry.vpdApplied !== vpd.value) { + return false; + } + if (protectedObject?.value && entry.protectedObject !== protectedObject.value) { + return false; + } + if (!includeSupport?.checked && isBackofficeSupportObject(entry.objectName)) { + return false; + } + return true; + }; + + const applyFilters = () => { + const maxRows = pageSize?.value === 'ALL' ? Number.POSITIVE_INFINITY : Number(pageSize?.value || 50); + let matched = 0; + let visible = 0; + rows.forEach((entry) => { + const matchedRow = matchesFilters(entry); + if (matchedRow) { + matched += 1; + } + const show = matchedRow && visible < maxRows; + if (show) { + visible += 1; + } + entry.row.hidden = !show; + if (entry.detail) { + entry.detail.hidden = !show; + } + }); + empty.hidden = matched !== 0; + if (visibleCount) { + visibleCount.textContent = String(visible); + } + if (matchedCount) { + matchedCount.textContent = String(matched); + } + if (totalCount) { + totalCount.textContent = String(rows.length); + } + }; + + [search, type, vpd, protectedObject, includeSupport, pageSize].forEach((control) => { + control?.addEventListener(control === search ? 'input' : 'change', applyFilters); + }); + reset?.addEventListener('click', () => { + if (search) { + search.value = ''; + } + if (type) { + type.value = ''; + } + if (vpd) { + vpd.value = ''; + } + if (protectedObject) { + protectedObject.value = ''; + } + if (includeSupport) { + includeSupport.checked = false; + } + if (pageSize) { + pageSize.value = '50'; + } + applyFilters(); + search?.focus(); + }); + applyFilters(); +} + document.addEventListener('DOMContentLoaded', () => { initPersistentMenus(); renderMarkdownViews(); + initVpdTargetFilters(); const master = document.getElementById('userRoleMaster'); if (master) { master.addEventListener('change', filterUserRoleDetail); diff --git a/src/main/resources/templates/vpd-policies.html b/src/main/resources/templates/vpd-policies.html index 6b5f4fc..4d74eaa 100644 --- a/src/main/resources/templates/vpd-policies.html +++ b/src/main/resources/templates/vpd-policies.html @@ -46,6 +46,56 @@ +
+ + + + + + + +
+ 표시 0 + / 일치 0 + / 전체 0 +
+
@@ -59,9 +109,14 @@ - + - + - +
검증용 ORDS 경로
ADMIN.TABLE TABLE @@ -98,7 +153,7 @@ 검증 경로 미등록
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.