feat(sirsoft-board,sirsoft-ecommerce,message_bizppurio): 게시판·이커머스 알림 설정에 카카오 알림톡 연동 UI 추가

코어 알림 설정 화면에만 있던 비즈뿌리오 카카오 알림톡 템플릿 연결 UI(연결 버튼,
검수모드 배너, 안내박스)가 게시판·이커머스 알림 설정 화면에는 빠져 있었다.
Phase 6 재설계 시 "후속 작업"으로 남겨두고 실제 반영이 되지 않았던 부분이다.

- 게시판·이커머스 알림 정의 행에 코어와 동일한 notification_definition_row_footer
 확장 슬롯을 추가하고, 게시판 레이아웃에는 코어/이커머스와 동일한 채널 콘텐츠
 wrapper id를 신설해 구조를 맞췄다.
- 비즈뿌리오 플러그인에 게시판·이커머스 전용 overlay 확장 파일을 추가해 배너·
 안내박스·연결모달·데이터소스를 각 레이아웃에 주입한다. 백엔드는 notification_type을
 확장 구분 없는 문자열 키로 이미 3영역 공용 처리하고 있어 프론트 진입점만 보완했다.
- 회귀 테스트: 이커머스 화면에서 최초 구현 시 발견된 위치 버그(연결 버튼이 변수
 뱃지 아래가 아니라 토글/편집 버튼 옆으로 붙던 문제)를 fail 재현 후 수정,
 같은 회귀를 잡는 구조 검증 테스트를 게시판·이커머스 양쪽에 추가했다.
This commit is contained in:
chym1217
2026-07-28 12:40:22 +09:00
parent 49edacf5e0
commit 40d3ce9639
10 changed files with 1416 additions and 100 deletions
@@ -10,6 +10,7 @@
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
### Changed
@@ -0,0 +1,78 @@
// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는
// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28)
/**
* 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28)
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
* extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야
* 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json';
type AnyNode = Record<string, unknown> & {
id?: string;
type?: string;
name?: string;
props?: { className?: string };
children?: AnyNode[];
};
function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] {
if (!node) return results;
if (pred(node)) results.push(node);
if (Array.isArray(node.children)) {
for (const c of node.children) findAll(c, pred, results);
}
return results;
}
describe('게시판 알림 행 footer extension_point 위치 (#28)', () => {
it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => {
const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0');
expect(infoColumns.length).toBeGreaterThan(0);
const infoColumnHasEp = infoColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'),
);
expect(infoColumnHasEp).toBe(true);
});
it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => {
const buttonColumns = findAll(
tab as AnyNode,
(n) => n.props?.className === 'flex-center gap-3 flex-shrink-0',
);
expect(buttonColumns.length).toBeGreaterThan(0);
const buttonColumnHasEp = buttonColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point'),
);
expect(buttonColumnHasEp).toBe(false);
});
it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => {
const rowWrappers = findAll(
tab as AnyNode,
(n) =>
Array.isArray(n.children) &&
n.children.some((c) => c.props?.className === 'flex-1 min-w-0') &&
n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'),
);
expect(rowWrappers.length).toBeGreaterThan(0);
for (const wrapper of rowWrappers) {
const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point');
expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false);
}
});
});
@@ -196,26 +196,16 @@
]
},
{
"id": "board_notif_list_info",
"id": "board_notif_channel_content",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between py-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}"
},
{
"id": "board_notif_list_info",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
"className": "flex-between py-3"
},
"children": [
{
@@ -224,55 +214,70 @@
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label"
"text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}"
},
{
"type": "composite",
"name": "Select",
"type": "basic",
"name": "Div",
"props": {
"name": "notifPerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{
"value": "10",
"label": "10"
},
{
"value": "20",
"label": "20"
},
{
"value": "50",
"label": "50"
}
]
"className": "flex-center gap-2"
},
"actions": [
"children": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label"
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "notifPerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{
"value": "10",
"label": "10"
},
{
"value": "20",
"label": "20"
},
{
"value": "50",
"label": "50"
}
]
},
"actions": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
}
}
}
}
]
}
]
}
]
}
]
},
{
"id": "board_notif_definitions_list",
"type": "basic",
"name": "Div",
"children": [
},
{
"id": "board_notif_definitions_list",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
@@ -493,6 +498,15 @@
]
}
]
},
{
"comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.",
"type": "extension_point",
"name": "notification_definition_row_footer",
"props": {
"definition": "{{def}}",
"activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}"
}
}
]
},
@@ -657,70 +671,72 @@
}
]
},
{
"id": "board_notif_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}",
"props": {
"className": "admin-card"
},
"children": [
{
"id": "board_notif_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}",
"props": {
"className": "row-stack"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Div",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
"className": "row-stack"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message"
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message"
}
]
}
]
}
]
},
{
"id": "board_notif_pagination",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center"
},
"children": [
},
{
"type": "composite",
"name": "Pagination",
"id": "board_notif_pagination",
"type": "basic",
"name": "Div",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
"className": "flex justify-center"
},
"actions": [
"children": [
{
"event": "onPageChange",
"type": "action",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
"type": "composite",
"name": "Pagination",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
},
"actions": [
{
"event": "onPageChange",
"type": "action",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
}
}
}
]
}
]
}
@@ -10,6 +10,7 @@
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
### Changed
@@ -0,0 +1,78 @@
// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는
// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28)
/**
* 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28)
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
* extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야
* 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json';
type AnyNode = Record<string, unknown> & {
id?: string;
type?: string;
name?: string;
props?: { className?: string };
children?: AnyNode[];
};
function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] {
if (!node) return results;
if (pred(node)) results.push(node);
if (Array.isArray(node.children)) {
for (const c of node.children) findAll(c, pred, results);
}
return results;
}
describe('이커머스 알림 행 footer extension_point 위치 (#28)', () => {
it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => {
const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0');
expect(infoColumns.length).toBeGreaterThan(0);
const infoColumnHasEp = infoColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'),
);
expect(infoColumnHasEp).toBe(true);
});
it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => {
const buttonColumns = findAll(
tab as AnyNode,
(n) => n.props?.className === 'flex-center gap-3 flex-shrink-0',
);
expect(buttonColumns.length).toBeGreaterThan(0);
const buttonColumnHasEp = buttonColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point'),
);
expect(buttonColumnHasEp).toBe(false);
});
it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => {
const rowWrappers = findAll(
tab as AnyNode,
(n) =>
Array.isArray(n.children) &&
n.children.some((c) => c.props?.className === 'flex-1 min-w-0') &&
n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'),
);
expect(rowWrappers.length).toBeGreaterThan(0);
for (const wrapper of rowWrappers) {
const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point');
expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false);
}
});
});
@@ -498,6 +498,15 @@
]
}
]
},
{
"comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.",
"type": "extension_point",
"name": "notification_definition_row_footer",
"props": {
"definition": "{{def}}",
"activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}"
}
}
]
},