fix(board,admin_basic): 반응 카운트 정합성·접근성 결함 수정 + 일괄적용 화면 UX 정리

빠른 반응 전환 시 캐시된 reaction_counts 가 실제 board_reactions 행 수와
어긋나던 결함을 Post 락 직렬화 + 실제 COUNT 재집계 방식으로 수정. 반응
버튼에 aria-pressed/aria-disabled 를 추가해 스크린리더가 상태를 인지하도록
보완. 신규 게시판 생성 폼의 반응 유형 체크박스 초기값 누락, 본인 글 반응
클릭 시 안내 토스트 부재도 함께 수정.

게시판 설정 일괄 적용 화면의 대상 게시판 선택을 카드형으로 개선하고
"반응 사용" 필드 번역 키 누락(ko/en/ja)을 수정. TagInput 컴포넌트가
드롭다운 메뉴 포탈에 react-select 기본 z-index(1)를 그대로 둬 sticky
하단 버튼바에 가려지던 결함을 styles.menuPortal 오버라이드로 수정.
This commit is contained in:
chym1217
2026-08-07 17:27:44 +09:00
parent 6490b339dd
commit 442474817d
34 changed files with 508 additions and 322 deletions
@@ -9,9 +9,11 @@
### Added
- 게시글 반응(추천/비추천) 기능의 안내·검증·활동 로그·관리자 설정 문구 일본어 번역 추가 — 반응 등록/변경/취소 안내, 반응 비활성화·본인 글·비활성 유형·열람 권한 없는 비밀글 차단 안내, 반응 사용 토글·유형 선택 설정 라벨이 일본어 로케일에서 자연스럽게 표시됩니다.
- 게시판 환경설정 일괄 적용 화면의 "반응 사용" 항목 라벨 일본어 번역 추가.
- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가.
- 게시판 대시보드 조회 안내 문구 일본어 번역 추가.
- 관리자 게시글 작성·수정 화면의 첨부 안내 문구 일본어 번역 추가 — 게시판에 설정된 첨부 개수·용량이 일본어 로케일에서도 그대로 표시됩니다.
- 게시판 환경설정 일괄 적용 화면의 "선택한 게시판" 안내 문구 일본어 번역 추가.
- 게시판 환경설정의 조회·저장·일괄적용·캐시 초기화 안내 문구 일본어 번역 추가 (`messages.settings.*`) — 저장에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 댓글 비밀번호 확인, 댓글 목록 조회, 게시판 목록·폼 조회 실패, 비밀글 사용 불가 안내 문구의 일본어 번역 추가 — 지금까지 일본어 로케일에서 안내 문구 대신 내부 식별자가 그대로 보이던 화면들이 정상 표시됩니다.
- 게시판 설정 일괄 적용 화면의 입력 항목명과 검증 안내 문구, 신고 상태 변경 불가 안내, 권한 모드 선택 안내의 일본어 번역 추가.
@@ -301,8 +301,10 @@
"notify_author": "投稿者通知",
"new_display_hours": "新着表示時間",
"default_board_permissions": "デフォルト権限",
"manager": "掲示板管理"
}
"manager": "掲示板管理",
"use_reaction": "リアクション使用"
},
"select_boards_hint": "下記から一括適用する掲示板を検索して選択してください。"
},
"bulk_apply_modal": {
"title": "一括適用確認",
@@ -25,6 +25,7 @@
- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다.
- 검색 결과 건수 표기와 검색어 구체화 안내(`search.result_count_suffix_at_least`, `search.refine_query_hint`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기가 일본어 로케일에서 자연스럽게 표시됩니다.
- 편집기 화면 문구(`editor.*`) 일본어 번역 보강.
- 본인 글에 반응 버튼을 눌렀을 때의 안내 문구(`board.reaction.self_post_denied`) 일본어 번역 추가 — 본인 글 반응 시도 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.1] - 2026-07-08
@@ -139,7 +139,8 @@
"thanks": "ありがとうございます",
"wow": "驚きました",
"login_required": "ログインが必要な機能です",
"failed": "応答処理に失敗しました。しばらく後にもう一度お試しください。"
"failed": "応答処理に失敗しました。しばらく後にもう一度お試しください。",
"self_post_denied": "自分の投稿には反応できません。"
},
"attachments": {
"title": "添付ファイル",
@@ -43,6 +43,7 @@
- 관리자 게시판 목록이 삭제글을 포함해 열릴 때마다 전체 건수를 다시 세던 것을 저장해 두고 재사용하도록 바꿨습니다.
- 답변글이 아주 많은 글을 열 때 답변 전체를 한꺼번에 읽던 것에 상한을 두고, 답변을 트리로 묶는 처리도 개선했습니다.
- 한 글의 댓글을 무제한으로 읽던 것에 상한을 두고, 같은 댓글을 두 번 가져오던 부분을 없앴습니다.
- 게시판 환경설정의 "기존 게시판에 일괄 적용" 화면을 정리했습니다. 대상 게시판(전체/선택)을 카드 형태로 눈에 띄게 표시하고, "선택한 게시판"을 고르면 검색 영역 위에 안내 문구가 함께 표시됩니다. 적용할 항목이나 대상 게시판을 고르지 않고 일괄 적용을 누르면 화면에 계속 남는 안내문 대신 토스트로 즉시 알려줍니다.
### Fixed
@@ -396,6 +396,8 @@ _단건 응답: `data` 객체의 필드._
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| show_view_count | boolean | `true` | 조회수 노출 |
| use_report | boolean | `false` | 게시글/댓글 신고 기능 사용 |
| use_reaction | boolean | `true` | 게시글 반응(추천/비추천) 기능 사용 |
| active_reaction_types | array | `["like","dislike"]` | 이 게시판에서 켠 반응 유형 code 목록. 생성 모드에서는 환경설정 기본값(`basic_defaults.active_reaction_types`)이 채워짐 |
| min_title_length | integer | `2` | 최소 제목 글자 수 |
| max_title_length | integer | `200` | 최대 제목 글자 수 |
| min_content_length | integer | `2` | 최소 게시글 글자 수 |
@@ -116,7 +116,8 @@ Authorization: Bearer {YOUR_TOKEN}
| id | integer | `1` | 반응 유형 ID |
| code | string | `like` | 내부 식별자 (예: `like`/`dislike`) |
| name | string | `추천` | 현재 로케일 라벨. 미입력 언어는 사이트 기본 언어로 폴백됩니다. |
| icon | string\|null | `fas fa-thumbs-up` | Font Awesome 아이콘 클래스 |
| icon | string\|null | `fas fa-thumbs-up` | 저장된 원본 Font Awesome 클래스 (스타일 접두사 포함) |
| icon_name | string\|null | `fa-thumbs-up` | Icon 컴포넌트 `name` prop 용 토큰 (스타일 접두사 제거). Icon 컴포넌트가 접두사를 자체 부착하므로 `icon` 을 그대로 넘기면 접두사가 중복됩니다. |
**응답 예시**
@@ -126,8 +127,8 @@ Authorization: Bearer {YOUR_TOKEN}
"message": "반응 유형 목록을 조회했습니다.",
"data": {
"reaction_types": [
{ "id": 1, "code": "like", "name": "추천", "icon": "fas fa-thumbs-up" },
{ "id": 2, "code": "dislike", "name": "비추천", "icon": "fas fa-thumbs-down" }
{ "id": 1, "code": "like", "name": "추천", "icon": "fas fa-thumbs-up", "icon_name": "fa-thumbs-up" },
{ "id": 2, "code": "dislike", "name": "비추천", "icon": "fas fa-thumbs-down", "icon_name": "fa-thumbs-down" }
]
}
}
@@ -267,6 +267,7 @@
"target_boards": "Target Boards",
"apply_all": "All Boards",
"apply_selected": "Selected Boards",
"select_boards_hint": "Search and select the boards to apply this to below.",
"select_boards_placeholder": "Select boards",
"apply_button": "Bulk Apply",
"no_fields_selected": "No items selected.",
@@ -282,6 +283,7 @@
"secret_mode": "Secret Mode",
"use_comment": "Enable Comments",
"use_reply": "Enable Replies",
"use_reaction": "Enable Reactions",
"max_reply_depth": "Max Reply Depth",
"max_comment_depth": "Max Comment Depth",
"comment_order": "Comment Sort Order",
@@ -267,6 +267,7 @@
"target_boards": "대상 게시판",
"apply_all": "전체 게시판",
"apply_selected": "선택한 게시판",
"select_boards_hint": "아래에서 일괄 적용할 게시판을 검색해 선택하세요.",
"select_boards_placeholder": "게시판을 선택하세요",
"apply_button": "일괄 적용",
"no_fields_selected": "적용할 항목이 선택되지 않았습니다.",
@@ -282,6 +283,7 @@
"secret_mode": "비밀글 모드",
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"use_reaction": "반응 사용",
"max_reply_depth": "답변글 최대 깊이",
"max_comment_depth": "대댓글 최대 깊이",
"comment_order": "댓글 정렬 순서",
@@ -1,7 +1,8 @@
{
"meta": {
"is_partial": true,
"description": "Board Form - Basic Tab Content"
"description": "Board Form - Basic Tab Content",
"_e2e_allow": "e2e:allow 반응 유형 체크박스 시각 스타일(배경 박스·들여쓰기·아이콘·정렬)만 변경, 클릭 동작(setState/hasChanges)은 기존과 동일. 렌더 분기·바인딩은 admin-board-reaction-settings.test.tsx(Vitest 레이아웃 테스트)로 커버, MCP 실브라우저로 시각 회귀 확인."
},
"id": "section_basic_card",
"type": "basic",
@@ -723,12 +724,12 @@
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 active_reaction_types(code 배열) 를 토글.",
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 active_reaction_types(code 배열) 를 토글. 게시판 환경설정 화면과 동일하게 배경 박스 + 좌측 들여쓰기로 시각적 구분 (PO 피드백).",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.use_reaction === true}}",
"props": {
"className": "mt-3 pl-1"
"className": "ml-6 mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
@@ -759,7 +760,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 cursor-pointer"
"className": "flex-center gap-2 cursor-pointer"
},
"children": [
{
@@ -777,11 +778,20 @@
"handler": "setState",
"params": {
"target": "local",
"form.active_reaction_types": "{{(_local.form?.active_reaction_types ?? []).includes(reactionType.code) ? (_local.form?.active_reaction_types ?? []).filter(c => c !== reactionType.code) : [...(_local.form?.active_reaction_types ?? []), reactionType.code]}}"
"form.active_reaction_types": "{{(_local.form?.active_reaction_types ?? []).includes(reactionType.code) ? (_local.form?.active_reaction_types ?? []).filter(c => c !== reactionType.code) : [...(_local.form?.active_reaction_types ?? []), reactionType.code]}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
@@ -1,7 +1,8 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 기본 탭 (기본 설정 + 일괄 적용)"
"description": "게시판 설정 > 기본 탭 (기본 설정 + 일괄 적용)",
"_e2e_allow": "e2e:allow 반응 유형 체크박스 시각 스타일(배경 박스·들여쓰기·아이콘·정렬)만 변경, 클릭 동작(setState/hasChanges)은 기존과 동일. 렌더 분기·바인딩은 admin-board-reaction-settings.test.tsx(Vitest 레이아웃 테스트)로 커버, MCP 실브라우저로 시각 회귀 확인."
},
"id": "basic",
"type": "basic",
@@ -545,12 +546,12 @@
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 basic_defaults.active_reaction_types(code 배열) 를 토글.",
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 basic_defaults.active_reaction_types(code 배열) 를 토글. 좌측 일괄적용 체크박스(bulkApplyFields)와 형태가 같아 혼동되기 쉬우므로, 배경 박스 + 좌측 들여쓰기로 '설정값 입력 영역'임을 시각적으로 분리한다 (PO 피드백).",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.basic_defaults?.use_reaction === true}}",
"props": {
"className": "row-content-indent mt-2"
"className": "row-content-indent ml-6 mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
@@ -581,7 +582,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 cursor-pointer"
"className": "flex-center gap-2 cursor-pointer"
},
"children": [
{
@@ -605,6 +606,14 @@
}
]
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
@@ -35,93 +35,34 @@
{
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "flex flex-col gap-3 mt-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "alert-warning"
},
"children": [
{
"type": "basic",
"name": "Div",
"name": "Icon",
"props": {
"className": "alert-warning"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.checked_fields_notice"
}
]
"name": "triangle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "Div",
"if": "{{!(_local.bulkApplyFields ?? []).length}}",
"name": "P",
"props": {
"className": "flex-center gap-2 px-1"
"className": "text-warning-soft"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.no_fields_selected"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && _local.bulkApplyAll === false && !(_local._bulkApplyBoardIds ?? []).length}}",
"props": {
"className": "flex-center gap-2 px-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"className": "text-red-500 dark:text-red-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.no_boards_selected"
}
]
"text": "$t:sirsoft-board.admin.settings.bulk_apply.checked_fields_notice"
}
]
}
@@ -156,125 +97,61 @@
]
},
{
"id": "bulk_apply_target",
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "mt-6"
},
"children": [
{
"id": "bulk_apply_target",
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.target_boards"
}
]
},
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.target_boards"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 sm:grid-cols-2 gap-3 mt-2"
},
"children": [
{
"id": "bulk_apply_target_all",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
"className": "{{_local.bulkApplyAll !== false ? 'flex-center gap-2 p-3 rounded-lg border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/20 cursor-pointer' : 'flex-center gap-2 p-3 rounded-lg border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 cursor-pointer hover:border-gray-300 dark:hover:border-gray-600'}}"
},
"children": [
"actions": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-center gap-2 text-body cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll !== false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_all"
}
]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-center gap-2 text-body cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll === false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": false
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_selected"
}
]
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": true
}
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.bulkApplyAll === false}}",
"props": {
"className": "mt-3"
},
],
"children": [
{
"type": "composite",
"name": "TagInput",
"type": "basic",
"name": "Input",
"props": {
"value": "{{_local._bulkApplyBoardIds ?? []}}",
"options": "{{$computed.boardOptions ?? []}}",
"placeholder": "$t:sirsoft-board.admin.settings.bulk_apply.select_boards_placeholder",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll !== false}}"
},
"actions": [
{
@@ -282,10 +159,105 @@
"handler": "setState",
"params": {
"target": "local",
"_bulkApplyBoardIds": "{{$event.target.value}}"
"bulkApplyAll": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_all"
}
]
},
{
"id": "bulk_apply_target_selected",
"type": "basic",
"name": "Div",
"props": {
"className": "{{_local.bulkApplyAll === false ? 'flex-center gap-2 p-3 rounded-lg border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/20 cursor-pointer' : 'flex-center gap-2 p-3 rounded-lg border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 cursor-pointer hover:border-gray-300 dark:hover:border-gray-600'}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": false
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll === false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": false
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_selected"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.bulkApplyAll === false}}",
"props": {
"className": "mt-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30 border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-tertiary mb-2"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.select_boards_hint"
},
{
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{_local._bulkApplyBoardIds ?? []}}",
"options": "{{$computed.boardOptions ?? []}}",
"placeholder": "$t:sirsoft-board.admin.settings.bulk_apply.select_boards_placeholder",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"_bulkApplyBoardIds": "{{$event.target.value}}"
}
}
]
}
@@ -298,7 +270,7 @@
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "flex justify-end"
"className": "flex justify-end mt-6"
},
"children": [
{
@@ -306,7 +278,7 @@
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || !(_local.bulkApplyFields ?? []).length || (_local.bulkApplyAll === false && !(_local._bulkApplyBoardIds ?? []).length)}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
@@ -314,9 +286,28 @@
"type": "click",
"handler": "sequence",
"actions": [
{
"comment": "검증 실패 안내 토스트 - 적용할 항목 미선택",
"handler": "toast",
"if": "{{!(_local.bulkApplyFields ?? []).length}}",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.bulk_apply.no_fields_selected"
}
},
{
"comment": "검증 실패 안내 토스트 - 선택한 게시판 미선택 (적용 항목은 선택된 경우에만 표시)",
"handler": "toast",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && _local.bulkApplyAll === false && !(_local._bulkApplyBoardIds ?? []).length}}",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.bulk_apply.no_boards_selected"
}
},
{
"comment": "모달 열기 전 현재 _local 상태를 _global 스냅샷에 저장 + 오류 상태 초기화",
"handler": "setState",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && (_local.bulkApplyAll !== false || (_local._bulkApplyBoardIds ?? []).length > 0)}}",
"params": {
"target": "global",
"bulkApplySnapshot": "{{(function() { const fields = _local.bulkApplyFields ?? []; const applyAll = _local.bulkApplyAll !== false; const boardIds = _local._bulkApplyBoardIds ?? []; const tab = _global.activeBoardSettingsTab || 'basic_defaults'; const form = _local.form || {}; return { fields: fields, applyAll: applyAll, boardIds: boardIds, tab: tab, form: form }; })()}}"
@@ -325,6 +316,7 @@
{
"comment": "이전 오류/롤백 안내 초기화 (모달 재오픈 시 직전 실패 안내 잔존 방지)",
"handler": "setState",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && (_local.bulkApplyAll !== false || (_local._bulkApplyBoardIds ?? []).length > 0)}}",
"params": {
"target": "local",
"bulkApplySaveError": false,
@@ -334,6 +326,7 @@
},
{
"handler": "openModal",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && (_local.bulkApplyAll !== false || (_local._bulkApplyBoardIds ?? []).length > 0)}}",
"target": "bulk_apply_confirm_modal"
}
]
@@ -288,6 +288,8 @@ class BoardController extends AdminBaseController
// TagInput 입력과 일관되게 배열로 주입 (categories 와 동일 패턴)
$data['blocked_keywords'] = array_values($spamSecurity['blocked_keywords'] ?? []);
$data['allowed_extensions'] = array_values($basicDefaults['allowed_extensions'] ?? []);
// reject(is_array) 로 제거된 배열 필드 — 체크박스 초기 선택 상태 렌더에 필요
$data['active_reaction_types'] = array_values($basicDefaults['active_reaction_types'] ?? []);
// depth 필드는 reject(is_array)로 걸러지지 않지만 basic_defaults가 비어있을 수 있으므로 명시적 기본값 보장
$limits = config('sirsoft-board.limits', []);
@@ -296,33 +296,10 @@ class BoardResource extends BaseApiResource
'name' => $type->getLocalizedName(),
'icon' => $type->icon,
// Icon 컴포넌트 name prop 용 — 스타일 접두사(fas/far 등)를 떼고 fa-* 토큰만 추출
'icon_name' => self::normalizeIconName($type->icon),
'icon_name' => $type->getIconName(),
])->values()->all();
}
/**
* 저장된 Font Awesome 클래스(`fas fa-thumbs-up`)에서 Icon 컴포넌트 name prop 용
* 아이콘 토큰(`fa-thumbs-up`)만 추출합니다. Icon 컴포넌트가 스타일 접두사를 자체 부착하므로
* 원본 클래스를 그대로 name 에 넘기면 접두사가 중복됩니다.
*
* @param string|null $icon 저장된 아이콘 클래스
* @return string|null Icon name prop 값 (없으면 null)
*/
private static function normalizeIconName(?string $icon): ?string
{
if ($icon === null || $icon === '') {
return null;
}
foreach (preg_split('/\s+/', trim($icon)) as $token) {
if (str_starts_with($token, 'fa-')) {
return $token;
}
}
return $icon;
}
/**
* 허용 확장자를 배열로 반환합니다.
*
@@ -24,6 +24,7 @@ class ReactionTypeResource extends BaseApiResource
'code' => $this->code,
'name' => $this->getLocalizedName(),
'icon' => $this->icon,
'icon_name' => $this->getIconName(),
];
}
}
@@ -91,4 +91,26 @@ class ReactionType extends Model
{
return $query->where('is_active', true);
}
/**
* 저장된 Font Awesome 클래스(`fas fa-thumbs-up`)에서 Icon 컴포넌트 name prop 용
* 아이콘 토큰(`fa-thumbs-up`)만 추출합니다. Icon 컴포넌트가 스타일 접두사를 자체 부착하므로
* 원본 클래스를 그대로 name 에 넘기면 접두사가 중복됩니다.
*
* @return string|null Icon name prop 값 (없으면 null)
*/
public function getIconName(): ?string
{
if ($this->icon === null || $this->icon === '') {
return null;
}
foreach (preg_split('/\s+/', trim($this->icon)) as $token) {
if (str_starts_with($token, 'fa-')) {
return $token;
}
}
return $this->icon;
}
}
@@ -19,6 +19,18 @@ interface ReactionRepositoryInterface
*/
public function findByUserAndTarget(int $userId, string $targetType, int $targetId): ?Reaction;
/**
* 대상 게시글 행을 `lockForUpdate` 로 잠급니다.
*
* 짧은 시간에 연속 전송된 반응 요청이 서버에 도착 순서와 다르게 처리되어도
* (요청 A→B→C 전송, B→A→C 처리 등) `existing` 조회부터 upsert/delete·카운트
* 재집계까지 동일 게시글에 대한 처리 전체가 이 잠금 하나로 직렬화되도록,
* 반응 처리 트랜잭션 진입 직후 가장 먼저 호출해야 합니다.
*
* @param int $postId 게시글 ID
*/
public function lockPostForReaction(int $postId): void;
/**
* 반응을 등록하거나 전환합니다 (없으면 INSERT, 있으면 유형 UPDATE).
*
@@ -40,15 +52,16 @@ interface ReactionRepositoryInterface
public function delete(Reaction $reaction): bool;
/**
* 게시글의 반응 카운트(JSON)를 유형별 증감으로 원자 갱신합니다.
* 게시글의 반응 카운트(JSON)를 board_reactions 실제 행 기준으로 재집계합니다.
*
* 반드시 트랜잭션 안에서 호출되어야 하며, 대상 행을 `lockForUpdate` 로 잠가
* 동시 반응에도 카운트 누락이 없도록 read-modify-write 를 원자 처리합니다.
* 결과 카운트는 0 미만으로 내려가지 않도록 클램프합니다.
* 반드시 트랜잭션 안에서 호출되어야 하며, 대상 게시글 행을 `lockForUpdate` 로 잠근 뒤
* 그 잠금 범위 안에서 COUNT 재집계까지 수행해 동시 요청의 처리 순서가 뒤바뀌어도
* (짧은 시간에 연속 전송된 반응 요청이 도착 순서와 다르게 처리되는 경우) 캐시된
* 카운트가 실제 반응 행 수와 항상 일치하도록 보장합니다. 델타 누적 방식은 요청
* 순서 역전 시 캐시가 실제 데이터와 어긋날 수 있어 사용하지 않습니다.
*
* @param int $postId 게시글 ID
* @param array<int, int> $deltas 유형 ID => 증감값 (예: [1 => -1, 2 => 1])
* @return array<string, int> 갱신 후 reaction_counts (키는 유형 ID 문자열)
* @return array<string, int> 갱신 후 reaction_counts (키는 유형 ID 문자열, 값은 실제 COUNT)
*/
public function adjustPostReactionCounts(int $postId, array $deltas): array;
public function recalculatePostReactionCounts(int $postId): array;
}
@@ -24,6 +24,14 @@ class ReactionRepository implements ReactionRepositoryInterface
->first();
}
/**
* {@inheritDoc}
*/
public function lockPostForReaction(int $postId): void
{
Post::query()->lockForUpdate()->findOrFail($postId);
}
/**
* {@inheritDoc}
*/
@@ -53,18 +61,28 @@ class ReactionRepository implements ReactionRepositoryInterface
/**
* {@inheritDoc}
*/
public function adjustPostReactionCounts(int $postId, array $deltas): array
public function recalculatePostReactionCounts(int $postId): array
{
/** @var Post $post */
$post = Post::query()->lockForUpdate()->findOrFail($postId);
$counts = $post->reaction_counts ?? [];
// 이전에 캐시에 등장했던 유형은 반응이 0건이 되어도 키 자체는 유지한다
// (API 응답 계약 — 한 번 노출된 유형의 카운트는 0으로라도 항상 존재).
$zeroed = array_fill_keys(array_keys($post->reaction_counts ?? []), 0);
foreach ($deltas as $typeId => $delta) {
$key = (string) $typeId;
$next = (int) ($counts[$key] ?? 0) + $delta;
$counts[$key] = max(0, $next);
}
$actual = Reaction::query()
->byTarget('post', $postId)
->selectRaw('reaction_type_id, COUNT(*) as total')
->groupBy('reaction_type_id')
->pluck('total', 'reaction_type_id')
->mapWithKeys(fn ($total, $typeId) => [(string) $typeId => (int) $total])
->toArray();
// array_merge 는 숫자형 키(반응 유형 ID)를 재색인해 순서/키를 잃는다
// (예: [1=>1] 이 [0=>1] 로 바뀌어 JSON 직렬화 시 객체 대신 리스트가 됨).
// + 연산자는 키를 그대로 보존하면서 좌측을 우선하므로 실제 COUNT($actual)로
// zeroed 를 덮어쓰려면 우측에 두어야 한다.
$counts = $actual + $zeroed;
$post->reaction_counts = $counts;
$post->save();
@@ -100,6 +100,15 @@ class ReactionService
HookManager::doAction('sirsoft-board.reaction.before_react', $userId, $post, $reactionTypeId);
$result = DB::transaction(function () use ($userId, $board, $post, $reactionTypeId) {
// 이 게시글에 대한 반응 처리를 직렬화 — 짧은 시간에 연속 전송된 요청이
// 서버 도착 순서와 다르게 처리되어도(A→B→C 전송, B→A→C 처리 등) existing
// 조회부터 upsert/delete·카운트 재집계까지가 이 잠금 하나로 묶여, 이후
// 로직이 항상 "이 트랜잭션 차례가 됐을 때의 실제 최신 상태"를 기준으로
// 판단하도록 보장한다. 잠금 없이 existing 을 먼저 읽으면 다른 트랜잭션이
// 그 사이 상태를 바꿔도 반영되지 않아, 이미 지워진 반응을 다시 지우거나
// 캐시 카운트가 실제 반응 행 수와 어긋나는 결함으로 이어진다.
$this->reactionRepository->lockPostForReaction($post->id);
$existing = $this->reactionRepository->findByUserAndTarget(
$userId,
self::TARGET_POST,
@@ -109,17 +118,13 @@ class ReactionService
// 같은 유형 재요청 → 해제
if ($existing !== null && (int) $existing->reaction_type_id === $reactionTypeId) {
$this->reactionRepository->delete($existing);
$counts = $this->reactionRepository->adjustPostReactionCounts(
$post->id,
[$reactionTypeId => -1],
);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'remove', 'reaction_type_id' => null, 'reaction_counts' => $counts];
}
// 다른 유형 → 전환 (이전 -1, 신규 +1)
if ($existing !== null) {
$previousTypeId = (int) $existing->reaction_type_id;
$this->reactionRepository->upsert(
$userId,
self::TARGET_POST,
@@ -127,10 +132,7 @@ class ReactionService
$reactionTypeId,
$board->id,
);
$counts = $this->reactionRepository->adjustPostReactionCounts(
$post->id,
[$previousTypeId => -1, $reactionTypeId => 1],
);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'change', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts];
}
@@ -143,10 +145,7 @@ class ReactionService
$reactionTypeId,
$board->id,
);
$counts = $this->reactionRepository->adjustPostReactionCounts(
$post->id,
[$reactionTypeId => 1],
);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'add', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts];
});
@@ -42,8 +42,10 @@ class ReactionTypeApiTest extends ModuleTestCase
->assertJsonPath('data.reaction_types.0.code', 'like')
->assertJsonPath('data.reaction_types.0.name', '추천')
->assertJsonPath('data.reaction_types.0.icon', 'fas fa-thumbs-up')
->assertJsonPath('data.reaction_types.0.icon_name', 'fa-thumbs-up')
->assertJsonPath('data.reaction_types.1.code', 'dislike')
->assertJsonPath('data.reaction_types.1.name', '비추천');
->assertJsonPath('data.reaction_types.1.name', '비추천')
->assertJsonPath('data.reaction_types.1.icon_name', 'fa-thumbs-down');
}
/**
@@ -10,7 +10,7 @@
* 재조회 → 화면 갱신의 종단 흐름을 담당한다.
*
* @scenario surface=detail_register_switch_remove
* @effects detail_register_then_switch_then_remove_updates_ui,register_inserts_row_and_increments_count,switch_updates_row_and_adjusts_both_counts,remove_deletes_row_and_decrements_count
* @effects detail_register_then_switch_then_remove_updates_ui,register_inserts_row_and_increments_count,switch_updates_row_and_adjusts_both_counts,remove_deletes_row_and_decrements_count,self_post_click_shows_denied_toast
*
* 활성화 절차: PlaywrightIssueToken 발급 + 반응 유형이 켜진 공개 게시판/게시글 시드가 가능한
* 환경에서 test.describe.skip → test.describe. SLUG/POST_ID 는 시드에 맞춰 조정.
@@ -26,6 +26,11 @@ const REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${POST_ID}/
const LIKE_ID = 1;
const DISLIKE_ID = 2;
// reactionToken 발급 계정이 작성자인 게시글 ID (본인 글 반응 차단 검증용) — 시드에 맞춰 조정
const OWN_POST_ID = 19;
const OWN_POST_PATH = `/board/${SLUG}/${OWN_POST_ID}`;
const OWN_POST_REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${OWN_POST_ID}/react`;
test.describe('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
// @scenario surface=detail_register_switch_remove
// @effects register_inserts_row_and_increments_count, switch_updates_row_and_adjusts_both_counts, remove_deletes_row_and_decrements_count
@@ -104,4 +109,33 @@ test.describe('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
await expect.poll(() => page.url(), { timeout: 5_000 }).toContain('/login');
expect(page.url()).toContain('redirect');
});
// @scenario surface=detail_register_switch_remove
// @effects self_post_click_shows_denied_toast
test('본인 글 반응 버튼 클릭 시 API 호출 없이 안내 토스트가 표시된다', async ({
page,
reactionToken,
}) => {
await authenticatePage(page, reactionToken);
// disabled 버튼은 클릭 이벤트 자체가 발생하지 않아 안내를 줄 수 없으므로, 본인 글에서는
// 버튼을 시각적으로만 비활성 톤으로 두고 클릭은 받아 switch 분기에서 토스트로 안내한다
// (트러블슈팅: PO 피드백 — disabled 버튼은 "왜 안 눌리는지" 사용자가 알 수 없음).
let reactCalled = false;
await page.route(OWN_POST_REACT_API, async (route) => {
reactCalled = true;
await route.abort();
});
await page.goto(OWN_POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
await likeButton.click();
await expect(page.getByText(/본인 글에는 반응할 수 없습니다|cannot react to your own post/i)).toBeVisible({
timeout: 5_000,
});
expect(reactCalled).toBe(false);
});
});
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* ReactionRepository / ReactionTypeRepository 검증.
*
* upsert(등록/전환)·delete(해제)·adjustPostReactionCounts(원자 카운트) 및
* upsert(등록/전환)·delete(해제)·recalculatePostReactionCounts(실제 행 기준 재집계) 및
* 유형 조회(getActive/findByCodes/findById/findByIds)를 검증한다.
*/
class ReactionRepositoryTest extends BoardTestCase
@@ -92,26 +92,29 @@ class ReactionRepositoryTest extends BoardTestCase
}
/**
* adjustPostReactionCounts 는 유형별 증감을 적용하고 0 미만으로 내려가지 않는다.
* recalculatePostReactionCounts 는 board_reactions 실제 행 수를 그대로 반영한다
* (델타 누적이 아닌 COUNT 재집계 — 캐시가 실제 데이터와 항상 일치해야 함).
*/
public function test_adjust_post_reaction_counts_applies_deltas_and_clamps(): void
public function test_recalculate_post_reaction_counts_reflects_actual_rows(): void
{
$postId = $this->createTestPost(['reaction_counts' => json_encode([(string) $this->likeId => 2])]);
$postId = $this->createTestPost(['reaction_counts' => json_encode([(string) $this->likeId => 99])]);
$counts = $this->reactionRepository->adjustPostReactionCounts($postId, [
$this->likeId => -1,
$this->dislikeId => 1,
]);
// 실제 반응 행 없음 → 캐시에 남아있던 오염된 값(99)은 사라지고, 이전에
// 노출됐던 유형 키는 0으로 유지된다 (API 응답 계약 — 키 자체는 보존).
$counts = $this->reactionRepository->recalculatePostReactionCounts($postId);
$this->assertSame([(string) $this->likeId => 0], $counts);
$userA = $this->createUser();
$userB = $this->createUser();
$this->reactionRepository->upsert($userA->id, 'post', $postId, $this->likeId, $this->board->id);
$this->reactionRepository->upsert($userB->id, 'post', $postId, $this->dislikeId, $this->board->id);
$counts = $this->reactionRepository->recalculatePostReactionCounts($postId);
$this->assertSame(1, $counts[(string) $this->likeId]);
$this->assertSame(1, $counts[(string) $this->dislikeId]);
// 클램프: 0 아래로 내려가지 않음
$clamped = $this->reactionRepository->adjustPostReactionCounts($postId, [$this->likeId => -5]);
$this->assertSame(0, $clamped[(string) $this->likeId]);
$post = Post::findOrFail($postId);
$this->assertSame(0, $post->reaction_counts[(string) $this->likeId]);
$this->assertSame($counts, $post->reaction_counts);
}
/**
@@ -141,6 +141,38 @@ class ReactionServiceTest extends BoardTestCase
$this->assertGreaterThanOrEqual(0, $result['reaction_counts'][(string) $this->likeId]);
}
/**
* 캐시된 reaction_counts 가 실제 board_reactions 행 수와 어긋나 있어도(예: 짧은
* 시간에 연속 전송된 요청이 서버 도착 순서와 다르게 처리되어 캐시가 오염된 경우),
* 다음 반응 처리가 실제 행을 재집계해 캐시를 실제 데이터와 일치하는 값으로
* 정정한다. 델타(증감) 누적 방식은 이런 오염을 스스로 고치지 못하고 계속
* 누적시키므로, 실제 COUNT 재집계 방식임을 검증한다.
*
* @scenario case=polluted_cache_self_heals_on_next_reaction
* @effects reaction_counts_matches_actual_rows_after_recalculation
*/
public function test_react_recalculates_from_actual_rows_and_heals_polluted_cache(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
// 캐시에는 실제와 무관한 오염된 값(양쪽 다 1)을 미리 심어둔다.
$postId = $this->createTestPost([
'user_id' => $author->id,
'reaction_counts' => json_encode([(string) $this->likeId => 1, (string) $this->dislikeId => 1]),
]);
$result = $this->service->react($reactor->id, $this->board, $postId, $this->likeId);
// 실제 반응 행은 이번에 등록된 like 1건뿐 — 오염된 dislike=1 은 사라지고
// 캐시가 실제 상태(like=1, dislike=0)로 정정되어야 한다.
$this->assertSame(1, $result['reaction_counts'][(string) $this->likeId]);
$this->assertSame(0, $result['reaction_counts'][(string) $this->dislikeId]);
$this->assertSame(
1,
Reaction::query()->where('target_id', $postId)->where('target_type', 'post')->count(),
);
}
/**
* use_reaction 이 꺼진 게시판은 반응이 차단된다.
*
@@ -24,6 +24,7 @@ axes:
- switch_like_to_dislike
- remove_same_type
- count_never_negative_on_over_remove
- polluted_cache_self_heals_on_next_reaction
- guest_react_blocked
- self_post_react_blocked
- inactive_type_react_blocked
@@ -51,6 +52,7 @@ effects:
- remove_deletes_row_and_decrements_count
- reaction_count_never_negative
- switch_count_atomic_in_single_transaction
- reaction_counts_matches_actual_rows_after_recalculation
# 어뷰징/상태 차단
- guest_react_returns_401
- self_post_react_returns_422
@@ -79,6 +81,7 @@ effects:
# 프론트 UX (낙관 하이라이트 + 응답값 개수 + 실패 롤백)
- reaction_failure_rolls_back_optimistic_state
- reaction_pending_disables_button
- self_post_click_shows_denied_toast
# E2E 흐름
- detail_register_then_switch_then_remove_updates_ui
@@ -70,6 +70,7 @@
- 템플릿 목록 화면의 탭 영역이 본문 여백과 어긋나 가로로 밀려 보일 수 있던 문제를 수정했습니다.
- 메일 템플릿·알림 정의 목록에서 각 행이 같은 식별자를 공유해 화면 읽기 도구가 항목을 구분하지 못하던 문제를 수정했습니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
- 태그 입력(다중 검색 선택) 컴포넌트의 드롭다운 목록이 화면 하단에 고정된 저장/취소 버튼 등에 가려 일부 항목을 선택하기 어렵던 문제를 수정했습니다.
### Added
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -564,6 +564,12 @@ export const TagInput: React.FC<TagInputProps> = ({
classNamePrefix: 'tag-input',
unstyled: true,
classNames: commonClassNames,
// react-select 는 menuPortal 에 zIndex:1 을 인라인 스타일로 강제 주입한다.
// classNames.menuPortal(Tailwind 클래스)는 인라인 스타일에 밀려 무력화되므로
// styles.menuPortal 로 직접 오버라이드해야 sticky 푸터(z-10) 등에 가려지지 않는다.
styles: {
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
},
// 구분자 기반 태그 분리 또는 외부 onInputChange 콜백이 있을 때 제어 컴포넌트 설정
...((creatable && isMulti) || onInputChangeProp ? {
inputValue,
@@ -112,6 +112,35 @@ describe('TagInput 컴포넌트', () => {
});
});
describe('메뉴 포탈 z-index', () => {
// 회귀(게시판 설정 일괄 적용 화면 결함): react-select 는 menuPortal 에
// zIndex:1 을 인라인 스타일로 강제 주입하며, 이는 Tailwind classNames
// 보다 우선 적용된다. styles.menuPortal 오버라이드가 없으면 드롭다운이
// sticky 하단 버튼바(z-10) 등 낮은 요소에도 가려질 수 있었다.
it('메뉴 포탈이 sticky 요소보다 높은 z-index를 인라인 스타일로 갖는다', async () => {
const user = userEvent.setup();
render(
<TagInput
value={[]}
options={mockOptions}
onChange={() => {}}
/>
);
const input = screen.getByRole('combobox');
await user.click(input);
await screen.findByText('A/S');
const portal = document.body.querySelector('.tag-input__menu-portal') as HTMLElement | null;
expect(portal).toBeTruthy();
// react-select(emotion)는 zIndex 를 style 속성이 아닌 생성된 CSS 클래스로 적용하므로
// getComputedStyle 로 확인해야 한다 (element.style.zIndex 는 항상 빈 값).
expect(Number(getComputedStyle(portal!).zIndex)).toBeGreaterThanOrEqual(9999);
});
});
describe('onBeforeRemove 콜백', () => {
it('onBeforeRemove가 true를 반환하면 삭제된다', async () => {
const handleChange = vi.fn();
@@ -12,7 +12,7 @@
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
- 주문서에서 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 선택해 주문할 수 있습니다. 선택한 결제수단은 주문 시 대응하는 기본 결제수단으로 전송되어, 결제창 호출과 이후 주문 처리가 정상적으로 이어집니다.
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 버튼을 누르면 즉시 반응 표시가 반영되고, 처리 중에는 잠깐 로딩이 표시되며, 처리에 실패하면 원래 상태로 되돌리고 안내합니다. 반응 버튼은 게시글 하단의 수정·삭제 버튼과 같은 줄에 배치됩니다. 본인이 쓴 글에서는 버튼이 눌리지 않고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 버튼을 누르면 즉시 반응 표시가 반영되고, 처리 중에는 잠깐 로딩이 표시되며, 처리에 실패하면 원래 상태로 되돌리고 안내합니다. 반응 버튼은 게시글 하단의 수정·삭제 버튼과 같은 줄에 배치됩니다. 본인이 쓴 글에서 버튼을 누르면 반응할 수 없다는 안내가 표시되고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
### Changed
@@ -85,7 +85,7 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
// @scenario case=author_buttons_disabled
// @effects author_sees_buttons_disabled, active_types_always_shown_even_zero_count, my_reaction_type_highlighted
it('유형별 버튼은 본인 글이면 disabled 이고 개수를 바인딩한다 (확정 08·09)', () => {
it('유형별 버튼은 처리 중이면 disabled, 본인 글이면 시각적 비활성 톤 + 클릭 시 안내(disabled 미부여)로 개수를 바인딩한다 (확정 08·09)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
expect(buttons.length).toBeGreaterThan(0);
@@ -93,7 +93,14 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
const btn = buttons[0];
const props = btn.props as Record<string, unknown>;
expect(props.type).toBe('button');
expect(String(props.disabled)).toContain('is_author');
// disabled 는 처리 중(reactingTypeId)에만 건다 — disabled 버튼은 클릭 이벤트가
// 발생하지 않아 본인 글 안내 토스트를 띄울 수 없으므로, 본인 글은 disabled 대신
// className 의 시각적 톤 + switch 핸들러의 self_post 분기로 처리한다.
expect(String(props.disabled)).toContain('reactingTypeId');
expect(String(props.disabled)).not.toContain('is_author');
expect(String(props.className)).toContain('is_author');
expect(String(props['aria-pressed'])).toBeTruthy();
expect(String(props['aria-disabled'])).toContain('reactingTypeId');
// 개수 바인딩 (reaction_counts[유형 ID] ?? 0)
const counts = collectNodes(btn, (n) =>
@@ -143,7 +143,8 @@
"thanks": "Thanks",
"wow": "Wow",
"login_required": "You need to sign in to use this feature.",
"failed": "Failed to process the reaction. Please try again."
"failed": "Failed to process the reaction. Please try again.",
"self_post_denied": "You cannot react to your own post."
},
"attachments": {
"title": "Attachments",
@@ -143,7 +143,8 @@
"thanks": "감사해요",
"wow": "놀라워요",
"login_required": "로그인이 필요한 기능입니다",
"failed": "반응 처리에 실패했습니다. 잠시 후 다시 시도해주세요."
"failed": "반응 처리에 실패했습니다. 잠시 후 다시 시도해주세요.",
"self_post_denied": "본인 글에는 반응할 수 없습니다."
},
"attachments": {
"title": "첨부파일",
@@ -190,7 +190,7 @@
"name": "Span",
"if": "{{post?.data?.is_new}}",
"props": {
"className": "px-2 py-1 text-xs font-bold bg-red-500 text-white rounded flex-shrink-0"
"className": "px-2 py-1 text-xs font-bold bg-red-500 dark:bg-red-600 text-white rounded flex-shrink-0"
},
"text": "N"
}
@@ -955,12 +955,14 @@
},
"children": [
{
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성). 실패 시 override 를 클릭 전 값으로 롤백. 본인 글은 항상 비활성. 활성 상태는 배경색+테두리+하단 밑줄로 표시(상시 확대는 제거 — 부자연스럽다는 PO 피드백). 클릭하는 순간에만 active:scale-95 로 살짝 눌리는 촉각 피드백을 주며, 손을 떼면 즉시 원래 크기로 복귀(순수 CSS :active 의사클래스, 상태 관리 불필요).",
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성, 실제 disabled). 본인 글은 시각적으로만 비활성 톤이며 disabled 는 걸지 않는다 — disabled 버튼은 클릭 이벤트 자체가 발생하지 않아 안내 토스트를 띄울 수 없으므로, 클릭은 받고 switch 분기에서 토스트로 안내한다. 실패 시 override 를 클릭 전 값으로 롤백. 활성 상태는 배경색+테두리+하단 밑줄로 표시(상시 확대는 제거 — 부자연스럽다는 PO 피드백). 클릭하는 순간에만 active:scale-95 로 살짝 눌리는 촉각 피드백을 주며, 손을 떼면 즉시 원래 크기로 복귀(순수 CSS :active 의사클래스, 상태 관리 불필요).",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{post?.data?.is_author === true || (_local?.reactingTypeId ?? null) !== null}}",
"disabled": "{{(_local?.reactingTypeId ?? null) !== null}}",
"aria-pressed": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id}}",
"aria-disabled": "{{(_local?.reactingTypeId ?? null) !== null}}",
"className": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id ? 'px-4 py-2 text-sm border border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg flex items-center gap-1.5 font-medium active:scale-95 transition-transform duration-150 ease-out' : 'px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-1.5 active:scale-95 transition-transform duration-150 ease-out'}}{{(post?.data?.is_author || (_local?.reactingTypeId ?? null) !== null) ? ' cursor-not-allowed opacity-60' : ' cursor-pointer'}}"
},
"actions": [
@@ -968,9 +970,16 @@
"type": "click",
"handler": "switch",
"params": {
"value": "{{_global.currentUser?.uuid ? 'logged_in' : 'not_logged_in'}}"
"value": "{{post?.data?.is_author ? 'self_post' : (_global.currentUser?.uuid ? 'logged_in' : 'not_logged_in')}}"
},
"cases": {
"self_post": {
"handler": "toast",
"params": {
"type": "info",
"message": "$t:board.reaction.self_post_denied"
}
},
"logged_in": {
"handler": "sequence",
"actions": [