fix: 채널 생성 화면의 재로드 안전성, 중복 클릭 방지, 오류 처리를 개선
- OrgDetail의 초기화 effect를 org 객체 identity 대신 org.id에 키잉해, 채널 생성 성공 후 App.reload()가 만드는 새 객체가 방금 받은 provision 결과/에러/입력 중이던 담당자 정보를 지워버리지 않게 함(다른 기관으로 전환할 때는 그대로 초기화). - 양쪽 채널이 모두 실패했을 때 "문정원 채널은 살아있다"는 잘못된 안내를 보여주던 문제를 3분기(둘 다 실패/법률검토만 실패/문정원만 실패)로 재구성해 해결. - 정보 저장/채널 생성/시드 업로드에 busy 상태를 추가해 처리 중 중복 클릭으로 두 요청이 동시에 나가는 것을 방지. ConfirmDialog도 busy 중엔 두 버튼을 비활성화. - api/client에 상태코드를 담는 ApiError를 도입해 401만 로그아웃으로 취급하고, 그 외 오류는 재시도 버튼이 있는 오류 화면으로 안내(App.tsx). - 필수 담당자 정보(부서명/담당자명/연락처/이메일)가 비어 있으면 정보 저장 버튼을 비활성화(직급/직함은 선택 유지). - 로그인/기관검색 입력에 aria-label과 autoComplete 추가, StatusBadge READY 케이스 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context)
@6fc9ca73271050cab33fe955ddb2cdd6685b5112
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 |
import { useCallback, useEffect, useState } from 'react'
|
| 2 |
-import { getOrgs, type Org } from './api/client'
|
|
| 2 |
+import { ApiError, getOrgs, type Org } from './api/client'
|
|
| 3 | 3 |
import LoginPage from './components/LoginPage' |
| 4 | 4 |
import OrgDetail from './components/OrgDetail' |
| 5 | 5 |
import OrgList from './components/OrgList' |
... | ... | @@ -7,19 +7,30 @@ |
| 7 | 7 |
|
| 8 | 8 |
// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도 |
| 9 | 9 |
// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다. |
| 10 |
-type AuthState = 'checking' | 'authenticated' | 'anonymous' |
|
| 10 |
+// |
|
| 11 |
+// 'error'는 401이 아닌 실패(서버 500, 네트워크 단절 등)를 위한 상태다. 이런 오류까지 |
|
| 12 |
+// '로그아웃'으로 취급해 로그인 화면으로 보내면, 로그인해도 같은 오류가 반복되고 |
|
| 13 |
+// 원인을 알 수 없게 된다 - 세션은 멀쩡한데 서버/네트워크만 문제인 경우이므로 |
|
| 14 |
+// 오류 메시지와 재시도 버튼을 보여주는 편이 맞다. |
|
| 15 |
+type AuthState = 'checking' | 'authenticated' | 'anonymous' | 'error' |
|
| 11 | 16 |
|
| 12 | 17 |
export default function App() {
|
| 13 | 18 |
const [orgs, setOrgs] = useState<Org[]>([]) |
| 14 | 19 |
const [selectedId, setSelectedId] = useState<number | null>(null) |
| 15 | 20 |
const [auth, setAuth] = useState<AuthState>('checking')
|
| 21 |
+ const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null) |
|
| 16 | 22 |
|
| 17 | 23 |
const reload = useCallback(async () => {
|
| 18 | 24 |
try {
|
| 19 | 25 |
setOrgs(await getOrgs()) |
| 20 | 26 |
setAuth('authenticated')
|
| 21 |
- } catch {
|
|
| 22 |
- setAuth('anonymous')
|
|
| 27 |
+ } catch (e) {
|
|
| 28 |
+ if (e instanceof ApiError && e.status === 401) {
|
|
| 29 |
+ setAuth('anonymous')
|
|
| 30 |
+ } else {
|
|
| 31 |
+ setAuthErrorMessage((e as Error).message) |
|
| 32 |
+ setAuth('error')
|
|
| 33 |
+ } |
|
| 23 | 34 |
} |
| 24 | 35 |
}, []) |
| 25 | 36 |
|
... | ... | @@ -35,6 +46,21 @@ |
| 35 | 46 |
return <LoginPage onSuccess={() => void reload()} />
|
| 36 | 47 |
} |
| 37 | 48 |
|
| 49 |
+ if (auth === 'error') {
|
|
| 50 |
+ return ( |
|
| 51 |
+ <div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm"> |
|
| 52 |
+ <p className="text-red-600">{authErrorMessage}</p>
|
|
| 53 |
+ <button |
|
| 54 |
+ type="button" |
|
| 55 |
+ onClick={() => void reload()}
|
|
| 56 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" |
|
| 57 |
+ > |
|
| 58 |
+ 다시 시도 |
|
| 59 |
+ </button> |
|
| 60 |
+ </div> |
|
| 61 |
+ ) |
|
| 62 |
+ } |
|
| 63 |
+ |
|
| 38 | 64 |
const selected = orgs.find((org) => org.id === selectedId) ?? null |
| 39 | 65 |
|
| 40 | 66 |
return ( |
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 |
import { afterEach, describe, expect, it, vi } from 'vitest'
|
| 2 |
-import { getOrgs, provisionChannels, updateContact } from './client'
|
|
| 2 |
+import { ApiError, getOrgs, provisionChannels, updateContact } from './client'
|
|
| 3 | 3 |
|
| 4 | 4 |
afterEach(() => {
|
| 5 | 5 |
vi.unstubAllGlobals() |
... | ... | @@ -35,15 +35,19 @@ |
| 35 | 35 |
expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
|
| 36 | 36 |
}) |
| 37 | 37 |
|
| 38 |
- it('응답이 실패면 예외를 던진다', async () => {
|
|
| 38 |
+ it('응답이 실패면 status를 담은 ApiError를 던진다', async () => {
|
|
| 39 | 39 |
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
| 40 | 40 |
ok: false, |
| 41 | 41 |
status: 400, |
| 42 | 42 |
text: async () => 'bad request', |
| 43 | 43 |
})) |
| 44 | 44 |
|
| 45 |
- await expect(updateContact(1, {
|
|
| 45 |
+ const promise = updateContact(1, {
|
|
| 46 | 46 |
deptName: '', managerName: '', managerTitle: '', managerPhone: '', managerEmail: '', |
| 47 |
- })).rejects.toThrow() |
|
| 47 |
+ }) |
|
| 48 |
+ |
|
| 49 |
+ await expect(promise).rejects.toThrow() |
|
| 50 |
+ await expect(promise).rejects.toBeInstanceOf(ApiError) |
|
| 51 |
+ await expect(promise).rejects.toMatchObject({ status: 400 })
|
|
| 48 | 52 |
}) |
| 49 | 53 |
}) |
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -36,6 +36,22 @@ |
| 36 | 36 |
total: number |
| 37 | 37 |
} |
| 38 | 38 |
|
| 39 |
+/** |
|
| 40 |
+ * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가 |
|
| 41 |
+ * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 - |
|
| 42 |
+ * 그렇지 않으면(예: 평범한 Error) 서버 오류나 네트워크 문제까지 전부 로그아웃으로 |
|
| 43 |
+ * 오인해 사용자를 로그인 화면으로 쫓아내게 된다. |
|
| 44 |
+ */ |
|
| 45 |
+export class ApiError extends Error {
|
|
| 46 |
+ constructor( |
|
| 47 |
+ public status: number, |
|
| 48 |
+ message: string, |
|
| 49 |
+ ) {
|
|
| 50 |
+ super(message) |
|
| 51 |
+ this.name = 'ApiError' |
|
| 52 |
+ } |
|
| 53 |
+} |
|
| 54 |
+ |
|
| 39 | 55 |
/** Spring Security가 내려주는 CSRF 쿠키를 읽어 헤더로 되돌려 보낸다. */ |
| 40 | 56 |
function csrfToken(): string {
|
| 41 | 57 |
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/) |
... | ... | @@ -54,7 +70,7 @@ |
| 54 | 70 |
|
| 55 | 71 |
if (!response.ok) {
|
| 56 | 72 |
const body = await response.text() |
| 57 |
- throw new Error(`요청 실패 (${response.status}): ${body}`)
|
|
| 73 |
+ throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
|
|
| 58 | 74 |
} |
| 59 | 75 |
return response.json() as Promise<T> |
| 60 | 76 |
} |
... | ... | @@ -93,6 +109,6 @@ |
| 93 | 109 |
body, |
| 94 | 110 |
}) |
| 95 | 111 |
if (!response.ok) {
|
| 96 |
- throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.')
|
|
| 112 |
+ throw new ApiError(response.status, '아이디 또는 비밀번호가 올바르지 않습니다.') |
|
| 97 | 113 |
} |
| 98 | 114 |
} |
--- frontend/src/components/ConfirmDialog.tsx
+++ frontend/src/components/ConfirmDialog.tsx
... | ... | @@ -4,9 +4,18 @@ |
| 4 | 4 |
confirmLabel: string |
| 5 | 5 |
onConfirm: () => void |
| 6 | 6 |
onCancel: () => void |
| 7 |
+ /** 확인 액션이 진행 중일 때 true. 두 버튼을 모두 비활성화해 중복 클릭을 막는다. */ |
|
| 8 |
+ busy?: boolean |
|
| 7 | 9 |
} |
| 8 | 10 |
|
| 9 |
-export default function ConfirmDialog({ title, lines, confirmLabel, onConfirm, onCancel }: Props) {
|
|
| 11 |
+export default function ConfirmDialog({
|
|
| 12 |
+ title, |
|
| 13 |
+ lines, |
|
| 14 |
+ confirmLabel, |
|
| 15 |
+ onConfirm, |
|
| 16 |
+ onCancel, |
|
| 17 |
+ busy = false, |
|
| 18 |
+}: Props) {
|
|
| 10 | 19 |
return ( |
| 11 | 20 |
<div className="fixed inset-0 flex items-center justify-center bg-black/30"> |
| 12 | 21 |
<div className="w-96 rounded-lg bg-white p-5"> |
... | ... | @@ -21,15 +30,17 @@ |
| 21 | 30 |
<div className="mt-5 flex justify-end gap-2"> |
| 22 | 31 |
<button |
| 23 | 32 |
type="button" |
| 33 |
+ disabled={busy}
|
|
| 24 | 34 |
onClick={onCancel}
|
| 25 |
- className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" |
|
| 35 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50" |
|
| 26 | 36 |
> |
| 27 | 37 |
취소 |
| 28 | 38 |
</button> |
| 29 | 39 |
<button |
| 30 | 40 |
type="button" |
| 41 |
+ disabled={busy}
|
|
| 31 | 42 |
onClick={onConfirm}
|
| 32 |
- className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white" |
|
| 43 |
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" |
|
| 33 | 44 |
> |
| 34 | 45 |
{confirmLabel}
|
| 35 | 46 |
</button> |
--- frontend/src/components/LoginPage.tsx
+++ frontend/src/components/LoginPage.tsx
... | ... | @@ -26,6 +26,8 @@ |
| 26 | 26 |
<input |
| 27 | 27 |
className="mt-6 w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
| 28 | 28 |
placeholder="아이디" |
| 29 |
+ aria-label="아이디" |
|
| 30 |
+ autoComplete="username" |
|
| 29 | 31 |
value={username}
|
| 30 | 32 |
onChange={(e) => setUsername(e.target.value)}
|
| 31 | 33 |
/> |
... | ... | @@ -33,6 +35,8 @@ |
| 33 | 35 |
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
| 34 | 36 |
type="password" |
| 35 | 37 |
placeholder="비밀번호" |
| 38 |
+ aria-label="비밀번호" |
|
| 39 |
+ autoComplete="current-password" |
|
| 36 | 40 |
value={password}
|
| 37 | 41 |
onChange={(e) => setPassword(e.target.value)}
|
| 38 | 42 |
/> |
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
... | ... | @@ -112,4 +112,130 @@ |
| 112 | 112 |
expect(screen.getByText(/다시 눌러도 안전/)).toBeTruthy() |
| 113 | 113 |
}) |
| 114 | 114 |
}) |
| 115 |
+ |
|
| 116 |
+ it('양쪽 다 실패하면 문정원 채널이 살아있다는 잘못된 안내를 보여주지 않는다', async () => {
|
|
| 117 |
+ mocks.provisionChannels.mockResolvedValue({
|
|
| 118 |
+ mj: 'FAILED', |
|
| 119 |
+ law: 'FAILED', |
|
| 120 |
+ message: '[문정원] 서버 오류\n[법률검토] 서버 오류', |
|
| 121 |
+ }) |
|
| 122 |
+ |
|
| 123 |
+ render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
|
|
| 124 |
+ |
|
| 125 |
+ fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
|
|
| 126 |
+ fireEvent.click(screen.getByRole('button', { name: '생성' }))
|
|
| 127 |
+ |
|
| 128 |
+ await waitFor(() => {
|
|
| 129 |
+ expect(screen.getByText(/문정원 채널.*법률검토 채널.*모두 생성에 실패/)).toBeTruthy() |
|
| 130 |
+ }) |
|
| 131 |
+ expect(screen.queryByText(/문정원 채널은 그대로 남아 있으니/)).toBeNull() |
|
| 132 |
+ }) |
|
| 133 |
+ |
|
| 134 |
+ it('문정원만 실패하면 법률검토 채널이 살아있다는 안내를 보여준다', async () => {
|
|
| 135 |
+ mocks.provisionChannels.mockResolvedValue({
|
|
| 136 |
+ mj: 'FAILED', |
|
| 137 |
+ law: 'CREATED', |
|
| 138 |
+ message: '[문정원] 서버 오류', |
|
| 139 |
+ }) |
|
| 140 |
+ |
|
| 141 |
+ render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
|
|
| 142 |
+ |
|
| 143 |
+ fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
|
|
| 144 |
+ fireEvent.click(screen.getByRole('button', { name: '생성' }))
|
|
| 145 |
+ |
|
| 146 |
+ await waitFor(() => {
|
|
| 147 |
+ expect(screen.getByText(/문정원 채널 생성에 실패/)).toBeTruthy() |
|
| 148 |
+ expect(screen.getByText(/법률검토 채널은 그대로 남아 있으니/)).toBeTruthy() |
|
| 149 |
+ }) |
|
| 150 |
+ }) |
|
| 151 |
+ |
|
| 152 |
+ it('채널 생성이 진행 중이면 생성 버튼과 채널 생성 버튼이 비활성화된다', async () => {
|
|
| 153 |
+ let resolvePromise: (value: { mj: string; law: string; message: string }) => void = () => {}
|
|
| 154 |
+ mocks.provisionChannels.mockReturnValue( |
|
| 155 |
+ new Promise((resolve) => {
|
|
| 156 |
+ resolvePromise = resolve |
|
| 157 |
+ }), |
|
| 158 |
+ ) |
|
| 159 |
+ |
|
| 160 |
+ render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
|
|
| 161 |
+ |
|
| 162 |
+ fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
|
|
| 163 |
+ fireEvent.click(screen.getByRole('button', { name: '생성' }))
|
|
| 164 |
+ |
|
| 165 |
+ await waitFor(() => {
|
|
| 166 |
+ expect(screen.getByRole('button', { name: '생성' })).toBeDisabled()
|
|
| 167 |
+ }) |
|
| 168 |
+ expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled()
|
|
| 169 |
+ |
|
| 170 |
+ resolvePromise({ mj: 'CREATED', law: 'CREATED', message: '' })
|
|
| 171 |
+ |
|
| 172 |
+ await waitFor(() => {
|
|
| 173 |
+ expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled()
|
|
| 174 |
+ }) |
|
| 175 |
+ }) |
|
| 176 |
+ |
|
| 177 |
+ it('필수 항목이 비어있으면 정보 저장 버튼이 비활성화된다', () => {
|
|
| 178 |
+ render(<OrgDetail org={org()} onChanged={() => {}} />)
|
|
| 179 |
+ |
|
| 180 |
+ expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled()
|
|
| 181 |
+ }) |
|
| 182 |
+ |
|
| 183 |
+ it('직급/직함이 비어있어도 나머지 필수 항목만 채우면 정보 저장 버튼이 활성화된다', () => {
|
|
| 184 |
+ render(<OrgDetail org={org()} onChanged={() => {}} />)
|
|
| 185 |
+ |
|
| 186 |
+ fireEvent.change(screen.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } })
|
|
| 187 |
+ fireEvent.change(screen.getByLabelText('담당자명'), { target: { value: '송민지' } })
|
|
| 188 |
+ fireEvent.change(screen.getByLabelText('연락처'), { target: { value: '02-3475-5434' } })
|
|
| 189 |
+ fireEvent.change(screen.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } })
|
|
| 190 |
+ |
|
| 191 |
+ expect(screen.getByRole('button', { name: '정보 저장' })).toBeEnabled()
|
|
| 192 |
+ }) |
|
| 193 |
+ |
|
| 194 |
+ it('reload로 org 객체가 새로 만들어져도 같은 기관이면 결과가 지워지지 않는다', async () => {
|
|
| 195 |
+ mocks.provisionChannels.mockResolvedValue({
|
|
| 196 |
+ mj: 'CREATED', |
|
| 197 |
+ law: 'FAILED', |
|
| 198 |
+ message: '[법률검토] 서버 오류', |
|
| 199 |
+ }) |
|
| 200 |
+ |
|
| 201 |
+ const { rerender } = render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
|
|
| 202 |
+ |
|
| 203 |
+ fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
|
|
| 204 |
+ fireEvent.click(screen.getByRole('button', { name: '생성' }))
|
|
| 205 |
+ |
|
| 206 |
+ await waitFor(() => {
|
|
| 207 |
+ expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() |
|
| 208 |
+ }) |
|
| 209 |
+ |
|
| 210 |
+ // App.reload()가 만드는, id는 같지만 identity가 다른 새 객체를 흉내낸다. |
|
| 211 |
+ rerender( |
|
| 212 |
+ <OrgDetail |
|
| 213 |
+ org={org({ status: 'PARTIAL', channelIdMj: 'chan-mj' })}
|
|
| 214 |
+ onChanged={() => {}}
|
|
| 215 |
+ />, |
|
| 216 |
+ ) |
|
| 217 |
+ |
|
| 218 |
+ expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() |
|
| 219 |
+ }) |
|
| 220 |
+ |
|
| 221 |
+ it('다른 기관으로 전환하면 이전 결과와 입력값이 초기화된다', async () => {
|
|
| 222 |
+ mocks.provisionChannels.mockResolvedValue({
|
|
| 223 |
+ mj: 'CREATED', |
|
| 224 |
+ law: 'FAILED', |
|
| 225 |
+ message: '[법률검토] 서버 오류', |
|
| 226 |
+ }) |
|
| 227 |
+ |
|
| 228 |
+ const { rerender } = render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
|
|
| 229 |
+ |
|
| 230 |
+ fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
|
|
| 231 |
+ fireEvent.click(screen.getByRole('button', { name: '생성' }))
|
|
| 232 |
+ |
|
| 233 |
+ await waitFor(() => {
|
|
| 234 |
+ expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() |
|
| 235 |
+ }) |
|
| 236 |
+ |
|
| 237 |
+ rerender(<OrgDetail org={org({ id: 2, status: 'READY' })} onChanged={() => {}} />)
|
|
| 238 |
+ |
|
| 239 |
+ expect(screen.queryByText(/법률검토 채널 생성에 실패/)).toBeNull() |
|
| 240 |
+ }) |
|
| 115 | 241 |
}) |
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
... | ... | @@ -27,40 +27,61 @@ |
| 27 | 27 |
} |
| 28 | 28 |
} |
| 29 | 29 |
|
| 30 |
+const REQUIRED_FIELDS: (keyof Contact)[] = [ |
|
| 31 |
+ 'deptName', |
|
| 32 |
+ 'managerName', |
|
| 33 |
+ 'managerPhone', |
|
| 34 |
+ 'managerEmail', |
|
| 35 |
+] |
|
| 36 |
+ |
|
| 30 | 37 |
export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
|
| 31 | 38 |
const [contact, setContact] = useState<Contact>(emptyContact(org)) |
| 32 | 39 |
const [confirming, setConfirming] = useState(false) |
| 33 | 40 |
const [result, setResult] = useState<ProvisionResult | null>(null) |
| 34 | 41 |
const [error, setError] = useState<string | null>(null) |
| 42 |
+ const [busy, setBusy] = useState(false) |
|
| 35 | 43 |
|
| 44 |
+ // org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성 뒤에 |
|
| 45 |
+ // 새 배열/새 객체를 만들어 넘기더라도, 같은 기관을 계속 보고 있는 한 방금 받은 |
|
| 46 |
+ // result/error를 이 effect가 지워버리면 안 되기 때문이다(같은 이유로 입력 중이던 |
|
| 47 |
+ // contact 값도 다른 기관으로 전환할 때만 초기화한다). |
|
| 36 | 48 |
useEffect(() => {
|
| 37 | 49 |
setContact(emptyContact(org)) |
| 38 | 50 |
setResult(null) |
| 39 | 51 |
setError(null) |
| 40 |
- }, [org]) |
|
| 52 |
+ // eslint-disable-next-line react-hooks/exhaustive-deps |
|
| 53 |
+ }, [org.id]) |
|
| 41 | 54 |
|
| 42 | 55 |
const displayMj = `${org.orgNo}_${org.orgName} (문정원)`
|
| 43 | 56 |
const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)`
|
| 44 | 57 |
const canProvision = org.status !== 'INFO_PENDING' |
| 58 |
+ const requiredFilled = REQUIRED_FIELDS.every((key) => contact[key].trim() !== '') |
|
| 45 | 59 |
|
| 46 | 60 |
async function save() {
|
| 47 | 61 |
setError(null) |
| 62 |
+ setBusy(true) |
|
| 48 | 63 |
try {
|
| 49 | 64 |
await updateContact(org.id, contact) |
| 50 | 65 |
onChanged() |
| 51 | 66 |
} catch (e) {
|
| 52 | 67 |
setError((e as Error).message) |
| 68 |
+ } finally {
|
|
| 69 |
+ setBusy(false) |
|
| 53 | 70 |
} |
| 54 | 71 |
} |
| 55 | 72 |
|
| 56 | 73 |
async function provision() {
|
| 57 |
- setConfirming(false) |
|
| 58 | 74 |
setError(null) |
| 75 |
+ setBusy(true) |
|
| 59 | 76 |
try {
|
| 60 |
- setResult(await provisionChannels(org.id)) |
|
| 77 |
+ const provisionResult = await provisionChannels(org.id) |
|
| 78 |
+ setResult(provisionResult) |
|
| 61 | 79 |
onChanged() |
| 62 | 80 |
} catch (e) {
|
| 63 | 81 |
setError((e as Error).message) |
| 82 |
+ } finally {
|
|
| 83 |
+ setBusy(false) |
|
| 84 |
+ setConfirming(false) |
|
| 64 | 85 |
} |
| 65 | 86 |
} |
| 66 | 87 |
|
... | ... | @@ -95,14 +116,15 @@ |
| 95 | 116 |
<div className="mt-5 flex gap-2"> |
| 96 | 117 |
<button |
| 97 | 118 |
type="button" |
| 119 |
+ disabled={busy || !requiredFilled}
|
|
| 98 | 120 |
onClick={() => void save()}
|
| 99 |
- className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" |
|
| 121 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50" |
|
| 100 | 122 |
> |
| 101 | 123 |
정보 저장 |
| 102 | 124 |
</button> |
| 103 | 125 |
<button |
| 104 | 126 |
type="button" |
| 105 |
- disabled={!canProvision}
|
|
| 127 |
+ disabled={busy || !canProvision}
|
|
| 106 | 128 |
onClick={() => setConfirming(true)}
|
| 107 | 129 |
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" |
| 108 | 130 |
> |
... | ... | @@ -118,16 +140,25 @@ |
| 118 | 140 |
<p>문정원 채널: {outcomeLabel(result.mj)}</p>
|
| 119 | 141 |
<p>법률검토 채널: {outcomeLabel(result.law)}</p>
|
| 120 | 142 |
|
| 121 |
- {result.law === 'FAILED' && (
|
|
| 143 |
+ {result.mj === 'FAILED' && result.law === 'FAILED' ? (
|
|
| 122 | 144 |
<p className="mt-2 text-amber-700"> |
| 123 |
- 법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니 |
|
| 124 |
- [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다. |
|
| 145 |
+ 문정원 채널과 법률검토 채널 모두 생성에 실패했습니다. 아직 만들어진 채널이 없으니 [채널 생성]을 다시 눌러도 안전합니다. |
|
| 125 | 146 |
</p> |
| 126 |
- )} |
|
| 127 |
- {result.mj === 'FAILED' && (
|
|
| 128 |
- <p className="mt-2 text-amber-700"> |
|
| 129 |
- 문정원 채널 생성에 실패했습니다. [채널 생성]을 다시 눌러도 안전합니다. |
|
| 130 |
- </p> |
|
| 147 |
+ ) : ( |
|
| 148 |
+ <> |
|
| 149 |
+ {result.law === 'FAILED' && (
|
|
| 150 |
+ <p className="mt-2 text-amber-700"> |
|
| 151 |
+ 법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니 |
|
| 152 |
+ [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다. |
|
| 153 |
+ </p> |
|
| 154 |
+ )} |
|
| 155 |
+ {result.mj === 'FAILED' && (
|
|
| 156 |
+ <p className="mt-2 text-amber-700"> |
|
| 157 |
+ 문정원 채널 생성에 실패했습니다. 법률검토 채널은 그대로 남아 있으니 |
|
| 158 |
+ [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다. |
|
| 159 |
+ </p> |
|
| 160 |
+ )} |
|
| 161 |
+ </> |
|
| 131 | 162 |
)} |
| 132 | 163 |
{result.message && (
|
| 133 | 164 |
<pre className="mt-2 whitespace-pre-wrap text-xs text-gray-500">{result.message}</pre>
|
... | ... | @@ -140,6 +171,7 @@ |
| 140 | 171 |
title="아래 채널 2개를 Mattermost에 생성합니다." |
| 141 | 172 |
lines={[displayMj, displayLaw]}
|
| 142 | 173 |
confirmLabel="생성" |
| 174 |
+ busy={busy}
|
|
| 143 | 175 |
onConfirm={() => void provision()}
|
| 144 | 176 |
onCancel={() => setConfirming(false)}
|
| 145 | 177 |
/> |
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
... | ... | @@ -19,6 +19,7 @@ |
| 19 | 19 |
<input |
| 20 | 20 |
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
| 21 | 21 |
placeholder="기관명 검색" |
| 22 |
+ aria-label="기관명 검색" |
|
| 22 | 23 |
value={keyword}
|
| 23 | 24 |
onChange={(e) => setKeyword(e.target.value)}
|
| 24 | 25 |
/> |
+++ frontend/src/components/SeedUpload.test.tsx
... | ... | @@ -0,0 +1,55 @@ |
| 1 | +import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import SeedUpload from './SeedUpload' | |
| 4 | + | |
| 5 | +const mocks = vi.hoisted(() => ({ | |
| 6 | + uploadSeed: vi.fn(), | |
| 7 | +})) | |
| 8 | + | |
| 9 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 10 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 11 | + uploadSeed: mocks.uploadSeed, | |
| 12 | +})) | |
| 13 | + | |
| 14 | +beforeEach(() => { | |
| 15 | + mocks.uploadSeed.mockReset() | |
| 16 | +}) | |
| 17 | + | |
| 18 | +function selectFile() { | |
| 19 | + const file = new File(['dummy'], 'seed.xlsx', { | |
| 20 | + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
| 21 | + }) | |
| 22 | + const input = document.querySelector('input[type="file"]') as HTMLInputElement | |
| 23 | + fireEvent.change(input, { target: { files: [file] } }) | |
| 24 | +} | |
| 25 | + | |
| 26 | +describe('SeedUpload', () => { | |
| 27 | + /** | |
| 28 | + * Finding I3 회귀 테스트: 업로드가 진행 중일 때 버튼을 다시 누르면 두 번째 업로드 | |
| 29 | + * 요청이 겹쳐서 나갈 수 있다. 업로드 promise가 아직 풀리지 않은 동안 버튼이 | |
| 30 | + * 비활성화되는지 검증한다. | |
| 31 | + */ | |
| 32 | + it('업로드가 진행 중이면 버튼이 비활성화된다', async () => { | |
| 33 | + let resolvePromise: (value: { created: number; updated: number; total: number }) => void = | |
| 34 | + () => {} | |
| 35 | + mocks.uploadSeed.mockReturnValue( | |
| 36 | + new Promise((resolve) => { | |
| 37 | + resolvePromise = resolve | |
| 38 | + }), | |
| 39 | + ) | |
| 40 | + | |
| 41 | + render(<SeedUpload onUploaded={() => {}} />) | |
| 42 | + | |
| 43 | + selectFile() | |
| 44 | + | |
| 45 | + await waitFor(() => { | |
| 46 | + expect(screen.getByRole('button', { name: '신청목록 시트 올리기' })).toBeDisabled() | |
| 47 | + }) | |
| 48 | + | |
| 49 | + resolvePromise({ created: 1, updated: 0, total: 1 }) | |
| 50 | + | |
| 51 | + await waitFor(() => { | |
| 52 | + expect(screen.getByRole('button', { name: '신청목록 시트 올리기' })).toBeEnabled() | |
| 53 | + }) | |
| 54 | + }) | |
| 55 | +}) |
--- frontend/src/components/SeedUpload.tsx
+++ frontend/src/components/SeedUpload.tsx
... | ... | @@ -5,14 +5,18 @@ |
| 5 | 5 |
const inputRef = useRef<HTMLInputElement>(null) |
| 6 | 6 |
const [report, setReport] = useState<SeedReport | null>(null) |
| 7 | 7 |
const [error, setError] = useState<string | null>(null) |
| 8 |
+ const [busy, setBusy] = useState(false) |
|
| 8 | 9 |
|
| 9 | 10 |
async function handle(file: File) {
|
| 10 | 11 |
setError(null) |
| 12 |
+ setBusy(true) |
|
| 11 | 13 |
try {
|
| 12 | 14 |
setReport(await uploadSeed(file)) |
| 13 | 15 |
onUploaded() |
| 14 | 16 |
} catch (e) {
|
| 15 | 17 |
setError((e as Error).message) |
| 18 |
+ } finally {
|
|
| 19 |
+ setBusy(false) |
|
| 16 | 20 |
} |
| 17 | 21 |
} |
| 18 | 22 |
|
... | ... | @@ -31,8 +35,9 @@ |
| 31 | 35 |
/> |
| 32 | 36 |
<button |
| 33 | 37 |
type="button" |
| 38 |
+ disabled={busy}
|
|
| 34 | 39 |
onClick={() => inputRef.current?.click()}
|
| 35 |
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
|
| 40 |
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm disabled:opacity-50" |
|
| 36 | 41 |
> |
| 37 | 42 |
신청목록 시트 올리기 |
| 38 | 43 |
</button> |
--- frontend/src/components/StatusBadge.test.tsx
+++ frontend/src/components/StatusBadge.test.tsx
... | ... | @@ -8,6 +8,11 @@ |
| 8 | 8 |
expect(screen.getByText('정보 대기')).toBeTruthy()
|
| 9 | 9 |
}) |
| 10 | 10 |
|
| 11 |
+ it('생성 가능 상태를 보여준다', () => {
|
|
| 12 |
+ render(<StatusBadge status="READY" />) |
|
| 13 |
+ expect(screen.getByText('생성 가능')).toBeTruthy()
|
|
| 14 |
+ }) |
|
| 15 |
+ |
|
| 11 | 16 |
it('부분 생성 상태를 구분해서 보여준다', () => {
|
| 12 | 17 |
render(<StatusBadge status="PARTIAL" />) |
| 13 | 18 |
expect(screen.getByText('부분 생성')).toBeTruthy()
|
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?