From 2ba0c2ba2d090d51cb6e2945ee5d43cb859e0f04 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 4 Aug 2026 12:29:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(board):=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91(=EC=B6=94=EC=B2=9C/=EB=B9=84=EC=B6=94?= =?UTF-8?q?=EC=B2=9C)=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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단계. --- .../_bundled/sirsoft-board/docs/api/boards.md | 4 + .../sirsoft-board/docs/api/settings.md | 2 +- .../admin-board-reaction-settings.test.tsx | 66 ++++++++ .../sirsoft-board/resources/lang/en.json | 1 + .../sirsoft-board/resources/lang/ko.json | 1 + .../resources/lang/partial/en/admin/form.json | 3 + .../lang/partial/en/admin/settings.json | 3 + .../resources/lang/partial/ko/admin/form.json | 3 + .../lang/partial/ko/admin/settings.json | 3 + .../layouts/admin/admin_board_form.json | 14 ++ .../layouts/admin/admin_board_settings.json | 15 ++ .../partials/admin_board_form/_tab_basic.json | 125 ++++++++++++++- .../_tab_board_settings_basic.json | 149 ++++++++++++++++++ .../Admin/BulkApplySettingsRequest.php | 2 + .../Admin/StoreBoardSettingsRequest.php | 4 + .../src/Http/Requests/StoreBoardRequest.php | 7 +- .../src/Http/Requests/UpdateBoardRequest.php | 6 +- .../src/Http/Resources/BoardResource.php | 25 +++ .../layouts/board-reaction-buttons.test.tsx | 114 ++++++++++++++ .../sirsoft-basic/lang/partial/en/board.json | 3 +- .../sirsoft-basic/lang/partial/ko/board.json | 3 +- .../partials/board/types/basic/show.json | 115 ++++++++++++++ 22 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 55f2117b..118efc5c 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -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 | 게시글 제목 최소 글자 수 | diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md index 432e3097..808b6fbf 100644 --- a/modules/_bundled/sirsoft-board/docs/api/settings.md +++ b/modules/_bundled/sirsoft-board/docs/api/settings.md @@ -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` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) | diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx new file mode 100644 index 00000000..f23aa297 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx @@ -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); + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/lang/en.json b/modules/_bundled/sirsoft-board/resources/lang/en.json index c93b4247..59dfc480 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/en.json +++ b/modules/_bundled/sirsoft-board/resources/lang/en.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/resources/lang/ko.json b/modules/_bundled/sirsoft-board/resources/lang/ko.json index 7f456038..d5402abc 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/ko.json +++ b/modules/_bundled/sirsoft-board/resources/lang/ko.json @@ -2,6 +2,7 @@ "editor": { "data_source": { "availableChannels": "사용 가능 채널", + "reactionTypes": "반응 유형", "boardIdentityPolicies": "게시판 본인인증 정책", "boardIdentityPurposes": "게시판 본인인증 목적", "boardNotificationDefinitions": "게시판 알림 정의", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json index 6f819468..22ca8519 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json index c29062c4..c5bb929d 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json @@ -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}})", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json index eb7c3863..1db7885b 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json index 473ef297..1b699f26 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json @@ -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}})", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_form.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_form.json index c828cb38..4537a5c4 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_form.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_form.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json index 5ad705ee..0518317b 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_basic.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_basic.json index f794de44..a9de0edf 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_basic.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_basic.json @@ -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}}" + } + ] + } + ] + } + ] + } + ] + } + ] } ] } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json index ab51b823..54aea8af 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json @@ -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}}" + } + ] + } + ] + } + ] + } + ] + } + ] } ] } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php index 1e38bbfe..d97b4b21 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php @@ -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', diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php index 15f81a67..6c9a5387 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php @@ -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}"], diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php index f67978bc..3c819ae8 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php @@ -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}"], diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php index d2e37eb8..40aec9fe 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php @@ -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', diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php index 0c8b93b2..c3e924cd 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php @@ -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; + } + /** * 허용 확장자를 배열로 반환합니다. * diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx new file mode 100644 index 00000000..4fc0856d --- /dev/null +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx @@ -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 & { 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; + 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; + 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; + 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; + expect(navParams.path).toBe('/login'); + expect((navParams.query as Record).redirect).toContain('/board/'); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/lang/partial/en/board.json b/templates/_bundled/sirsoft-basic/lang/partial/en/board.json index dc135183..04595804 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/en/board.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/en/board.json @@ -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", diff --git a/templates/_bundled/sirsoft-basic/lang/partial/ko/board.json b/templates/_bundled/sirsoft-basic/lang/partial/ko/board.json index 9c1ded74..a4dfbb6e 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/ko/board.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/ko/board.json @@ -141,7 +141,8 @@ "laugh": "웃겨요", "agree": "동의해요", "thanks": "감사해요", - "wow": "놀라워요" + "wow": "놀라워요", + "login_required": "로그인이 필요한 기능입니다" }, "attachments": { "title": "첨부파일", diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json b/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json index afa2e6e7..2035e4eb 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json @@ -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",