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:
@@ -10,6 +10,7 @@
|
||||
|
||||
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
|
||||
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+78
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+115
-99
@@ -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
|
||||
|
||||
|
||||
+78
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+9
@@ -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'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user