diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 8e738382..557662e9 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-07-14 +## [1.0.2] - 2026-07-27 ### Added @@ -21,6 +21,13 @@ - 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). +### Fixed + +- 관리자 환경설정의 쿠키 카테고리·차단 도메인 목록에서 항목이 여러 개일 때 화면 요소 식별자가 중복되던 문제를 바로잡았습니다. +- 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다. +- 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. +- 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. + ## [1.0.1] - 2026-07-16 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index e177dc81..0b6b3e4f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -140,7 +140,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au * 운영자가 카드에서 required 값을 변경하는 위젯/액션은 존재하지 않는다. */ it('카테고리 카드 영역에 Toggle/setState(required) 위젯이 없다', () => { - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).not.toContain('"name":"Toggle"'); @@ -149,7 +149,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('카테고리 카드 헤더에 「필수」/「선택」 배지가 required 값으로 분기 렌더된다', () => { - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).toContain('category_required_badge'); @@ -167,7 +167,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au */ it('Phase 1: cookie_categories 영역에 functional 분기 (아이콘 + scope + tools) 존재', () => { // 실제 카테고리 카드는 cookie_categories_rows iteration 노드에 위치 (subsection 헤더 별개) - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); // 1. 카테고리 아이콘 분기 (fa-sliders / emerald 색상) @@ -186,7 +186,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au // 법령 인용 (ePrivacy Art.5(3) / GDPR Art.6(1)(a) 등) 은 운영자 의사결정에 불필요하여 // 펼치기 영역에서 제거되었다. 정책 버전 snapshot 에 카테고리 본문이 영속되므로 // 감사 자료는 그쪽에서 확인 가능. - const rows = findById(root, 'cookie_categories_rows'); + const rows = findById(root, 'cookie_categories_rows_{{categoryIdx}}'); const serialized = JSON.stringify(rows); expect(serialized).not.toContain('cookie_categories.info.necessary.legal_basis'); @@ -197,7 +197,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('Phase 1: 도메인 차단 섹션의 카테고리 아이콘에 functional 분기 존재', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // functional 카테고리 분기 (sliders 아이콘 + emerald 색상) @@ -331,7 +331,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('blocked_domains_taginputs iteration 이 cookie_categories 에서 necessary 제외하고 렌더', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); expect(wrapper).toBeTruthy(); expect(wrapper?.iteration?.source).toBe( "(_local.form?.cookie_categories ?? []).filter(c => c?.key !== 'necessary')" @@ -340,7 +340,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('TagInput 가 카테고리별 도메인 입력으로 사용되며 creatable=true 자유 입력 허용', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // TagInput composite 사용 expect(serialized).toContain('"name":"TagInput"'); @@ -353,7 +353,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('TagInput change 이벤트가 $event.target.value 에서 string 배열을 추출하여 form.blocked_domains 정적 키로 setState', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 정적 setState 키 — form.blocked_domains 만 사용 (CRITICAL 규정 준수) expect(serialized).toContain('"form.blocked_domains"'); @@ -368,7 +368,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('카탈로그 도메인 일괄 추가 링크 미존재 — 신규 설치 시 카탈로그가 이미 기본값으로 채워져 있고, 카탈로그 갱신 빈도 낮음. 운영자가 의도적으로 제외한 도메인이 재추가되는 위험 회피 (검토 #9)', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 일괄 추가 링크 라벨/툴팁 키 사용 금지 (lang 키 자체 제거됨) expect(serialized).not.toContain('blocked_domains.add_catalog_link'); @@ -392,7 +392,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); it('422 검증 에러 시 카테고리 카드 외곽이 빨간 테두리로 강조 + 카드 내부에 첫 잘못된 도메인 에러 메시지 Span 노출 (검토 #10 옵션 B — 카테고리 내 어느 인덱스든 매칭)', () => { - const wrapper = findById(root, 'blocked_domains_taginputs'); + const wrapper = findById(root, 'blocked_domains_taginputs_{{categoryIdx}}'); const serialized = JSON.stringify(wrapper); // 카드 외곽 동적 className — 카테고리 내 *어느 인덱스든* 에러 키가 있으면 true (some) expect(serialized).toContain("Object.keys(_local.errors ?? {}).some(k => k.startsWith('blocked_domains.' + category.key + '.'))"); @@ -406,8 +406,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).toContain('"form.blocked_domains"'); }); - it('header_save_button onError 토스트가 422 응답의 첫 번째 검증 에러 메시지를 노출 + fallback 일반화 메시지 유지 (검토 #10 옵션 A 기반)', () => { - const saveBtn = findById(root, 'header_save_button'); + it('footer_save_button onError 토스트가 422 응답의 첫 번째 검증 에러 메시지를 노출 + fallback 일반화 메시지 유지 (검토 #10 옵션 A 기반)', () => { + const saveBtn = findById(root, 'footer_save_button'); const serialized = JSON.stringify(saveBtn); // 첫 번째 검증 에러 메시지 추출 표현식 expect(serialized).toContain('Object.values(error.errors ?? {})[0]'); @@ -551,8 +551,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(Object.keys(params ?? {})).not.toContain('policyGuideOpen'); }); - it('상단 저장 sequence 는 단순 save_success 토스트만 노출 — published_version 분기 / 정책 버전 refetch 제거 (수동 발행 모델)', () => { - const btn = findById(root, 'header_save_button'); + it('하단 저장 sequence 는 단순 save_success 토스트만 노출 — published_version 분기 / 정책 버전 refetch 제거 (수동 발행 모델)', () => { + const btn = findById(root, 'footer_save_button'); const serialized = JSON.stringify(btn); // 옛 자동 발행 흐름의 분기 / refetch 제거 — 저장은 정책 버전을 발행하지 않으므로 분기 불필요 expect(serialized).not.toContain('response?.data?.published_version'); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index e577651e..859aedef 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -92,14 +92,14 @@ "dataKey": "form", "trackChanges": true, "props": { - "className": "bg-gray-50 dark:bg-gray-900 min-h-screen" + "className": "admin-page-content-responsive-fluid" }, "children": [ { "id": "sticky_header", "type": "basic", "name": "Div", - "comment": "헤더 + 탭을 sticky 컨테이너로 묶음 (게시판 설정과 동일한 구조)", + "comment": "헤더 + 탭을 sticky 컨테이너로 묶음 (게시판 설정과 동일한 구조 — root 가 admin-page-content-responsive-fluid 로 좌우 패딩을 이미 갖고 있으므로 header_content 는 자체 좌우 패딩을 두지 않는다. 그래야 sticky_header 의 border-b 폭이 아래 카드 폭과 정확히 일치한다).", "props": { "className": "sticky-section-header" }, @@ -109,16 +109,14 @@ "type": "basic", "name": "Div", "props": { - "className": "px-4 sm:px-6 lg:px-8 pt-4 pb-0" + "className": "pt-4 pb-0" }, "children": [ { "id": "header_top_row", "type": "basic", "name": "Div", - "props": { - "className": "panel-header-row" - }, + "comment": "제목 wrapper — 게시판 설정(page_header_wrapper)과 동일하게 별도 className 없음. panel-header-row(border-b 포함) 를 쓰면 탭 위에 불필요한 구분선이 하나 더 생긴다.", "children": [ { "id": "page_header", @@ -130,163 +128,6 @@ "backPath": "/admin/plugins", "backLabel": "$t:admin.plugins.back_to_list" } - }, - { - "id": "header_save_buttons", - "type": "basic", - "name": "Div", - "props": { - "className": "flex-center gap-3 flex-shrink-0 pt-1" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "if": "{{_local.hasChanges}}", - "props": { - "className": "flex-center gap-1.5 text-sm text-amber-600 dark:text-amber-400" - }, - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "circle-dot", - "size": "xs" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-gdpr.settings.unsaved_changes" - } - ] - }, - { - "id": "header_cancel_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "btn btn-secondary text-sm" - }, - "actions": [ - { - "type": "click", - "handler": "navigate", - "params": { - "path": "/admin/plugins" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Span", - "text": "$t:common.cancel" - } - ] - }, - { - "id": "header_save_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "disabled": "{{!_local.hasChanges || _local.isSaving}}", - "className": "btn btn-primary flex-center gap-1.5 text-sm" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": true, - "errors": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-gdpr/admin/settings", - "params": { - "method": "PUT", - "body": "{{_local.form}}" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "hasChanges": false - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "gdprSettings" - } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "$t:sirsoft-gdpr.settings.save_success" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "errors": "{{error.errors}}" - } - }, - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{Object.values(error.errors ?? {})[0]?.[0] ?? '$t:sirsoft-gdpr.settings.save_error'}}" - } - } - ] - } - ] - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "if": "{{_local.isSaving}}", - "props": { - "name": "spinner", - "size": "sm", - "spin": true - } - }, - { - "type": "basic", - "name": "Span", - "if": "{{!_local.isSaving}}", - "text": "$t:common.save" - }, - { - "type": "basic", - "name": "Span", - "if": "{{_local.isSaving}}", - "text": "$t:common.saving" - } - ] - } - ] } ] }, @@ -294,7 +135,7 @@ "id": "settings_tab_navigation", "type": "composite", "name": "TabNavigationScroll", - "comment": "상단 탭 스크롤 — 클릭 시 해당 카드로 자동 스크롤 + 스크롤 시 활성 탭 자동 갱신", + "comment": "상단 탭 스크롤 — settings_main_content(p-4 sm:p-6 lg:p-8) 안 카드들과 좌우 정렬을 맞추기 위해 header_content 패딩을 그대로 물려받는다(자체 좌우 패딩 없음). 클릭 시 해당 카드로 자동 스크롤 + 스크롤 시 활성 탭 자동 갱신.", "props": { "tabs": [ { @@ -326,9 +167,9 @@ "id": "settings_main_content", "type": "basic", "name": "Div", - "comment": "5 카드 컨테이너 (transition_overlay 타겟)", + "comment": "5 카드 컨테이너 (transition_overlay 타겟). root(admin-page-content-responsive-fluid) 가 좌우 패딩을 이미 가지므로 여기서는 세로 여백만 둔다. 상단 pt-6 은 게시판 설정(.tab-content — sticky 탭과 콘텐츠 사이 표준 24px)과 동일 값.", "props": { - "className": "p-4 sm:p-6 lg:p-8" + "className": "pt-6 pb-6" }, "children": [ { @@ -411,7 +252,7 @@ "type": "basic", "name": "Div", "props": { - "className": "flex-start justify-between gap-4 mb-3" + "className": "panel-header-row" }, "children": [ { @@ -1364,7 +1205,7 @@ ] }, { - "id": "cookie_categories_rows", + "id": "cookie_categories_rows_{{categoryIdx}}", "type": "basic", "name": "Div", "comment": "카드 구조: 헤더(아이콘+이름+key+필수/선택 배지) + 운영 정보 펼침. 카드는 정보 패널이며, 카테고리·필수 여부는 CookieCategory enum 이 SSoT.", @@ -1488,7 +1329,7 @@ "name": "Span", "if": "{{category?.required === true}}", "props": { - "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800text-danger-soft" + "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-danger-soft" }, "text": "$t:sirsoft-gdpr.settings.fields.cookie_categories.category_required_badge" }, @@ -1832,7 +1673,7 @@ ] }, { - "id": "blocked_domains_taginputs", + "id": "blocked_domains_taginputs_{{categoryIdx}}", "type": "basic", "name": "Div", "comment": "카테고리별 TagInput iteration — necessary 제외. options 에 카탈로그 도메인을 추천 노출 (creatable=true 로 자유 입력 가능)", @@ -1944,6 +1785,164 @@ ] } ] + }, + { + "id": "footer_buttons", + "type": "basic", + "name": "Div", + "comment": "하단 sticky 저장/취소 바 — 게시판 설정(admin_board_settings.json footer_buttons)과 동일 패턴으로 통일 (상단 헤더 배치는 스크롤 시 밀리는 것으로 오인되어 재배치).", + "props": { + "className": "sticky-footer-buttons" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "if": "{{_local.hasChanges}}", + "props": { + "className": "flex-center gap-1.5 text-sm text-amber-600 dark:text-amber-400 mr-auto" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "circle-dot", + "size": "xs" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-gdpr.settings.unsaved_changes" + } + ] + }, + { + "id": "footer_cancel_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary text-sm" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "id": "footer_save_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!_local.hasChanges || _local.isSaving}}", + "className": "btn btn-primary flex-center gap-1.5 text-sm" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": true, + "errors": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-gdpr/admin/settings", + "params": { + "method": "PUT", + "body": "{{_local.form}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "hasChanges": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "gdprSettings" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-gdpr.settings.save_success" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "errors": "{{error.errors}}" + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{Object.values(error.errors ?? {})[0]?.[0] ?? '$t:sirsoft-gdpr.settings.save_error'}}" + } + } + ] + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{_local.isSaving}}", + "props": { + "name": "spinner", + "size": "sm", + "spin": true + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{!_local.isSaving}}", + "text": "$t:common.save" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.isSaving}}", + "text": "$t:common.saving" + } + ] + } + ] } ] }