From 17a95bb5566f32f9e8239ab3a06cd120a841e9f8 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 20 Jul 2026 14:42:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=86=A1=20=EC=97=B0=EB=8F=99=20=ED=96=89=20=EC=8A=AC=EB=A1=AF?= =?UTF-8?q?=C2=B7=EC=97=B0=EA=B2=B0=20=EB=AA=A8=EB=8B=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림 설정 알림톡 탭에서 각 알림에 카카오 알림톡 템플릿을 연결하는 UI. 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). - 각 알림 행 하단에 범용 확장 슬롯(notification_definition_row_footer) — 연결 상태 줄 + [연결/변경] 버튼. 코어 편집 모달 내부 주입 불가(엔진 modals 미탐색) 회피. - 우리 전용 연결 모달(modals 병합) — 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API(코어 저장 무관). openModal 을 승인 조회보다 먼저 실행. - 상태 배너(readiness 미충족/테스트 모드, 문제 있을 때만) + 알림톡 탭 안내 박스. - SMS 대체발송 안내(실패 시 이 알림 본문이 SMS로 발송) + 다크 모드 solid 색. - 카카오 API 422 는 errorHandling.suppress 로 조용히 처리(전 탭 에러 토스트 제거). Vitest 17 통과, audit 위반 0, Chrome MCP 다크·저장 검수 통과. --- .../extensions/notification_row_footer.json | 101 +++++ .../extensions/notification_tab_core.json | 382 +++++++++++------- .../layouts/notification_tab_binding.test.tsx | 212 ++++++---- .../resources/lang/en.json | 10 +- .../resources/lang/ko.json | 10 +- .../scenarios/alimtalk-dispatch-binding.yaml | 25 +- .../_tab_notification_definitions.json | 9 + 7 files changed, 506 insertions(+), 243 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json new file mode 100644 index 00000000..d873dc8c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -0,0 +1,101 @@ +{ + "extension_point": "notification_definition_row_footer", + "comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.", + "components": [ + { + "id": "bizppurio_row_binding", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}", + "props": { + "className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2 min-w-0" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-link", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "comment": "연결됨: 템플릿명(또는 코드) · SMS 대체 표시 / 미연결: '미연결'", + "type": "basic", + "name": "Span", + "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200 truncate" + }, + "text": "{{(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code) + (bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ? ' · SMS' : '')}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}", + "props": { + "className": "text-sm text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-message_bizppurio.binding.unbound" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0" + }, + "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal": { + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}", + "template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}", + "fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_binding" + }, + { + "comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioApprovedTemplates" + } + } + ] + } + } + ] + } + ] + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index c8ced647..e5d0b3d0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -1,13 +1,14 @@ { "target_layout": "admin_settings", - "comment": "알림 템플릿 편집 모달(alimtalk 채널)에 비즈뿌리오 전용 칸을 append 한다(계획서 §6-2, Phase 6). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다(코어 템플릿 무오염). channel==='alimtalk'일 때만 ①안내 카드 ②연결 템플릿 드롭다운 ③SMS 대체 토글을 노출하며, 값을 바꾸면 즉시 우리 API 로 저장하고 toast 로 피드백한다(별도 저장 버튼 없음 — PO 확정 UX). 코어 [저장] 버튼은 본문·수신자만 저장하고 연동과 무관하다. 초기값·저장 후 상태는 bizppurioBindings 데이터소스에서 편집 대상 알림 type 으로 읽는다.", + "comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.", "data_sources": [ { "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", "type": "api", "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", "method": "GET", - "auto_fetch": true, + "auto_fetch": false, "auth_required": true, "fallback": { "data": { @@ -17,11 +18,17 @@ }, { "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 빈 목록 사용, 안내는 배너·'승인 템플릿 없음' 문구가 담당). 이 토스트를 막지 않으면 모달 열 때마다 에러 토스트가 뜬다.", "type": "api", "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", "method": "GET", - "auto_fetch": true, + "auto_fetch": false, "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, "fallback": { "data": { "templates": [] @@ -33,7 +40,7 @@ { "target_id": "notif_channel_content", "position": "prepend_child", - "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음, PO Q1-A). readiness 미충족 🔴 설정하기 안내 / is_test_mode 🟡 테스트 모드 안내. 코어 목록·모달은 그대로.", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", "components": [ { "id": "bizppurio_status_banner", @@ -41,7 +48,7 @@ "name": "Div", "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", "props": { - "className": "mb-3" + "className": "mt-4 mb-3" }, "children": [ { @@ -128,28 +135,65 @@ ] } ] - } - ] - }, - { - "target_id": "template_variables_info", - "position": "append", - "components": [ + }, { - "id": "bizppurio_alimtalk_section", - "comment": "알림톡 전용 칸 — channel==='alimtalk' 일 때만. 변경 즉시 우리 API 로 저장.", + "id": "bizppurio_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", "type": "basic", "name": "Div", - "if": "{{_global.notification_template_form_modal?.template?.channel === 'alimtalk'}}", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", "props": { - "className": "bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-4 space-y-3" + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" }, "children": [ { "type": "basic", "name": "Div", "props": { - "className": "flex-start gap-2" + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" }, "children": [ { @@ -158,35 +202,20 @@ "props": { "name": "fas fa-circle-info", "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5" + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" } }, { "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "H4", - "props": { - "className": "text-sm font-medium text-teal-800 dark:text-teal-200" - }, - "text": "$t:sirsoft-message_bizppurio.binding.section_title" - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-teal-700 dark:text-teal-300 mt-0.5" - }, - "text": "$t:sirsoft-message_bizppurio.binding.section_hint" - } - ] + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" } ] }, { - "id": "bizppurio_binding_template_select", "type": "basic", "name": "Div", "children": [ @@ -202,64 +231,19 @@ "type": "composite", "name": "Select", "props": { - "value": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", "className": "w-full text-sm", "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" }, "actions": [ { "type": "change", - "handler": "sequence", + "handler": "setState", "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "notification_template_form_modal.bizppurio.template_code": "{{$event.target.value}}", - "notification_template_form_modal.bizppurio.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "notification_template_form_modal.bizppurio.fallback_sms": "{{$event.target.value === '' ? false : (_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", - "params": { - "method": "POST", - "body": { - "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", - "template_code": "{{$event.target.value}}", - "template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioBindings" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", - "type": "error" - } - } - ] - } - ] + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" } } ] @@ -276,7 +260,6 @@ ] }, { - "id": "bizppurio_binding_fallback_toggle", "type": "basic", "name": "Div", "props": { @@ -287,61 +270,16 @@ "type": "composite", "name": "Toggle", "props": { - "checked": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false)}}", - "disabled": "{{(_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')) === ''}}" + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" }, "actions": [ { "type": "change", - "handler": "sequence", + "handler": "setState", "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "notification_template_form_modal.bizppurio.fallback_sms": "{{!(_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", - "params": { - "method": "POST", - "body": { - "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", - "template_code": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", - "template_name": "{{_global.notification_template_form_modal?.bizppurio?.template_name ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_name ?? '')}}", - "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioBindings" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", - "type": "error" - } - } - ] - } - ] + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" } } ] @@ -357,17 +295,177 @@ ] }, { + "id": "bizppurio_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", "type": "basic", "name": "P", "props": { - "className": "text-xs text-teal-700 dark:text-teal-300" + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] } ] } ] } ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], "priority": 320 } \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index 583baf7f..d179f33a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -1,105 +1,72 @@ /** - * 알림 설정 알림톡 연동 overlay 구조 검증 (Phase 6 재설계, §6-2) + * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2) * - * notification_tab_core.json 은 코어 admin_settings 편집 모달(alimtalk 채널)에 전용 칸을 얹고, - * 알림톡·SMS 탭 상단에 상태 배너를 얹는다. 코어 편집 모달·저장 버튼은 건드리지 않으며(무오염), - * 전용 칸 값을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음). + * 두 확장 파일로 분리 구현: + * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals) + * + data_sources. target_layout=admin_settings. + * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯 + * (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼. * - * 검증: - * - 데이터소스(bizppurioBindings·bizppurioApprovedTemplates) 주입 - * - 전용 칸: 안내 카드 · 연결 템플릿 드롭다운(즉시 저장) · SMS 대체 토글(연결 없으면 비활성) - * - 상태 배너: readiness 미충족(설정하기) · 테스트 모드 - * - 코어 편집 모달 저장 body 에 bizppurio 필드가 없다(무오염) - * - i18n 키 정합(ko·en 동일) + * 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용 + * 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는 + * errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당). + * + * 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선, + * 무오염(코어 저장 body 미개입), i18n 정합. */ import { describe, it, expect } from 'vitest'; import overlay from '../../../extensions/notification_tab_core.json'; +import footer from '../../../extensions/notification_row_footer.json'; import ko from '../../../lang/ko.json'; import en from '../../../lang/en.json'; import { findById, type AnyNode } from './helpers'; +const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; +const bannerRoot = { + children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []), +} as AnyNode; +const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; + /** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */ const collectPluginKeys = (json: unknown): string[] => { const text = JSON.stringify(json); const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; - const keys = [ + return Array.from(new Set([ ...prefixed.map((m) => m.replace('$t:', '')), ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), - ]; - return Array.from(new Set(keys)); + ])); }; -type Overlay = { - target_layout: string; - data_sources?: Array<{ id: string; endpoint: string }>; - injections: Array<{ target_id: string; position: string; components: AnyNode[] }>; -}; - -const ext = overlay as unknown as Overlay; - -/** injection components 를 하나의 검색 가능한 루트로 감싼다. */ -const injectionRoot = (targetId: string): AnyNode => { - const inj = ext.injections.find((i) => i.target_id === targetId); - return { children: inj?.components ?? [] } as AnyNode; -}; - -describe('notification binding overlay — 대상·데이터소스', () => { - it('코어 admin_settings 레이아웃을 대상으로 한다', () => { - expect(ext.target_layout).toBe('admin_settings'); +describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => { + it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => { + expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); + expect((overlay as Record).extension_point).toBeUndefined(); }); - it('연동 맵·승인 템플릿 데이터소스를 주입한다', () => { - const ids = (ext.data_sources ?? []).map((d) => d.id); + it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => { + expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer'); + expect((footer as Record).target_layout).toBeUndefined(); + }); + + it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => { + const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); expect(ids).toContain('bizppurioBindings'); expect(ids).toContain('bizppurioApprovedTemplates'); }); - it('전용 칸은 코어 편집 모달의 template_variables_info 뒤에 append 된다', () => { - const inj = ext.injections.find((i) => i.target_id === 'template_variables_info'); - expect(inj).toBeTruthy(); - expect(inj?.position).toBe('append'); + it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => { + const ds = ((overlay as { data_sources?: Array> }).data_sources ?? []) + .find((d) => d.id === 'bizppurioApprovedTemplates'); + expect(ds?.auto_fetch).toBe(false); + expect(JSON.stringify(ds?.errorHandling)).toContain('suppress'); }); }); -describe('notification binding overlay — 전용 칸', () => { - const root = injectionRoot('template_variables_info'); - - it('알림톡 전용 섹션은 channel === alimtalk 일 때만 노출된다', () => { - const section = findById(root, 'bizppurio_alimtalk_section'); - expect(section).toBeTruthy(); - expect((section as { if?: string }).if).toContain("=== 'alimtalk'"); - }); - - it('연결 템플릿 드롭다운을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음)', () => { - const raw = JSON.stringify(findById(root, 'bizppurio_binding_template_select')); - expect(raw).toContain('apiCall'); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('binding.saved'); - }); - - it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이고, 변경 시 즉시 저장한다', () => { - const toggleWrap = findById(root, 'bizppurio_binding_fallback_toggle'); - const raw = JSON.stringify(toggleWrap); - expect(raw).toContain('"disabled"'); - expect(raw).toContain("=== ''"); - expect(raw).toContain('apiCall'); - expect(raw).toContain('"method":"POST"'); - }); - - it('저장 실패 시 에러 토스트를 띄운다', () => { - const raw = JSON.stringify(root); - expect(raw).toContain('binding.save_error'); - }); -}); - -describe('notification binding overlay — 상태 배너', () => { - const root = injectionRoot('notif_channel_content'); - - it('배너는 sms·alimtalk 탭에서 문제가 있을 때만 노출된다', () => { - const banner = findById(root, 'bizppurio_status_banner'); +describe('binding UI — 상태 배너 + 안내 박스', () => { + it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { + const banner = findById(bannerRoot, 'bizppurio_status_banner'); expect(banner).toBeTruthy(); const cond = (banner as { if?: string }).if ?? ''; expect(cond).toContain("'sms'"); @@ -109,34 +76,101 @@ describe('notification binding overlay — 상태 배너', () => { }); it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { - const raw = JSON.stringify(findById(root, 'bizppurio_banner_not_ready')); + const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); expect(raw).toContain('banner.not_ready'); expect(raw).toContain('banner.setup_action'); expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); }); - it('테스트 모드 배너는 readiness 충족 + is_test_mode 일 때만 노출된다', () => { - const testBanner = findById(root, 'bizppurio_banner_test_mode'); - const cond = (testBanner as { if?: string }).if ?? ''; - expect(cond).toContain('readiness?.ready !== false'); - expect(cond).toContain('is_test_mode === true'); - expect(JSON.stringify(testBanner)).toContain('banner.test_mode'); + it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => { + const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); + expect(guide).toBeTruthy(); + expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); + expect(JSON.stringify(guide)).toContain('binding.list_guide'); }); }); -describe('notification binding overlay — i18n 정합', () => { - it('overlay 가 참조하는 모든 키가 ko·en 에 존재한다', () => { - const keys = collectPluginKeys(overlay); - expect(keys.length).toBeGreaterThan(0); +describe('binding UI — 행 연결(extension_point)', () => { + it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => { + const row = findById(footerRoot, 'bizppurio_row_binding'); + expect(row).toBeTruthy(); + expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'"); + }); - // 플러그인 resources/lang/{ko,en}.json 파일 루트가 곧 sirsoft-message_bizppurio 네임스페이스. - // 키 `sirsoft-message_bizppurio.binding.saved` → 첫 세그먼트(네임스페이스) 제거 후 파일 루트에서 해석. + it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => { + const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); + expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]'); + expect(raw).toContain('binding.unbound'); + expect(raw).toContain('binding.btn_connect'); + expect(raw).toContain('binding.btn_change'); + }); + + it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => { + const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); + expect(raw).toContain('bizppurio_binding_modal'); + expect(raw).toContain('"openModal"'); + expect(raw).toContain('modal_bizppurio_binding'); + }); + + it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => { + const row = findById(footerRoot, 'bizppurio_row_binding'); + const raw = JSON.stringify(row); + const openIdx = raw.indexOf('"openModal"'); + const refetchIdx = raw.indexOf('bizppurioApprovedTemplates'); + expect(openIdx).toBeGreaterThan(-1); + expect(refetchIdx).toBeGreaterThan(-1); + expect(openIdx).toBeLessThan(refetchIdx); + }); +}); + +describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => { + const modal = findById(overlayRoot, 'modal_bizppurio_binding'); + + it('연결 전용 모달이 modals 로 등록된다', () => { + expect(modal).toBeTruthy(); + expect((modal as { name?: string }).name).toBe('Modal'); + }); + + it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('binding.section_hint'); + expect(raw).toContain('binding.connected_template'); + expect(raw).toContain('binding.fallback_sms'); + expect(raw).toContain('binding.variables_hint'); + }); + + it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => { + const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body')); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + }); + + it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + expect(raw).toContain('binding.save_error'); + expect(raw).toContain('"closeModal"'); + expect(raw).toContain('bizppurioBindings'); + }); +}); + +describe('binding UI — 코어 무오염 + i18n', () => { + it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + const all = JSON.stringify(overlay) + JSON.stringify(footer); + expect(all).not.toContain('/api/admin/notification-templates/'); + expect(all).not.toContain('notification_template_form_modal'); + }); + + it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { + const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)]; + expect(keys.length).toBeGreaterThan(0); const resolve = (root: unknown, path: string): unknown => path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); - - for (const key of keys) { + for (const key of Array.from(new Set(keys))) { expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); } }); -}); \ No newline at end of file +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index d6a7f6e9..25622ee0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -64,12 +64,18 @@ }, "binding": { "section_title": "KakaoTalk Alimtalk Binding", - "section_hint": "Changes are saved instantly, separately from the message body.", + "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.", + "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", + "modal_title": "Alimtalk Binding · {name}", + "unbound": "Not connected", + "btn_connect": "Connect", + "btn_change": "Change", "connected_template": "Connected Template", "none": "Not connected", "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", "fallback_sms": "Fall back to SMS on failure", - "variables_hint": "Available variables are auto-substituted on send.", + "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", + "variables_hint": "Available variables (auto-substituted on send)", "saved": "Alimtalk binding saved.", "save_error": "Failed to save alimtalk binding." }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 024fd0ed..e38085a6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -64,12 +64,18 @@ }, "binding": { "section_title": "카카오 알림톡 연동", - "section_hint": "선택하면 즉시 저장됩니다. 본문 저장과 별개로 동작합니다.", + "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.", + "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", + "modal_title": "알림톡 연결 · {name}", + "unbound": "미연결", + "btn_connect": "연결", + "btn_change": "연결 변경", "connected_template": "연결 템플릿", "none": "연결 안 함", "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", "fallback_sms": "실패 시 SMS로 대체발송", - "variables_hint": "제공 변수는 발송 시 자동 치환됩니다.", + "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", + "variables_hint": "제공 변수 (발송 시 자동 치환)", "saved": "알림톡 연동을 저장했습니다.", "save_error": "알림톡 연동 저장에 실패했습니다." }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml index ff224438..9d071d93 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -27,9 +27,13 @@ description: | - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. - 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음). - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. - - 전용 칸(overlay): channel==='alimtalk' 일 때만 안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 - 토글. 변경 즉시 우리 API(store) 저장 + toast. 승인(RDY/ACT) 템플릿만 드롭다운 노출. - - 상태 배너(overlay): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡테스트모드. + - 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에 + 연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만. + - 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿 + 드롭다운(승인 RDY/ACT만) + SMS 대체 토글 + 변수 안내 + [취소][저장]. [저장]→우리 API(store) + + toast + 모달 닫힘 + 목록 갱신. 카카오 API 422 는 errorHandling.suppress 로 조용히 처리. + - 상태 배너(overlay injections): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡. + - 안내 박스(overlay): 알림톡 탭 상시 안내(무엇을 하는 화면인지). axes: channel: [alimtalk] @@ -75,15 +79,20 @@ effects: - bindings_map_returned_for_prefill - view_permission_required_for_read - manage_permission_required_for_store - # 전용 칸·배너 (프론트 — Vitest 구조/i18n) - - alimtalk_section_shown_only_for_alimtalk_channel - - template_select_saves_immediately_via_our_api + # 행 연결 UI·연결 모달·배너 (프론트 — Vitest 구조/i18n) + - row_binding_shown_only_for_alimtalk_channel + - row_shows_bound_or_unbound_status_from_bindings + - connect_button_seeds_modal_and_opens_our_modal + - openmodal_runs_before_approved_refetch + - our_binding_modal_registered_via_modals - fallback_toggle_disabled_when_no_template - - save_error_shows_toast + - save_posts_to_our_api_then_toast_close_refresh + - approved_templates_422_suppressed - banner_shown_only_on_sms_alimtalk_tab_when_problem - not_ready_banner_has_setup_action - test_mode_banner_shown_when_ready_and_test_mode - - core_edit_modal_save_body_has_no_bizppurio_fields + - alimtalk_tab_guide_box_shown + - core_edit_modal_untouched_no_bizppurio_fields # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) - e2e_open_alimtalk_edit_shows_section - e2e_select_template_saves_and_toasts diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json index e9355a2d..9b77bf77 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json @@ -500,6 +500,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] },