feat(board): 게시글 반응(추천/비추천) 프론트엔드 화면

4단계 화면 3면 + 백엔드 수용.

- 게시글 상세(show.json): 신고 버튼 위에 반응 영역 — use_reaction &&
 유형 존재 시 노출, 유형 iteration(아이콘+라벨+개수, 0도 노출), 본인 글 비활성화,
 같은 유형 재클릭=해제·다른 유형=전환, 비로그인 안내 후 로그인 유도
- 관리 설정 기본값 탭 + 개별 게시판 편집 폼: 반응 사용 토글 + 유형 체크박스
 (reactionTypes data_source iteration, active_reaction_types code 토글)
- FormRequest(Store/Update Board, Store/BulkApply Settings)에 use_reaction·
 active_reaction_types 화이트리스트/캐스트/시드 추가 (미추가 시 토글 미저장)
- BoardResource.icon_name(fa-* 토큰) — Icon 컴포넌트 접두사 중복 방지
- ko/en 다국어 + boards/settings API 문서 갱신
- Vitest 10건(템플릿 4 + 모듈 6) green, board 관리/설정 87건 회귀 green

ja 언어팩·시나리오 매니페스트·E2E·버전 bump·CHANGELOG 는 5단계.
This commit is contained in:
chym1217
2026-08-07 17:00:48 +09:00
parent 4060a91d28
commit 2ba0c2ba2d
22 changed files with 662 additions and 6 deletions
@@ -134,6 +134,8 @@ HTTP/1.1 200
| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 |
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| use_reaction | body | boolean | 예 | — | 게시글 반응(추천/비추천) 기능 사용 여부 |
| active_reaction_types | body | array | 아니오 | — | 이 게시판에서 켠 반응 유형 code 목록 (문자열 배열, 예: `["like","dislike"]`) |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
@@ -1251,6 +1253,8 @@ HTTP/1.1 200
| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 |
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| use_reaction | body | boolean | 예 | — | 게시글 반응(추천/비추천) 기능 사용 여부 |
| active_reaction_types | body | array | 아니오 | — | 이 게시판에서 켠 반응 유형 code 목록 (문자열 배열, 예: `["like","dislike"]`) |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
@@ -361,7 +361,7 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. |
| notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. 반응 기능 기본값으로 `basic_defaults.use_reaction`(boolean) 과 `basic_defaults.active_reaction_types`(반응 유형 code 문자열 배열)를 포함하며, 새 게시판 생성 시 이 값이 개별 게시판의 초기값으로 시드됩니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. |
| report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. |
| report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | 자동 숨김 신고 수 (이 횟수 이상 신고되면 자동 숨김 처리) |
| report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) |
@@ -0,0 +1,66 @@
// e2e:allow 반응 사용 토글 + 유형 체크박스 저장은 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(토글/체크/저장)으로 커버. 게시판 환경설정 화면 Playwright 인프라 부재
/**
* 게시판 반응(추천/비추천) 설정 UI 회귀 가드 (이슈 #525 확정 02·11·13)
*
* @description
* 고정 대상:
* 1) 환경설정 기본값 탭(_tab_board_settings_basic)에 use_reaction 토글이 있고
* basic_defaults.use_reaction 에 바인딩된다 (확정 13, 새 게시판 기본값).
* 2) 유형 체크박스 서브패널은 use_reaction on 일 때만 노출되며,
* reactionTypes 데이터소스를 iteration 해 basic_defaults.active_reaction_types(code 배열)를
* 토글한다 (확정 02·11, DB 유형 목록 동적 렌더).
* 3) 개별 게시판 편집 폼(admin_board_form/_tab_basic)에도 동일 구조가 use_reaction /
* active_reaction_types 에 바인딩되어 존재한다 (게시판별 override).
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import settingsTab from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json';
import formTab from '../../../layouts/admin/partials/admin_board_form/_tab_basic.json';
const settingsJson = JSON.stringify(settingsTab);
const formJson = JSON.stringify(formTab);
describe('환경설정 기본값 탭 — 반응 사용 토글 + 유형 체크박스', () => {
it('use_reaction 토글이 basic_defaults.use_reaction 에 바인딩된다', () => {
expect(/"name":\s*"basic_defaults\.use_reaction"/.test(settingsJson)).toBe(true);
});
it('유형 체크박스 서브패널은 use_reaction on 일 때만 노출된다', () => {
expect(/_local\.form\?\.basic_defaults\?\.use_reaction === true/.test(settingsJson)).toBe(true);
});
it('reactionTypes 데이터소스를 iteration 해 유형을 동적 렌더한다', () => {
expect(/reactionTypes\?\.data\?\.reaction_types/.test(settingsJson)).toBe(true);
expect(/"item_var":\s*"reactionType"/.test(settingsJson)).toBe(true);
});
it('체크박스가 active_reaction_types(code 배열) 를 include/exclude 로 토글한다', () => {
// 체크 상태: code 포함 여부
expect(
/\(_local\.form\?\.basic_defaults\?\.active_reaction_types \?\? \[\]\)\.includes\(reactionType\.code\)/.test(
settingsJson,
),
).toBe(true);
// 저장: 포함 시 filter 제거, 미포함 시 spread 추가 (upsert 토글)
expect(/"form\.basic_defaults\.active_reaction_types"/.test(settingsJson)).toBe(true);
expect(/filter\(c => c !== reactionType\.code\)/.test(settingsJson)).toBe(true);
});
});
describe('개별 게시판 편집 폼 — 반응 사용 토글 + 유형 체크박스 (override)', () => {
it('use_reaction 토글이 use_reaction 에 바인딩된다', () => {
expect(/"name":\s*"use_reaction"/.test(formJson)).toBe(true);
});
it('유형 체크박스는 use_reaction on 일 때만 노출되고 active_reaction_types 를 토글한다', () => {
expect(/_local\.form\?\.use_reaction === true/.test(formJson)).toBe(true);
expect(/reactionTypes\?\.data\?\.reaction_types/.test(formJson)).toBe(true);
expect(
/\(_local\.form\?\.active_reaction_types \?\? \[\]\)\.includes\(reactionType\.code\)/.test(formJson),
).toBe(true);
expect(/"form\.active_reaction_types"/.test(formJson)).toBe(true);
});
});
@@ -2,6 +2,7 @@
"editor": {
"data_source": {
"availableChannels": "Available Channels",
"reactionTypes": "Reaction Types",
"boardIdentityPolicies": "Board Identity Policies",
"boardIdentityPurposes": "Board Identity Purposes",
"boardNotificationDefinitions": "Board Notification Definitions",
@@ -2,6 +2,7 @@
"editor": {
"data_source": {
"availableChannels": "사용 가능 채널",
"reactionTypes": "반응 유형",
"boardIdentityPolicies": "게시판 본인인증 정책",
"boardIdentityPurposes": "게시판 본인인증 목적",
"boardNotificationDefinitions": "게시판 알림 정의",
@@ -23,6 +23,8 @@
"use_comment": "Enable Comments",
"use_reply": "Enable Reply Posts",
"use_report": "Enable Reports",
"use_reaction": "Enable Reactions",
"active_reaction_types": "Select Reaction Types",
"secret_mode": "Secret Mode",
"order_by": {
"label": "Sort By",
@@ -146,6 +148,7 @@
"use_comment": "When enabled, you can configure detailed comment settings (length, depth, order).",
"use_reply": "When enabled, you can set the maximum reply depth.",
"use_report": "Enable report feature for inappropriate posts/comments",
"use_reaction": "Enable reactions such as recommend/not recommend on posts",
"type": "Board types can be managed in Settings > Basic Settings."
},
"options": {
@@ -95,6 +95,8 @@
"notify_author": "Author Notification",
"notify_author_description": "Send email to the original author when a comment or reply is posted",
"use_report": "Enable Reports",
"use_reaction": "Enable Reactions",
"active_reaction_types": "Select Reaction Types",
"auto_hide_threshold": "Auto Blind Threshold",
"auto_hide_threshold_hint": "Automatically blind content when reports reach this count. (0=disabled, 1\u2013100)",
"auto_hide_target": "Blind Target",
@@ -155,6 +157,7 @@
"secret_mode": "Set the default behavior for the secret post feature",
"show_view_count": "Display view count on post list and detail pages",
"use_report": "Enable reporting of inappropriate posts and comments",
"use_reaction": "Enable reactions such as recommend\/not recommend on posts",
"new_display_hours": "Duration to display [New] badge after posting (in hours)",
"per_page": "Number of posts to display per page on desktop ({{min}}–{{max}})",
"per_page_mobile": "Number of posts to display per page on mobile ({{min}}–{{max}})",
@@ -23,6 +23,8 @@
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"use_report": "신고 사용",
"use_reaction": "반응 사용",
"active_reaction_types": "사용할 반응 유형 선택",
"secret_mode": "비밀글 모드",
"order_by": {
"label": "정렬 기준",
@@ -146,6 +148,7 @@
"use_comment": "활성화하면 댓글 관련 세부 설정(길이, 깊이, 정렬)을 지정할 수 있습니다.",
"use_reply": "활성화하면 답변글 최대 깊이를 설정할 수 있습니다.",
"use_report": "부적절한 게시글/댓글 신고 기능을 활성화합니다",
"use_reaction": "게시글에 추천/비추천 등 반응을 남기는 기능을 활성화합니다",
"type": "게시판 유형은 환경설정 > 기본설정에서 관리할 수 있습니다."
},
"options": {
@@ -95,6 +95,8 @@
"notify_author": "작성자 알림",
"notify_author_description": "댓글, 답변글 작성 시 원글 작성자에게 이메일 발송",
"use_report": "신고 사용",
"use_reaction": "반응 사용",
"active_reaction_types": "사용할 반응 유형 선택",
"auto_hide_threshold": "자동 숨김 기준 (회)",
"auto_hide_threshold_hint": "이 횟수 이상 신고가 접수되면 자동으로 블라인드 처리됩니다. (0=비활성, 1~100)",
"auto_hide_target": "적용 대상",
@@ -155,6 +157,7 @@
"secret_mode": "비밀글 기능의 기본 동작을 설정합니다",
"show_view_count": "게시글 목록 및 상세에서 조회수를 표시합니다",
"use_report": "부적절한 게시글\/댓글 신고 기능을 활성화합니다",
"use_reaction": "게시글에 추천\/비추천 등 반응을 남기는 기능을 활성화합니다",
"new_display_hours": "게시글 작성 후 [New] 배지를 표시할 기간 (시간 단위)",
"per_page": "PC 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
"per_page_mobile": "모바일 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
@@ -82,6 +82,20 @@
"channels": []
}
}
},
{
"id": "reactionTypes",
"label_key": "$t:sirsoft-board.editor.data_source.reactionTypes",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reaction-types",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"reaction_types": []
}
}
}
],
"slots": {
@@ -158,6 +158,21 @@
}
}
},
{
"id": "reactionTypes",
"label_key": "$t:sirsoft-board.editor.data_source.reactionTypes",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reaction-types",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"reaction_types": []
}
}
},
{
"id": "boardIdentityPolicies",
"label_key": "$t:sirsoft-board.editor.data_source.boardIdentityPolicies",
@@ -161,7 +161,7 @@
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-secondary w-4 h-4 mt-0.5 flex-shrink-0"
"className": "text-secondary text-base mt-0.5 flex-shrink-0"
}
},
{
@@ -673,6 +673,129 @@
}
}
]
},
{
"id": "field_use_reaction",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-start justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.fields.use_reaction"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.use_reaction"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "use_reaction",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 active_reaction_types(code 배열) 를 토글.",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.use_reaction === true}}",
"props": {
"className": "mt-3 pl-1"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mb-2"
},
"text": "$t:sirsoft-board.admin.form.fields.active_reaction_types"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{reactionTypes?.data?.reaction_types ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"disabled": "{{_computed.isReadOnly}}",
"checked": "{{(_local.form?.active_reaction_types ?? []).includes(reactionType.code)}}"
},
"actions": [
{
"type": "change",
"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]}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? reactionType.code}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -470,6 +470,155 @@
]
}
]
},
{
"id": "field_use_reaction",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-heading flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_reaction')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_reaction') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_reaction') : [...(_local.bulkApplyFields ?? []), 'use_reaction']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_reaction"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_reaction"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.use_reaction",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 basic_defaults.active_reaction_types(code 배열) 를 토글.",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.basic_defaults?.use_reaction === true}}",
"props": {
"className": "row-content-indent mt-2"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint mb-2"
},
"text": "$t:sirsoft-board.admin.settings.fields.active_reaction_types"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{reactionTypes?.data?.reaction_types ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"disabled": "{{_computed.isReadOnly}}",
"checked": "{{(_local.form?.basic_defaults?.active_reaction_types ?? []).includes(reactionType.code)}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.basic_defaults.active_reaction_types": "{{(_local.form?.basic_defaults?.active_reaction_types ?? []).includes(reactionType.code) ? (_local.form?.basic_defaults?.active_reaction_types ?? []).filter(c => c !== reactionType.code) : [...(_local.form?.basic_defaults?.active_reaction_types ?? []), reactionType.code]}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? reactionType.code}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -34,6 +34,8 @@ class BulkApplySettingsRequest extends FormRequest
'comment_order',
'show_view_count',
'use_report',
'use_reaction',
'active_reaction_types',
'min_title_length',
'max_title_length',
'min_content_length',
@@ -65,6 +65,7 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.use_reply',
'basic_defaults.show_view_count',
'basic_defaults.use_report',
'basic_defaults.use_reaction',
'basic_defaults.use_file_upload',
'basic_defaults.notify_admin_on_post',
'basic_defaults.notify_author',
@@ -176,6 +177,9 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'],
'basic_defaults.show_view_count' => ['nullable', 'boolean'],
'basic_defaults.use_report' => ['nullable', 'boolean'],
'basic_defaults.use_reaction' => ['nullable', 'boolean'],
'basic_defaults.active_reaction_types' => ['nullable', 'array'],
'basic_defaults.active_reaction_types.*' => ['string', 'max:50'],
'basic_defaults.min_title_length' => ['nullable', 'integer', "min:{$minTitleLengthMin}", "max:{$minTitleLengthMax}"],
'basic_defaults.max_title_length' => ['nullable', 'integer', "min:{$maxTitleLengthMin}", "max:{$maxTitleLengthMax}"],
'basic_defaults.min_content_length' => ['nullable', 'integer', "min:{$minContentLengthMin}", "max:{$minContentLengthMax}"],
@@ -53,6 +53,8 @@ class StoreBoardRequest extends FormRequest
'secret_mode' => $settings['secret_mode'] ?? 'disabled',
'use_comment' => $settings['use_comment'] ?? true,
'use_reply' => $settings['use_reply'] ?? true,
'use_reaction' => $settings['use_reaction'] ?? true,
'active_reaction_types' => $settings['active_reaction_types'] ?? [],
'max_reply_depth' => $settings['max_reply_depth'] ?? 5,
'max_comment_depth' => $settings['max_comment_depth'] ?? 10,
'use_file_upload' => $settings['use_file_upload'] ?? false,
@@ -86,7 +88,7 @@ class StoreBoardRequest extends FormRequest
// boolean 필드 캐스팅 (Toggle 컴포넌트가 "on"/"off" 문자열을 전송할 수 있음)
$booleanFields = [
'is_active', 'use_comment', 'use_reply', 'use_file_upload',
'use_report', 'show_view_count', 'is_notice',
'use_report', 'use_reaction', 'show_view_count', 'is_notice',
'notify_admin_on_post', 'notify_author',
];
@@ -169,6 +171,9 @@ class StoreBoardRequest extends FormRequest
'use_comment' => ['required', 'boolean'],
'use_reply' => ['required', 'boolean'],
'use_report' => ['required', 'boolean'],
'use_reaction' => ['required', 'boolean'],
'active_reaction_types' => ['sometimes', 'array'],
'active_reaction_types.*' => ['string'],
'comment_order' => ['required', 'in:ASC,DESC'],
'new_display_hours' => ['nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
@@ -75,7 +75,7 @@ class UpdateBoardRequest extends FormRequest
// boolean 필드 캐스팅 (Toggle 컴포넌트가 "on"/"off" 문자열을 전송할 수 있음)
$booleanFields = [
'is_active', 'use_comment', 'use_reply', 'use_file_upload',
'use_report', 'show_view_count', 'is_notice',
'use_report', 'use_reaction', 'show_view_count', 'is_notice',
'notify_admin_on_post', 'notify_author',
];
@@ -164,6 +164,9 @@ class UpdateBoardRequest extends FormRequest
'use_comment' => ['sometimes', 'required', 'boolean'],
'use_reply' => ['sometimes', 'required', 'boolean'],
'use_report' => ['sometimes', 'required', 'boolean'],
'use_reaction' => ['sometimes', 'required', 'boolean'],
'active_reaction_types' => ['sometimes', 'array'],
'active_reaction_types.*' => ['string'],
'comment_order' => ['sometimes', 'required', 'in:ASC,DESC'],
'new_display_hours' => ['sometimes', 'nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
@@ -261,6 +264,7 @@ class UpdateBoardRequest extends FormRequest
$settingFieldKeys = [
'per_page', 'per_page_mobile', 'order_by', 'order_direction',
'secret_mode', 'use_comment', 'use_reply', 'use_report',
'use_reaction', 'active_reaction_types',
'comment_order', 'show_view_count',
'max_reply_depth', 'max_comment_depth',
'min_title_length', 'max_title_length',
@@ -295,9 +295,34 @@ class BoardResource extends BaseApiResource
'code' => $type->code,
'name' => $type->getLocalizedName(),
'icon' => $type->icon,
// Icon 컴포넌트 name prop 용 — 스타일 접두사(fas/far 등)를 떼고 fa-* 토큰만 추출
'icon_name' => self::normalizeIconName($type->icon),
])->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;
}
/**
* 허용 확장자를 배열로 반환합니다.
*
@@ -0,0 +1,114 @@
/**
* @file board-reaction-buttons.test.tsx
* @description 게시글 상세 반응(추천/비추천) 버튼 레이아웃 회귀 (이슈 #525)
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (board 파티션 테스트 관례 — show.json 은 partial).
* 고정 대상:
* - 반응 영역은 use_reaction && reaction_type_options.length > 0 일 때만 노출 (확정 11)
* - reaction_type_options 를 iteration 으로 순회 (확정 02·유형 동적 렌더)
* - 유형별 개수는 reaction_counts[유형 ID] 를 바인딩, 없으면 0 (확정 09)
* - 본인 글이면 버튼 disabled (확정 08)
* - 클릭 시 비로그인은 안내+로그인 유도, 로그인 사용자는 react API 호출 후 post 재조회 (확정 03·07)
*/
import { describe, it, expect } from 'vitest';
import basicShow from '../../layouts/partials/board/types/basic/show.json';
type Node = Record<string, unknown> & { children?: unknown };
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
function findByComment(root: unknown, commentText: string): Node[] {
return collectNodes(root, (n) => typeof n.comment === 'string' && (n.comment as string).includes(commentText));
}
describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', () => {
it('반응 영역은 use_reaction + reaction_type_options 존재 시에만 노출된다 (확정 11)', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
expect(areas.length).toBe(1);
const area = areas[0];
expect(typeof area.if).toBe('string');
expect(area.if as string).toContain('use_reaction');
expect(area.if as string).toContain('reaction_type_options');
expect(area.if as string).toContain('length > 0');
});
it('반응 유형을 reaction_type_options 로 iteration 한다', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
const iterations = collectNodes(areas[0], (n) => n.iteration !== undefined);
expect(iterations.length).toBeGreaterThan(0);
const iter = iterations[0].iteration as Record<string, unknown>;
expect(iter.source as string).toContain('reaction_type_options');
expect(iter.item_var).toBe('reactionType');
});
it('유형별 버튼은 본인 글이면 disabled 이고 개수를 바인딩한다 (확정 08·09)', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
expect(buttons.length).toBeGreaterThan(0);
const btn = buttons[0];
const props = btn.props as Record<string, unknown>;
expect(props.type).toBe('button');
expect(String(props.disabled)).toContain('is_author');
// 개수 바인딩 (reaction_counts[유형 ID] ?? 0)
const counts = collectNodes(btn, (n) =>
typeof n.text === 'string' && (n.text as string).includes('reaction_counts')
);
expect(counts.length).toBe(1);
expect(counts[0].text as string).toContain('reactionType.id');
expect(counts[0].text as string).toContain('?? 0');
});
it('클릭 액션은 비로그인/로그인 분기 — react API 호출 + post 재조회 (확정 03·07)', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
const actions = (buttons[0].actions as Node[]) ?? [];
expect(actions.length).toBeGreaterThan(0);
const action = actions[0];
expect(action.handler).toBe('switch');
const cases = action.cases as Record<string, Node>;
expect(cases.logged_in).toBeDefined();
expect(cases.not_logged_in).toBeDefined();
// 로그인 사용자: react API 호출 후 post 데이터소스 재조회
const loggedIn = cases.logged_in;
expect(loggedIn.handler).toBe('apiCall');
expect(loggedIn.target as string).toContain('/react');
const onSuccess = loggedIn.onSuccess as Node[];
expect(onSuccess.some((a) => a.handler === 'refetchDataSource')).toBe(true);
// 비로그인: 안내 후 로그인 유도 (redirect 파라미터 전달)
const guest = cases.not_logged_in;
const guestActions = guest.actions as Node[];
expect(guestActions.some((a) => a.handler === 'toast')).toBe(true);
const navAction = guestActions.find((a) => a.handler === 'navigate');
expect(navAction).toBeDefined();
const navParams = navAction!.params as Record<string, unknown>;
expect(navParams.path).toBe('/login');
expect((navParams.query as Record<string, unknown>).redirect).toContain('/board/');
});
});
@@ -141,7 +141,8 @@
"laugh": "Funny",
"agree": "Agree",
"thanks": "Thanks",
"wow": "Wow"
"wow": "Wow",
"login_required": "You need to sign in to use this feature."
},
"attachments": {
"title": "Attachments",
@@ -141,7 +141,8 @@
"laugh": "웃겨요",
"agree": "동의해요",
"thanks": "감사해요",
"wow": "놀라워요"
"wow": "놀라워요",
"login_required": "로그인이 필요한 기능입니다"
},
"attachments": {
"title": "첨부파일",
@@ -927,6 +927,121 @@
}
]
},
{
"comment": "게시글 반응(추천/비추천) 영역 — 반응 사용 on + 게시판이 켠 유형이 1개 이상일 때만 노출(확정 11). 활성 유형을 iteration 으로 순회해 유형별 버튼(아이콘+라벨+개수, 개수 0도 항상 노출, 확정 09). 본인 글은 버튼/개수는 노출하되 클릭 불가(확정 08). 비로그인은 안내 후 로그인 유도, 로그인 사용자는 같은 유형 재클릭=해제·다른 유형=전환.",
"type": "basic",
"name": "Div",
"if": "{{post?.data?.board?.use_reaction && (post?.data?.board?.reaction_type_options ?? []).length > 0}}",
"props": {
"className": "flex flex-wrap items-center gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{post?.data?.board?.reaction_type_options ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"comment": "반응 유형 버튼 — my_reaction_type_id 와 일치하면 하이라이트, 본인 글이면 비활성화",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{post?.data?.is_author === true}}",
"className": "{{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 transition-colors' : '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 transition-colors flex items-center gap-1.5'}}{{post?.data?.is_author ? ' cursor-not-allowed opacity-60' : ' cursor-pointer'}}"
},
"actions": [
{
"type": "click",
"handler": "switch",
"params": {
"value": "{{_global.currentUser?.uuid ? 'logged_in' : 'not_logged_in'}}"
},
"cases": {
"logged_in": {
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/boards/{{route.slug}}/posts/{{route.id}}/react",
"auth_mode": "required",
"params": {
"method": "POST",
"body": {
"reaction_type_id": "{{reactionType.id}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "post"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
},
"not_logged_in": {
"handler": "sequence",
"actions": [
{
"handler": "toast",
"params": {
"type": "info",
"message": "$t:board.reaction.login_required"
}
},
{
"handler": "navigate",
"params": {
"path": "/login",
"query": {
"redirect": "/board/{{route.slug}}/{{route.id}}"
}
}
}
]
}
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "tabular-nums"
},
"text": "{{post?.data?.reaction_counts?.[reactionType.id] ?? 0}}"
}
]
}
]
}
]
},
{
"comment": "게시글 액션 버튼 영역 (답변, 신고, 수정, 삭제) - 비회원 글은 항상 표시 (수정/삭제용)",
"type": "basic",