fix(board): 게시글 반응 개수 표시 결함 수정 + 반응 버튼 UX 개선
Chrome MCP 검수 중 발견한 결함 수정 및 요청 반영. - 게시글 상세 응답의 reaction_counts 가 배열로 붕괴되어 개수가 0으로 표시되던 문제 수정 (PostResource → stdClass 로 JSON 객체 보장) + 회귀 테스트 - 반응 버튼을 수정·삭제 버튼과 같은 줄에 배치 - 반응 UX 개선: 클릭 즉시 하이라이트 반영 + 개수는 응답값으로 갱신(전체 재조회 제거) + 처리 중 로딩·중복클릭 차단 + 실패 시 롤백·안내 - board.md 상세 응답 필드 + basic CHANGELOG + 시나리오 매니페스트 갱신
This commit is contained in:
1 parent
a4632eb47e
commit
facbd98b14
9 files changed
+212
-47
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user