feat: 채팅 입력창 파일 드래그앤드롭 첨부 지원
@3ea6cc34a5bc33d467f9cce67d2735b62bef9d6f
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
... | ... | @@ -212,6 +212,24 @@ |
| 212 | 212 |
expect(screen.queryByText('계약서.pdf')).toBeNull()
|
| 213 | 213 |
}) |
| 214 | 214 |
|
| 215 |
+ it('입력창에 파일을 드래그앤드롭하면 첨부된다', async () => {
|
|
| 216 |
+ mocks.getPosts.mockResolvedValue([]) |
|
| 217 |
+ |
|
| 218 |
+ render(<ChannelPosts org={org()} />)
|
|
| 219 |
+ |
|
| 220 |
+ const dropzone = screen.getByTestId('composer-dropzone')
|
|
| 221 |
+ const file = new File(['x'], '드랍파일.pdf', { type: 'application/pdf' })
|
|
| 222 |
+ |
|
| 223 |
+ fireEvent.dragEnter(dropzone, { dataTransfer: { files: [file] } })
|
|
| 224 |
+ expect(screen.getByText('파일을 여기에 놓으면 첨부됩니다')).toBeTruthy()
|
|
| 225 |
+ |
|
| 226 |
+ fireEvent.drop(dropzone, { dataTransfer: { files: [file] } })
|
|
| 227 |
+ |
|
| 228 |
+ expect(screen.getByText('드랍파일.pdf')).toBeTruthy()
|
|
| 229 |
+ expect(screen.queryByText('파일을 여기에 놓으면 첨부됩니다')).toBeNull()
|
|
| 230 |
+ expect(screen.getByRole('button', { name: '보내기' })).toBeEnabled()
|
|
| 231 |
+ }) |
|
| 232 |
+ |
|
| 215 | 233 |
it('공지가 있으면 배너로 보이고 확인 후 해제할 수 있다', async () => {
|
| 216 | 234 |
mocks.getPosts.mockResolvedValue([]) |
| 217 | 235 |
mocks.getNotice.mockResolvedValue(post({ id: 'n1', message: '중요 공지입니다' }))
|
--- frontend/src/components/ChannelPosts.tsx
+++ frontend/src/components/ChannelPosts.tsx
... | ... | @@ -179,10 +179,14 @@ |
| 179 | 179 |
const [sending, setSending] = useState(false) |
| 180 | 180 |
const [sendError, setSendError] = useState<string | null>(null) |
| 181 | 181 |
const [noticeWarning, setNoticeWarning] = useState<string | null>(null) |
| 182 |
+ const [dragging, setDragging] = useState(false) |
|
| 182 | 183 |
|
| 183 | 184 |
const containerRef = useRef<HTMLDivElement>(null) |
| 184 | 185 |
const fileInputRef = useRef<HTMLInputElement>(null) |
| 185 | 186 |
const prevCountRef = useRef(0) |
| 187 |
+ // dragenter/dragleave는 자식 요소를 지날 때마다 쌍으로 발생하므로, |
|
| 188 |
+ // 카운터로 세지 않으면 안내 표시가 깜빡인다 |
|
| 189 |
+ const dragDepthRef = useRef(0) |
|
| 186 | 190 |
|
| 187 | 191 |
useEffect(() => {
|
| 188 | 192 |
if (bothMissing) {
|
... | ... | @@ -331,12 +335,43 @@ |
| 331 | 335 |
)} |
| 332 | 336 |
</div> |
| 333 | 337 |
|
| 334 |
- {/* 입력창 */}
|
|
| 338 |
+ {/* 입력창 — 파일을 끌어다 놓으면 첨부된다 */}
|
|
| 335 | 339 |
<div |
| 336 |
- className={`mt-3 rounded-lg border bg-white p-3 ${
|
|
| 337 |
- asNotice ? 'border-amber-300' : 'border-gray-200' |
|
| 340 |
+ data-testid="composer-dropzone" |
|
| 341 |
+ onDragEnter={(e) => {
|
|
| 342 |
+ e.preventDefault() |
|
| 343 |
+ dragDepthRef.current += 1 |
|
| 344 |
+ setDragging(true) |
|
| 345 |
+ }} |
|
| 346 |
+ onDragOver={(e) => e.preventDefault()}
|
|
| 347 |
+ onDragLeave={() => {
|
|
| 348 |
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) |
|
| 349 |
+ if (dragDepthRef.current === 0) {
|
|
| 350 |
+ setDragging(false) |
|
| 351 |
+ } |
|
| 352 |
+ }} |
|
| 353 |
+ onDrop={(e) => {
|
|
| 354 |
+ e.preventDefault() |
|
| 355 |
+ dragDepthRef.current = 0 |
|
| 356 |
+ setDragging(false) |
|
| 357 |
+ const dropped = Array.from(e.dataTransfer?.files ?? []) |
|
| 358 |
+ if (dropped.length > 0) {
|
|
| 359 |
+ setAttachments((prev) => [...prev, ...dropped]) |
|
| 360 |
+ } |
|
| 361 |
+ }} |
|
| 362 |
+ className={`mt-3 rounded-lg border bg-white p-3 transition-colors ${
|
|
| 363 |
+ dragging |
|
| 364 |
+ ? 'border-2 border-dashed border-blue-400 bg-blue-50/50' |
|
| 365 |
+ : asNotice |
|
| 366 |
+ ? 'border-amber-300' |
|
| 367 |
+ : 'border-gray-200' |
|
| 338 | 368 |
}`} |
| 339 | 369 |
> |
| 370 |
+ {dragging && (
|
|
| 371 |
+ <p className="mb-2 text-center text-xs font-medium text-blue-600"> |
|
| 372 |
+ 파일을 여기에 놓으면 첨부됩니다 |
|
| 373 |
+ </p> |
|
| 374 |
+ )} |
|
| 340 | 375 |
{attachments.length > 0 && (
|
| 341 | 376 |
<div className="mb-2 flex flex-wrap gap-1.5"> |
| 342 | 377 |
{attachments.map((file, i) => (
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?