fix(board): 게시글 반응 개수 표시 결함 수정 + 반응 버튼 UX 개선
Chrome MCP 검수 중 발견한 결함 수정 및 요청 반영. - 게시글 상세 응답의 reaction_counts 가 배열로 붕괴되어 개수가 0으로 표시되던 문제 수정 (PostResource → stdClass 로 JSON 객체 보장) + 회귀 테스트 - 반응 버튼을 수정·삭제 버튼과 같은 줄에 배치 - 반응 UX 개선: 클릭 즉시 하이라이트 반영 + 개수는 응답값으로 갱신(전체 재조회 제거) + 처리 중 로딩·중복클릭 차단 + 실패 시 롤백·안내 - board.md 상세 응답 필드 + basic CHANGELOG + 시나리오 매니페스트 갱신
This commit is contained in:
@@ -893,6 +893,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
|
||||
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
|
||||
| is_already_reported | boolean | `false` | already reported 여부 |
|
||||
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
|
||||
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
@@ -1055,6 +1057,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). 비밀글은 열람 권한이 없으면 빈 배열, 삭제된 게시글은 관리 권한이 없으면 연쇄 삭제된 첨부만 노출됩니다. |
|
||||
| replies | array | `[]` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
|
||||
| is_already_reported | boolean | `false` | already reported 여부 |
|
||||
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
|
||||
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
@@ -1312,6 +1316,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
|
||||
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
|
||||
| is_already_reported | boolean | `false` | already reported 여부 |
|
||||
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
|
||||
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
@@ -1471,6 +1477,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
|
||||
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
|
||||
| is_already_reported | boolean | `false` | already reported 여부 |
|
||||
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
|
||||
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
@@ -2078,6 +2086,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| is_author | boolean | `true` | author 여부 |
|
||||
| is_guest_comment | boolean | `false` | guest comment 여부 |
|
||||
| is_already_reported | boolean | `false` | already reported 여부 |
|
||||
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
|
||||
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
|
||||
@@ -493,14 +493,19 @@ class PostResource extends BaseApiResource
|
||||
* 반응 카운트를 응답용으로 반환합니다.
|
||||
*
|
||||
* 게시판이 켠(활성) 유형 전체를 키로 항상 포함하며, 저장된 카운트가 없는 유형은
|
||||
* 0 으로 채웁니다 (확정 09 — 활성 유형은 개수 0이어도 항상 노출). 키는 유형 ID 문자열.
|
||||
* 0 으로 채웁니다 (확정 09 — 활성 유형은 개수 0이어도 항상 노출). 키는 유형 ID.
|
||||
*
|
||||
* @return array<string, int> 유형 ID 문자열 => 개수
|
||||
* 반드시 stdClass(객체)로 반환한다 — 키가 정수(유형 ID)뿐이면 배열이 list 로 오인되어
|
||||
* JsonResource::resolve() 가 `[count1, count2]` 로 재인덱싱하고, 그러면 프론트가
|
||||
* `reaction_counts[유형ID]` 로 읽을 때 엉뚱한 인덱스를 읽어 개수가 항상 0/어긋난다.
|
||||
* stdClass 는 array 필터의 재인덱싱을 타지 않아 항상 `{"유형ID": 개수}` JSON 객체로 나간다.
|
||||
*
|
||||
* @return \stdClass 유형 ID => 개수 (JSON 객체)
|
||||
*/
|
||||
private function getReactionCountsForResponse(): array
|
||||
private function getReactionCountsForResponse(): \stdClass
|
||||
{
|
||||
if (! $this->relationLoaded('board') || ! $this->board) {
|
||||
return [];
|
||||
return new \stdClass;
|
||||
}
|
||||
|
||||
$stored = $this->reaction_counts ?? [];
|
||||
@@ -512,7 +517,7 @@ class PostResource extends BaseApiResource
|
||||
$counts[$key] = (int) ($stored[$key] ?? 0);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
return (object) $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,6 +191,41 @@ class ReactionApiTest extends BoardTestCase
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 상세 응답의 reaction_counts 가 유형 ID 키를 보존한다 (JSON 객체, 배열 재인덱싱 금지).
|
||||
*
|
||||
* 회귀: PostResource 의 reaction_counts 키가 정수 [1,2] 라 JsonResource::resolve() 가
|
||||
* list 로 오인해 [count1, count2] 로 재인덱싱하면, 프론트가 reaction_counts[유형ID] 로
|
||||
* 읽을 때 엉뚱한 인덱스를 읽어 개수가 항상 0/어긋나게 표시된다. 키는 항상 유형 ID 여야 한다.
|
||||
*/
|
||||
public function test_post_detail_reaction_counts_preserves_type_id_keys(): void
|
||||
{
|
||||
// 상세 조회는 posts.read 권한이 필요하다
|
||||
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
|
||||
|
||||
$reactor = $this->createUser();
|
||||
$postId = $this->createTestPost(['user_id' => $this->createUser()->id]);
|
||||
|
||||
// 추천 1회 등록
|
||||
$this->actingAs($reactor, 'sanctum')
|
||||
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
|
||||
->assertOk();
|
||||
|
||||
// 게시글 상세 조회 — reaction_counts 는 유형 ID 키로 개수를 담아야 한다
|
||||
$detail = $this->actingAs($reactor, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
$detail->assertOk()
|
||||
->assertJsonPath("data.reaction_counts.{$this->likeId}", 1)
|
||||
->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 0)
|
||||
->assertJsonPath('data.my_reaction_type_id', $this->likeId);
|
||||
|
||||
// 키가 유형 ID 로 보존되는지 직접 확인 (0-기반 재인덱싱 아님)
|
||||
$counts = $detail->json('data.reaction_counts');
|
||||
$this->assertArrayHasKey((string) $this->likeId, $counts);
|
||||
$this->assertArrayNotHasKey('0', $counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응 등록/전환/해제 시 활동 로그가 기록된다 (after_react 훅 → 리스너).
|
||||
*
|
||||
|
||||
@@ -38,6 +38,8 @@ axes:
|
||||
- reaction_type_label_localized
|
||||
- reaction_type_label_falls_back
|
||||
- react_logs_activity
|
||||
- react_failure_rolls_back
|
||||
- react_pending_disables_button
|
||||
|
||||
effects:
|
||||
# 등록/전환/해제 + 카운트 증감 정합성
|
||||
@@ -67,6 +69,9 @@ effects:
|
||||
- missing_locale_falls_back_to_default
|
||||
# 활동 로그
|
||||
- react_logs_add_change_remove_activity
|
||||
# 프론트 UX (낙관 하이라이트 + 응답값 개수 + 실패 롤백)
|
||||
- reaction_failure_rolls_back_optimistic_state
|
||||
- reaction_pending_disables_button
|
||||
# E2E 흐름
|
||||
- detail_register_then_switch_then_remove_updates_ui
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
|
||||
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
|
||||
- 주문서에서 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 선택해 주문할 수 있습니다. 선택한 결제수단은 주문 시 대응하는 기본 결제수단으로 전송되어, 결제창 호출과 이후 주문 처리가 정상적으로 이어집니다.
|
||||
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 본인이 쓴 글에서는 버튼이 눌리지 않고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
|
||||
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 버튼을 누르면 즉시 반응 표시가 반영되고, 처리 중에는 잠깐 로딩이 표시되며, 처리에 실패하면 원래 상태로 되돌리고 안내합니다. 반응 버튼은 게시글 하단의 수정·삭제 버튼과 같은 줄에 배치됩니다. 본인이 쓴 글에서는 버튼이 눌리지 않고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
|
||||
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
|
||||
|
||||
### Changed
|
||||
|
||||
+74
-10
@@ -48,7 +48,7 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
// reaction off / 유형 0개 두 케이스 모두 이 조건으로 미노출된다.
|
||||
// @scenario case=detail_hidden_when_use_reaction_off
|
||||
// @effects reaction_area_hidden_when_use_reaction_off
|
||||
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
expect(areas.length).toBe(1);
|
||||
|
||||
const area = areas[0];
|
||||
@@ -61,7 +61,7 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
// @scenario case=detail_shows_active_types_even_zero
|
||||
// @effects active_types_always_shown_even_zero_count
|
||||
it('반응 유형을 reaction_type_options 로 iteration 한다', () => {
|
||||
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
const iterations = collectNodes(areas[0], (n) => n.iteration !== undefined);
|
||||
expect(iterations.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
// @scenario case=author_buttons_disabled
|
||||
// @effects author_sees_buttons_disabled, active_types_always_shown_even_zero_count, my_reaction_type_highlighted
|
||||
it('유형별 버튼은 본인 글이면 disabled 이고 개수를 바인딩한다 (확정 08·09)', () => {
|
||||
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -91,8 +91,8 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
expect(counts[0].text as string).toContain('?? 0');
|
||||
});
|
||||
|
||||
it('클릭 액션은 비로그인/로그인 분기 — react API 호출 + post 재조회 (확정 03·07)', () => {
|
||||
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
|
||||
it('클릭 액션은 비로그인/로그인 분기 — 로그인은 낙관 하이라이트+react API, 비로그인은 안내+로그인 (확정 03·07)', () => {
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
|
||||
const actions = (buttons[0].actions as Node[]) ?? [];
|
||||
expect(actions.length).toBeGreaterThan(0);
|
||||
@@ -104,12 +104,30 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
expect(cases.logged_in).toBeDefined();
|
||||
expect(cases.not_logged_in).toBeDefined();
|
||||
|
||||
// 로그인 사용자: react API 호출 후 post 데이터소스 재조회
|
||||
// 로그인 사용자: sequence(낙관 하이라이트 setState → react apiCall)
|
||||
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);
|
||||
expect(loggedIn.handler).toBe('sequence');
|
||||
const seq = loggedIn.actions as Node[];
|
||||
// 첫 액션: 클릭 즉시 하이라이트 낙관 이동 + 처리 중 표시
|
||||
const firstSetState = seq[0];
|
||||
expect(firstSetState.handler).toBe('setState');
|
||||
const firstParams = firstSetState.params as Record<string, unknown>;
|
||||
expect(firstParams.reactingTypeId).toBeDefined();
|
||||
expect(firstParams.myReactionTypeId).toBeDefined();
|
||||
// 개수는 낙관적으로 건드리지 않는다 (reactionCounts 는 응답에서만)
|
||||
expect(firstParams.reactionCounts).toBeUndefined();
|
||||
|
||||
// apiCall: react + 응답값으로 확정(refetch 없음)
|
||||
const apiCall = seq.find((a) => a.handler === 'apiCall')!;
|
||||
expect(apiCall).toBeDefined();
|
||||
expect(apiCall.target as string).toContain('/react');
|
||||
const onSuccess = apiCall.onSuccess as Node[];
|
||||
// 성공: refetch 대신 응답값(reaction_counts/my_reaction_type_id)으로 setState
|
||||
expect(onSuccess.some((a) => a.handler === 'refetchDataSource')).toBe(false);
|
||||
const successSet = onSuccess.find((a) => a.handler === 'setState')!;
|
||||
const successParams = successSet.params as Record<string, unknown>;
|
||||
expect(String(successParams.reactionCounts)).toContain('response.data.reaction_counts');
|
||||
expect(String(successParams.myReactionTypeId)).toContain('response.data.my_reaction_type_id');
|
||||
|
||||
// 비로그인: 안내 후 로그인 유도 (redirect 파라미터 전달)
|
||||
const guest = cases.not_logged_in;
|
||||
@@ -121,4 +139,50 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
|
||||
expect(navParams.path).toBe('/login');
|
||||
expect((navParams.query as Record<string, unknown>).redirect).toContain('/board/');
|
||||
});
|
||||
|
||||
// @scenario case=react_failure_rolls_back
|
||||
// @effects reaction_failure_rolls_back_optimistic_state
|
||||
it('실패 시 낙관 상태를 롤백하고 안내 토스트를 발화한다 (onError)', () => {
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
|
||||
const action = (buttons[0].actions as Node[])[0];
|
||||
const apiCall = ((action.cases as Record<string, Node>).logged_in.actions as Node[])
|
||||
.find((a) => a.handler === 'apiCall')!;
|
||||
|
||||
const onError = apiCall.onError as Node[];
|
||||
expect(onError.length).toBeGreaterThan(0);
|
||||
|
||||
// 롤백: override 를 초기값으로 되돌리는 setState (reactionApplied=false, override 클리어)
|
||||
const rollback = onError.find((a) => a.handler === 'setState')!;
|
||||
expect(rollback).toBeDefined();
|
||||
const rp = rollback.params as Record<string, unknown>;
|
||||
expect(rp.reactionApplied).toBe(false);
|
||||
expect(rp.myReactionTypeId).toBe(null);
|
||||
expect(rp.reactionCounts).toBe(null);
|
||||
expect(rp.reactingTypeId).toBe(null);
|
||||
|
||||
// 안내 토스트 (하드코딩 아닌 $t: 키)
|
||||
const toast = onError.find((a) => a.handler === 'toast')!;
|
||||
expect(toast).toBeDefined();
|
||||
const tp = toast.params as Record<string, unknown>;
|
||||
expect(tp.type).toBe('error');
|
||||
expect(String(tp.message)).toContain('$t:');
|
||||
});
|
||||
|
||||
// @scenario case=react_pending_disables_button
|
||||
// @effects reaction_pending_disables_button
|
||||
it('처리 중(reactingTypeId)에는 버튼이 비활성이고 스피너가 표시된다 (중복 클릭 차단)', () => {
|
||||
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
|
||||
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
|
||||
const btn = buttons[0];
|
||||
const props = btn.props as Record<string, unknown>;
|
||||
// disabled 가 reactingTypeId 에 의존 (처리 중 비활성)
|
||||
expect(String(props.disabled)).toContain('reactingTypeId');
|
||||
|
||||
// 스피너: 처리 중인 유형이면 fa-spinner + spin
|
||||
const icon = collectNodes(btn, (n) => n.name === 'Icon')[0];
|
||||
const iconProps = icon.props as Record<string, unknown>;
|
||||
expect(String(iconProps.name)).toContain('fa-spinner');
|
||||
expect(String(iconProps.spin)).toContain('reactingTypeId');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
"agree": "Agree",
|
||||
"thanks": "Thanks",
|
||||
"wow": "Wow",
|
||||
"login_required": "You need to sign in to use this feature."
|
||||
"login_required": "You need to sign in to use this feature.",
|
||||
"failed": "Failed to process the reaction. Please try again."
|
||||
},
|
||||
"attachments": {
|
||||
"title": "Attachments",
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
"agree": "동의해요",
|
||||
"thanks": "감사해요",
|
||||
"wow": "놀라워요",
|
||||
"login_required": "로그인이 필요한 기능입니다"
|
||||
"login_required": "로그인이 필요한 기능입니다",
|
||||
"failed": "반응 처리에 실패했습니다. 잠시 후 다시 시도해주세요."
|
||||
},
|
||||
"attachments": {
|
||||
"title": "첨부파일",
|
||||
|
||||
@@ -928,14 +928,23 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "게시글 반응(추천/비추천) 영역 — 반응 사용 on + 게시판이 켠 유형이 1개 이상일 때만 노출(확정 11). 활성 유형을 iteration 으로 순회해 유형별 버튼(아이콘+라벨+개수, 개수 0도 항상 노출, 확정 09). 본인 글은 버튼/개수는 노출하되 클릭 불가(확정 08). 비로그인은 안내 후 로그인 유도, 로그인 사용자는 같은 유형 재클릭=해제·다른 유형=전환.",
|
||||
"comment": "게시글 하단 바 — 반응(추천/비추천) 영역과 액션 버튼(답변/신고/수정/삭제)을 한 줄에 좌우로 배치. 반응은 왼쪽, 액션은 오른쪽. 둘 중 하나만 표시될 때도 justify-between 으로 정렬 유지. 둘 다 없으면 바 자체 미노출.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{post?.data?.board?.use_reaction && (post?.data?.board?.reaction_type_options ?? []).length > 0}}",
|
||||
"if": "{{(post?.data?.board?.use_reaction && (post?.data?.board?.reaction_type_options ?? []).length > 0) || post?.data?.is_guest_post || post?.data?.is_author || post?.data?.abilities?.can_manage || post?.data?.board?.use_reply || (_global.currentUser?.uuid && post?.data?.board?.use_report)}}",
|
||||
"props": {
|
||||
"className": "flex flex-wrap items-center gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
|
||||
"className": "flex flex-wrap items-center justify-between gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
@@ -946,13 +955,13 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "반응 유형 버튼 — my_reaction_type_id 와 일치하면 하이라이트, 본인 글이면 비활성화",
|
||||
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성). 실패 시 override 를 클릭 전 값으로 롤백. 본인 글은 항상 비활성.",
|
||||
"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'}}"
|
||||
"disabled": "{{post?.data?.is_author === true || (_local?.reactingTypeId ?? null) !== null}}",
|
||||
"className": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id ? 'px-4 py-2 text-sm border border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg flex items-center gap-1.5 font-medium 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 || (_local?.reactingTypeId ?? null) !== null) ? ' cursor-not-allowed opacity-60' : ' cursor-pointer'}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -963,30 +972,61 @@
|
||||
},
|
||||
"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": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"comment": "클릭 즉시 — 하이라이트를 눌린 유형으로 낙관 이동(같은 유형 재클릭이면 해제 예측), 처리 중 표시. 개수는 건드리지 않고 응답값으로만 확정.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"dataSourceId": "post"
|
||||
"target": "local",
|
||||
"reactingTypeId": "{{reactionType.id}}",
|
||||
"reactionApplied": true,
|
||||
"myReactionTypeId": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id ? null : reactionType.id}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-board/boards/{{route.slug}}/posts/{{route.id}}/react",
|
||||
"auth_mode": "required",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"reaction_type_id": "{{reactionType.id}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"comment": "서버 확정값으로 하이라이트·개수 갱신 (refetch 없이 응답값만). 응답만 오면 개수는 100% 정확.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"reactingTypeId": null,
|
||||
"reactionApplied": true,
|
||||
"myReactionTypeId": "{{response.data.my_reaction_type_id ?? null}}",
|
||||
"reactionCounts": "{{response.data.reaction_counts ?? {}}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"comment": "실패 롤백 — override 를 서버 초기값(post.data)으로 되돌리고 로딩 해제",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"reactingTypeId": null,
|
||||
"reactionApplied": false,
|
||||
"myReactionTypeId": null,
|
||||
"reactionCounts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "$t:board.reaction.failed"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1016,10 +1056,12 @@
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"comment": "처리 중인 유형은 스피너, 그 외엔 유형 아이콘",
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
|
||||
"name": "{{(_local?.reactingTypeId ?? null) === reactionType.id ? 'fa-spinner' : (reactionType.icon_name ?? 'fa-thumbs-up')}}",
|
||||
"spin": "{{(_local?.reactingTypeId ?? null) === reactionType.id}}",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
@@ -1034,7 +1076,7 @@
|
||||
"props": {
|
||||
"className": "tabular-nums"
|
||||
},
|
||||
"text": "{{post?.data?.reaction_counts?.[reactionType.id] ?? 0}}"
|
||||
"text": "{{(_local?.reactionCounts ?? post?.data?.reaction_counts)?.[reactionType.id] ?? 0}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1043,12 +1085,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "게시글 액션 버튼 영역 (답변, 신고, 수정, 삭제) - 비회원 글은 항상 표시 (수정/삭제용)",
|
||||
"comment": "액션 버튼 영역 (답변, 신고, 수정, 삭제) - 비회원 글은 항상 표시 (수정/삭제용). 하단 바 오른쪽에 배치. 반응 영역이 없으면 justify-between 이 이 그룹만 오른쪽으로 민다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{post?.data?.is_guest_post || post?.data?.is_author || post?.data?.abilities?.can_manage || post?.data?.board?.use_reply || (_global.currentUser?.uuid && post?.data?.board?.use_report)}}",
|
||||
"props": {
|
||||
"className": "flex justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
|
||||
"className": "flex items-center justify-end gap-2 ml-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -1258,6 +1300,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user