Skip to content
Open
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
21 changes: 18 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router";

import LoginPage from "@/pages/LoginPage";
import NotFoundPage from "@/pages/NotFoundPage";
import SessionsPage from "@/pages/SessionsPage";
import TeamsPage from "@/pages/TeamsPage";
import UITestPage from "@/pages/UITestPage";
import UsersPage from "@/pages/UsersPage";
import WorkspacePage from "@/pages/WorkspacePage";
import LandingRedirect from "@/components/auth/LandingRedirect";
import RequireAuth from "@/components/auth/RequireAuth";
import NoticeModal from "@/components/elements/NoticeModal";
import ToastContainer from "@/components/elements/ToastContainer";
import AppLayout from "@/components/layout/AppLayout";
import ToastContainer from "@/components/toast/ToastContainer";
import { PATH_LIST } from "@/constants/commonConstants";

/* Dev-only UI showcase — the import.meta.env.DEV guard is statically false in
production builds, so Rollup drops both the route and the chunk entirely. */
const UITestPage = import.meta.env.DEV
? lazy(() => import("@/pages/UITestPage"))
: null;

/** App defines the top-level route table for Rune Console. */
const App = () => {
return (
Expand All @@ -27,7 +33,16 @@ const App = () => {
<Route path={PATH_LIST.teams} element={<TeamsPage />} />
<Route path={PATH_LIST.users} element={<UsersPage />} />
<Route path={PATH_LIST.sessions} element={<SessionsPage />} />
<Route path={PATH_LIST.uiTest} element={<UITestPage />} />
{import.meta.env.DEV && UITestPage && (
<Route
path={PATH_LIST.uiTest}
element={
<Suspense fallback={null}>
<UITestPage />
</Suspense>
}
/>
)}
</Route>
</Route>
{/* 404 (SC-04) — reachable regardless of auth; outside RequireAuth so a
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/elements/NoticeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Button from "@/components/elements/Button";
import ModalLayout from "@/components/layout/ModalLayout";
import { useNoticeStore } from "@/state/store/noticeStore";
import { cn } from "@/utils/cn";
import { BTN_TEXT } from "@/constants/commonConstants";
import { useNoticeStore } from "@/stores/noticeStore";

/**
* NoticeModal is the shared blocking result-notice modal. The title names
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/components/elements/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ const styles = {
numActive: "bg-mint text-on-mint font-semibold hover:text-on-mint",
};

/** Sliding 5-page window centered on the current page, clamped at both
ends so exactly min(5, totalPages) numbers always show — the button
count never jumps while paging (1→[1..5], 4→[2..6], 9/10→[6..10]). */
const pageWindow = (page: number, totalPages: number): number[] => {
const size = Math.min(5, totalPages);
const start = Math.min(Math.max(1, page - 2), totalPages - size + 1);
return Array.from({ length: size }, (_, i) => start + i);
};

interface PaginationProps {
page: number;
totalPages: number;
Expand All @@ -19,8 +28,10 @@ interface PaginationProps {

/**
* Pagination is the numbered pager (wireframe spec form): ‹ 1 2 3 ›.
* The current page is filled mint; boundary arrows disable. Page-count
* ellipsis (…) is deferred until a screen needs it.
* The current page is filled mint; boundary arrows disable. Large page
* counts show a sliding 5-page window around the current page — the
* session-history table grows without bound, so an unwindowed row of
* hundreds of buttons is not an option.
*/
const Pagination = ({
page,
Expand All @@ -39,7 +50,7 @@ const Pagination = ({
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
{pageWindow(page, totalPages).map((n) => (
<button
key={n}
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createPortal } from "react-dom";

import { useToastStore } from "@/state/store/toastStore";
import { cn } from "@/utils/cn";
import { useToastStore } from "@/stores/toastStore";

const styles = {
stack: "fixed top-4 right-4 z-90 flex flex-col items-end gap-2",
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/elements/WorkspaceStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cn } from "@/utils/cn";
import { WORKSPACE_STATUS_VAR } from "@/constants/styleConstants";
import type { TWorkspaceStatus } from "@/types/commonTypes";
import type { TWorkspaceStatus } from "@/types/workspaceTypes";

interface WorkspaceStatusProps {
status: TWorkspaceStatus;
Expand All @@ -14,7 +14,11 @@ interface WorkspaceStatusProps {
* voice), ported from UIKIT StorageStatus. Display-only by default;
* pass onClick to render it as an interactive button.
*/
const WorkspaceStatus = ({ status, onClick, className }: WorkspaceStatusProps) => {
const WorkspaceStatus = ({
status,
onClick,
className,
}: WorkspaceStatusProps) => {
const classes = cn(
"text-tag inline-flex h-[26px] w-fit cursor-pointer items-center rounded-full border border-current px-2 whitespace-nowrap",
WORKSPACE_STATUS_VAR[status].color,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import NoticeModal from "@/components/elements/NoticeModal";
import { useNoticeStore } from "@/state/store/noticeStore";
import { BTN_TEXT } from "@/constants/commonConstants";
import { useNoticeStore } from "@/stores/noticeStore";

afterEach(() => {
useNoticeStore.setState({ notice: null });
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/elements/__tests__/Pagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ describe("Pagination", () => {
);
});

it("shows a sliding 5-page window centered on the current page", () => {
render(<Pagination page={25} totalPages={50} onChange={() => {}} />);
/* Visible: 23 24 [25] 26 27 — always exactly five numbers. */
for (const n of ["23", "24", "25", "26", "27"]) {
expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
}
expect(screen.queryByRole("button", { name: "1" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "50" }),
).not.toBeInTheDocument();
});

it("clamps the window at both ends so the button count never jumps", () => {
/* Head: page 1 of 53 → 1 2 3 4 5. */
const { rerender } = render(
<Pagination page={1} totalPages={53} onChange={() => {}} />,
);
for (const n of ["1", "2", "3", "4", "5"]) {
expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
}
expect(screen.queryByRole("button", { name: "6" })).not.toBeInTheDocument();

/* Near the tail: page 9 of 10 → 6 7 8 9 10. */
rerender(<Pagination page={9} totalPages={10} onChange={() => {}} />);
for (const n of ["6", "7", "8", "9", "10"]) {
expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
}
expect(screen.queryByRole("button", { name: "5" })).not.toBeInTheDocument();
});

it("disables prev on the first page and next on the last", () => {
const { rerender } = render(
<Pagination page={1} totalPages={5} onChange={() => {}} />,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import ToastContainer from "@/components/toast/ToastContainer";
import { useToastStore } from "@/stores/toastStore";
import ToastContainer from "@/components/elements/ToastContainer";
import { useToastStore } from "@/state/store/toastStore";

describe("ToastContainer", () => {
beforeEach(() => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/navigation/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import WorkspaceModal from "@/components/workspace/WorkspaceModal";
import { useSessionQuery } from "@/hooks/queries/useSessionQuery";
import { useWorkspaceQuery } from "@/hooks/queries/useWorkspaceQuery";
import { postLogout } from "@/api/authAPIs";
import { useWorkspaceStore } from "@/state/store/workspaceStore";
import {
BRAND_WORDMARK,
PATH_LIST,
QUERY_KEYS,
} from "@/constants/commonConstants";
import { useWorkspaceStore } from "@/stores/workspaceStore";

/**
* Navbar is the console top bar (SC-03). Its background and bottom border
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import Navbar from "@/components/navigation/Navbar";
import * as authAPIs from "@/api/authAPIs";
import * as workspaceAPIs from "@/api/workspaceAPIs";
import { useWorkspaceStore } from "@/state/store/workspaceStore";
import { BTN_TEXT } from "@/constants/commonConstants";
import { useWorkspaceStore } from "@/stores/workspaceStore";

const jsonRes = (body: unknown) =>
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/table/TableEmptyRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";

interface TableEmptyRowProps {
colSpan: number;
/** Per-table empty copy (검색 결과가 없습니다 · 이력이 없습니다 · …). */
children: ReactNode;
}

/**
* TableEmptyRow is the shared zero-rows state for server-paged tables —
* a full-width muted line under the header rule, matching
* TableLoadingRow's height so pending → empty never shifts the layout.
*/
const TableEmptyRow = ({ colSpan, children }: TableEmptyRowProps) => {
return (
<tr>
<td
colSpan={colSpan}
className="text-muted-foreground border-t px-3 py-8 text-center text-sm"
>
{children}
</td>
</tr>
);
};

export default TableEmptyRow;
23 changes: 23 additions & 0 deletions frontend/src/components/table/TableLoadingRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
interface TableLoadingRowProps {
colSpan: number;
}

/**
* TableLoadingRow is the shared pending state for server-paged tables —
* one full-width faint line so every list (users, sessions, team
* members) waits with the same height and copy.
*/
const TableLoadingRow = ({ colSpan }: TableLoadingRowProps) => {
return (
<tr>
<td
colSpan={colSpan}
className="text-faint px-3 py-8 text-center text-sm"
>
불러오는 중…
</td>
</tr>
);
};

export default TableLoadingRow;
27 changes: 16 additions & 11 deletions frontend/src/components/teams/AddMemberModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ import Input from "@/components/elements/Input";
import MemberStatus from "@/components/elements/MemberStatus";
import Notice from "@/components/elements/Notice";
import ModalLayout from "@/components/layout/ModalLayout";
import { ROLE_OPTIONS } from "@/components/teams/teamOptions";
import { EMAIL_FORMAT_ERROR, EMAIL_PATTERN } from "@/utils/email";
import {
isSubmittableUsername,
normalizeUsernameInput,
USERNAME_MAX_LENGTH,
validateUsername,
} from "@/utils/username";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
import {
BTN_TEXT,
INPUT_LABELS,
MODAL_TITLES,
PLACEHOLDERS,
} from "@/constants/commonConstants";
import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
import { ROLE_OPTIONS } from "@/constants/teamConstants";

interface AddMemberModalProps {
teamName: string;
Expand Down Expand Up @@ -56,26 +61,26 @@ const AddMemberModal = ({
<div className="flex w-full flex-col gap-6">
<Input
id="add-member-account"
labelText="이메일 (account)"
labelText={INPUT_LABELS.emailAccount}
type="email"
placeholder="user@corp.com"
placeholder={PLACEHOLDERS.emailExample}
maxLength={100}
value={account}
setValue={setAccount}
error={invalidFormat ? "올바른 이메일 형식이 아닙니다." : undefined}
error={invalidFormat ? EMAIL_FORMAT_ERROR : undefined}
/>
<Input
id="add-member-username"
labelText="사용자 이름 (username)"
placeholder="사용자 이름"
labelText={INPUT_LABELS.username}
placeholder={PLACEHOLDERS.username}
maxLength={USERNAME_MAX_LENGTH}
value={username}
setValue={(value) => setUsername(normalizeUsernameInput(value))}
error={usernameError}
/>
<Dropdown
label="권한 (role)"
placeholder="권한 선택"
placeholder={PLACEHOLDERS.selectRole}
options={ROLE_OPTIONS}
value={role}
onChange={setRole}
Expand All @@ -90,7 +95,7 @@ const AddMemberModal = ({
</Notice>
{error && <Notice tone="error">{error}</Notice>}
</div>
<div className="flex w-full gap-2">
<div className={MODAL_STYLE_VAR.footer}>
<Button
btnText={BTN_TEXT.cancel}
btnSize="md"
Expand Down
20 changes: 13 additions & 7 deletions frontend/src/components/teams/CreateTeamModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import Dropdown from "@/components/elements/Dropdown";
import Input from "@/components/elements/Input";
import Notice from "@/components/elements/Notice";
import ModalLayout from "@/components/layout/ModalLayout";
import { buildTeamOptions } from "@/utils/buildTeamOptions";
import {
BTN_TEXT,
MODAL_TITLES,
PLACEHOLDERS,
} from "@/constants/commonConstants";
import { TEAM_NAME_DUPLICATE_TEXT } from "@/constants/errorConstants";
import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
import {
buildTeamOptions,
TEAM_NAME_PATTERN,
TEAM_NAME_RULE_TEXT,
} from "@/components/teams/teamOptions";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
} from "@/constants/teamConstants";
import type { TTeamTree } from "@/types/teamTypes";

interface CreateTeamModalProps {
Expand Down Expand Up @@ -51,12 +57,12 @@ const CreateTeamModal = ({
const nameError = isInvalidFormat
? TEAM_NAME_RULE_TEXT
: trimmed && isDuplicate
? "같은 상위 팀에 동일한 이름이 이미 있습니다."
? TEAM_NAME_DUPLICATE_TEXT
: undefined;

return (
<ModalLayout title={MODAL_TITLES.createTeam} isOpen>
<div className="flex w-full flex-col gap-5">
<div className={MODAL_STYLE_VAR.body}>
<Input
id="create-team-name"
labelText="팀 이름"
Expand All @@ -69,7 +75,7 @@ const CreateTeamModal = ({
/>
<Dropdown
label="상위 팀 (선택)"
placeholder="팀 선택"
placeholder={PLACEHOLDERS.selectTeam}
options={buildTeamOptions(teams)}
value={parentId}
onChange={setParentId}
Expand All @@ -80,7 +86,7 @@ const CreateTeamModal = ({
</Notice>
{error && <Notice tone="error">{error}</Notice>}
</div>
<div className="flex w-full gap-2">
<div className={MODAL_STYLE_VAR.footer}>
<Button
btnText={BTN_TEXT.cancel}
btnSize="md"
Expand Down
Loading
Loading