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:
@@ -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` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) |
|
||||
|
||||
+66
@@ -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",
|
||||
|
||||
+124
-1
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+149
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user