비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내 작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다. 발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지. - 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체 - 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request - 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결 - SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송 - 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면 - 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단 - admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원 - 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정, PHPUnit 421 / Vitest 228 green, audit 0 error
118 lines
4.6 KiB
TypeScript
118 lines
4.6 KiB
TypeScript
/**
|
|
* insertVariable 핸들러 (#597 §4.2)
|
|
*
|
|
* 알림톡 템플릿 작성 모달·SMS 본문 모달에서 "사용 가능 변수" 칩을 클릭하면, 대상 본문의
|
|
* 현재 커서 위치에 `#{변수}` 를 삽입한다. 값 자체는 G7 상태를 SSoT 로 삼아 갱신한다
|
|
* (DOM 값을 직접 쓰지 않으므로 다음 렌더에서 상태값으로 덮여 사라지는 충돌이 없다).
|
|
*
|
|
* 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.
|
|
*
|
|
* 레이아웃 JSON 사용 예:
|
|
* {
|
|
* "handler": "sirsoft-message_bizppurio.insertVariable",
|
|
* "params": {
|
|
* "variable": "name",
|
|
* "target": "bz_template_content",
|
|
* "stateTarget": "global",
|
|
* "statePath": "bz_tpl_modal.content.templateContent"
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
import type { ActionContext, ActionWithParams } from '../types';
|
|
|
|
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {
|
|
log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),
|
|
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),
|
|
error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),
|
|
};
|
|
|
|
/** 삽입 대상 본문 필드(name) 기본값 */
|
|
const DEFAULT_TARGET = 'bz_template_content';
|
|
|
|
/** 상태 경로 기본값 */
|
|
const DEFAULT_STATE_PATH = 'bz_tpl_modal.content.templateContent';
|
|
|
|
/**
|
|
* 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.
|
|
*
|
|
* @param action 액션 객체(params.variable/target/stateTarget/statePath)
|
|
* @param context 액션 컨텍스트(state 스냅샷)
|
|
*/
|
|
export function insertVariableHandler(
|
|
action: ActionWithParams,
|
|
context: ActionContext,
|
|
): void {
|
|
const G7Core = (window as any).G7Core;
|
|
const params = action.params ?? {};
|
|
const variable = String(params.variable ?? '').trim();
|
|
const target = String(params.target ?? DEFAULT_TARGET);
|
|
const stateTarget = String(params.stateTarget ?? 'global');
|
|
const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);
|
|
|
|
if (variable === '') {
|
|
logger.warn('삽입할 변수명이 지정되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
const setState = stateTarget === 'local'
|
|
? G7Core?.state?.setLocal
|
|
: (G7Core?.state?.setGlobal ?? G7Core?.state?.set);
|
|
const getState = stateTarget === 'local'
|
|
? G7Core?.state?.getLocal
|
|
: (G7Core?.state?.getGlobal ?? G7Core?.state?.get);
|
|
|
|
if (!setState || !getState) {
|
|
logger.error('G7Core.state API 를 사용할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
// 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getState.
|
|
const stateSnapshot = stateTarget === 'local'
|
|
? ((context as any)?.state ?? getState() ?? {})
|
|
: (getState() ?? {});
|
|
const current = String(readPath(stateSnapshot, statePath) ?? '');
|
|
|
|
const token = `#{${variable}}`;
|
|
|
|
// 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.
|
|
const field = document.querySelector<HTMLTextAreaElement | HTMLInputElement>(
|
|
`textarea[name="${target}"], input[name="${target}"]`,
|
|
);
|
|
const domValue = field?.value ?? '';
|
|
// DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
|
|
const inSync = domValue === current;
|
|
const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
|
|
const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
|
|
|
|
const next = current.slice(0, start) + token + current.slice(end);
|
|
|
|
setState({ [statePath]: next });
|
|
|
|
// 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).
|
|
if (field) {
|
|
const caret = start + token.length;
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
field.focus();
|
|
field.setSelectionRange(caret, caret);
|
|
} catch {
|
|
// setSelectionRange 미지원 input — 무시
|
|
}
|
|
});
|
|
}
|
|
|
|
logger.log(`변수 삽입: ${token} → ${stateTarget}:${statePath}`);
|
|
}
|
|
|
|
/**
|
|
* dot notation 경로로 객체에서 값을 읽습니다. (예: "bz_tpl_modal.content.templateContent")
|
|
*
|
|
* @param obj 대상 객체
|
|
* @param path dot 경로
|
|
* @return 경로 값(없으면 undefined)
|
|
*/
|
|
function readPath(obj: Record<string, any>, path: string): unknown {
|
|
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
|
}
|