Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"토론 주제를 입력해주세요": "Please enter a debate topic",
"팀명": "Team name",
"팀명은 최대 15자까지 입력할 수 있습니다.": "Team name can be up to 15 characters.",
"팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "Team name contains characters that are not allowed.",
"시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "Table name can be up to {{val0}} characters.",
"시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "Table name contains characters that are not allowed.",
"토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "Debate topic can be up to {{val0}} characters.",
"다음": "Next",
"볼륨 조절": "Volume control",
"투표 종료에 실패했습니다.": "Failed to end the vote.",
Expand All @@ -270,6 +274,7 @@
"나의 토론 주제": "My Debate Topic",
"나의 시간표": "My Timetable",
"유효하지 않은 토론방 ID입니다.": "Invalid debate room ID.",
"시간표 설정에 오류가 발생했어요.": "An error occurred in timetable settings.",
"실시간 연결 주소를 확인할 수 없어요.": "Could not verify live connection address.",
"토론방 연결이 거부되었어요.": "Connection to the debate room was rejected.",
"실시간 연결에서 서버 오류가 발생했어요.": "A server error occurred during live connection.",
Expand Down
5 changes: 5 additions & 0 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"토론 주제를 입력해주세요": "토론 주제를 입력해주세요",
"팀명": "팀명",
"팀명은 최대 15자까지 입력할 수 있습니다.": "팀명은 최대 15자까지 입력할 수 있습니다.",
"팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "팀명에 사용할 수 없는 문자가 포함되어 있습니다.",
"시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.",
"시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.",
"토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.",
"다음": "다음",
"볼륨 조절": "볼륨 조절",
"투표 종료에 실패했습니다.": "투표 종료에 실패했습니다.",
Expand All @@ -270,6 +274,7 @@
"나의 토론 주제": "나의 토론 주제",
"나의 시간표": "나의 시간표",
"유효하지 않은 토론방 ID입니다.": "유효하지 않은 토론방 ID입니다.",
"시간표 설정에 오류가 발생했어요.": "시간표 설정에 오류가 발생했어요.",
"실시간 연결 주소를 확인할 수 없어요.": "실시간 연결 주소를 확인할 수 없어요.",
"토론방 연결이 거부되었어요.": "토론방 연결이 거부되었어요.",
"실시간 연결에서 서버 오류가 발생했어요.": "실시간 연결에서 서버 오류가 발생했어요.",
Expand Down
52 changes: 52 additions & 0 deletions src/apis/apis/organization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { ApiUrl } from '../endpoints';
import { GetOrganizationTemplatesResponseType } from '../responses/organization';
import { getOrganizationTemplates } from './organization';

const mockTemplatesResponse: GetOrganizationTemplatesResponseType = {
organizations: [
{
organization: '테스트 기관',
affiliation: '테스트 대학',
iconPath: '/icon/test.png',
templates: [],
},
],
};

describe('조직 템플릿 조회 API', () => {
test('language=KO_KR 쿼리 파라미터와 함께 템플릿을 조회한다', async () => {
let capturedLanguage: string | null = null;

server.use(
http.get(ApiUrl.organization + '/templates', ({ request }) => {
const url = new URL(request.url);
capturedLanguage = url.searchParams.get('language');
return HttpResponse.json(mockTemplatesResponse);
}),
);

const data = await getOrganizationTemplates('KO_KR');

expect(capturedLanguage).toBe('KO_KR');
expect(data).toEqual(mockTemplatesResponse);
});

test('language=US_EN 쿼리 파라미터와 함께 템플릿을 조회한다', async () => {
let capturedLanguage: string | null = null;

server.use(
http.get(ApiUrl.organization + '/templates', ({ request }) => {
const url = new URL(request.url);
capturedLanguage = url.searchParams.get('language');
return HttpResponse.json(mockTemplatesResponse);
}),
);

const data = await getOrganizationTemplates('US_EN');

expect(capturedLanguage).toBe('US_EN');
expect(data).toEqual(mockTemplatesResponse);
});
});
8 changes: 6 additions & 2 deletions src/apis/apis/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { ApiUrl } from '../endpoints';
import { request } from '../primitives';
import { GetOrganizationTemplatesResponseType } from '../responses/organization';

export type ApiLanguageCode = 'KO_KR' | 'US_EN';

// GET /api/organizations/templates
export async function getOrganizationTemplates(): Promise<GetOrganizationTemplatesResponseType> {
export async function getOrganizationTemplates(
language: ApiLanguageCode,
): Promise<GetOrganizationTemplatesResponseType> {
const requestUrl: string = ApiUrl.organization + '/templates';
const response = await request<GetOrganizationTemplatesResponseType>(
'GET',
requestUrl,
null,
null,
{ language },
);

return response.data;
Expand Down
9 changes: 6 additions & 3 deletions src/apis/primitives.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axios from 'axios';
import { AxiosResponse } from 'axios';
import axiosInstance from './axiosInstance';
import { isSentryCaptured } from '../util/sentry';
import { isSentryCaptured, markSentryCaptured } from '../util/sentry';

// HTTP request methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
Expand All @@ -10,7 +10,6 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export class APIError extends Error {
public readonly status: number;
public readonly data: unknown;
public __sentry_captured__?: boolean;

constructor(message: string, status: number, data: unknown) {
super(message);
Expand Down Expand Up @@ -56,7 +55,11 @@ export async function request<T>(
error.response?.status || 500,
responseData,
);
apiError.__sentry_captured__ = isSentryCaptured(error);
// Axios 인터셉터에서 이미 보낸 에러는 APIError로 감싼 뒤에도
// ErrorBoundary/전역 핸들러에서 중복 수집되지 않도록 캡처 상태를 전달한다.
if (isSentryCaptured(error)) {
markSentryCaptured(apiError);
}
throw apiError;
}

Expand Down
55 changes: 54 additions & 1 deletion src/components/ClearableInput/ClearableInput.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
import clsx from 'clsx';
import { InputHTMLAttributes } from 'react';
import { IoMdCloseCircle } from 'react-icons/io';
import { useTranslation } from 'react-i18next';

interface ClearableInputProps extends InputHTMLAttributes<HTMLInputElement> {
value: string;
disabled?: boolean;
onClear?: () => void;
/** 검증 실패 시 붉은 테두리로 표시 */
isError?: boolean;
/** 설정 시 입력창 경계 바로 아래에 `현재/최대` 글자 수 카운터를 노출한다. */
maxCount?: number;
/** 설정 시 입력창 경계 바로 아래(왼쪽)에 붉은 에러 메시지를 노출한다. */
errorMessage?: string;
}

export default function ClearableInput({
value,
onClear,
disabled = false,
isError = false,
maxCount,
errorMessage,
className,
id,
...rest
}: ClearableInputProps) {
const { t } = useTranslation();
const hasCounter = typeof maxCount === 'number';
const isOverLimit = hasCounter && value.length > maxCount;
const errorId = errorMessage && id ? `${id}-error` : undefined;

return (
<div className={clsx('relative w-full', className)}>
<input
{...rest}
id={id}
value={value}
disabled={disabled}
className="text-body h-[48px] w-full appearance-none rounded-[4px] border border-default-border p-[12px] text-default-black placeholder-default-border focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-invalid={isError || undefined}
aria-describedby={errorId}
className={clsx(
'text-body h-[48px] w-full appearance-none rounded-[4px] p-[12px] text-default-black placeholder-default-border focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
// box-border 기준이라 테두리를 굵혀도 외곽 크기(48px)는 유지 → 레이아웃 밀림 없음
isError
? 'border-2 border-semantic-error'
: 'border border-default-border',
)}
/>
{value && !disabled && onClear && (
<button
Expand All @@ -32,6 +57,34 @@ export default function ClearableInput({
<IoMdCloseCircle />
</button>
)}
{hasCounter && (
// 박스 경계 바로 아래에 절대배치 → 레이아웃을 밀지 않아 행 간격이 균일하게 유지된다.
<span
aria-live="polite"
className={clsx(
'pointer-events-none absolute right-1 top-full mt-[2px] text-[11px] leading-none',
isOverLimit
? 'font-semibold text-semantic-error'
: 'text-default-neutral',
)}
>
{t('{{current}}/{{max}}', {
current: value.length,
max: maxCount,
})}
</span>
)}
{errorMessage && (
// 카운터(우하단)와 겹치지 않도록 우측 여백을 두고 좌하단에 절대배치한다.
<span
id={errorId}
role="alert"
aria-live="polite"
className="absolute left-1 top-full mt-[2px] pr-14 text-[12px] font-semibold leading-none text-semantic-error"
>
{errorMessage}
</span>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송
// API 인터셉터에서 이미 커스텀 이벤트로 전송한 에러는 전역 beforeSend에서도 drop된다.
// ErrorBoundary에서는 아직 수집되지 않은 렌더링 에러만 render-error로 전송한다.
if (!isSentryCaptured(error)) {
const feature = resolveFeatureFromPathname(window.location.pathname);
const sentryError = createSentryRenderError(error, feature);
Expand Down
102 changes: 102 additions & 0 deletions src/components/TimerProgressBar/TimerProgressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TimerProgressBar from './TimerProgressBar';

const animateMock = vi.hoisted(() => vi.fn());

vi.mock('framer-motion', async () => {
const actual =
await vi.importActual<typeof import('framer-motion')>('framer-motion');

return {
...actual,
animate: animateMock,
};
});

describe('TimerProgressBar', () => {
beforeEach(() => {
animateMock.mockReset();
animateMock.mockImplementation(
(motionValue: { set: (value: number) => void }, target: number) => {
motionValue.set(target);
return { stop: vi.fn() };
},
);
});

it('기본 크기와 전달받은 className 및 접근성 진행률을 적용한다', () => {
render(
<TimerProgressBar
progress={35}
team="PROS"
isRunning={false}
className="max-w-[1280px]"
/>,
);

const progressBar = screen.getByRole('progressbar');

expect(progressBar).toHaveClass(
'h-[24px]',
'w-full',
'overflow-hidden',
'rounded-full',
'max-w-[1280px]',
);
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
expect(progressBar).toHaveAttribute('aria-valuenow', '35');
});

it.each([
['PROS', 'bg-camp-blue'],
['CONS', 'bg-camp-red'],
['DISABLED', 'bg-default-neutral'],
] as const)('%s 팀 색상을 진행 영역에 적용한다', (team, colorClass) => {
render(<TimerProgressBar progress={50} team={team} isRunning={false} />);

expect(screen.getByTestId('timer-progress-fill')).toHaveClass(colorClass);
});

it.each([
[-10, 0],
[120, 100],
])('진행률 %s를 %s 범위로 제한한다', (progress, expectedProgress) => {
render(
<TimerProgressBar
progress={progress}
team="DISABLED"
isRunning={false}
/>,
);

expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
String(expectedProgress),
);
expect(animateMock).toHaveBeenCalledWith(
expect.anything(),
expectedProgress,
expect.objectContaining({ duration: 0 }),
);
});

it('실행 중에는 0.7초 easeOut으로 애니메이션하고 정지 상태에서는 즉시 동기화한다', () => {
const { rerender } = render(
<TimerProgressBar progress={30} team="PROS" isRunning={true} />,
);

expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 30, {
duration: 0.7,
ease: 'easeOut',
});

rerender(<TimerProgressBar progress={60} team="PROS" isRunning={false} />);

expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 60, {
duration: 0,
ease: 'easeOut',
});
});
});
Loading
Loading