| カテゴリ | ライブラリ | バージョン | 用途 |
|---|---|---|---|
| フレームワーク | React | ^18.x | UI構築 |
| ビルドツール | Vite | ^5.x | 開発・ビルド |
| 言語 | TypeScript | ^5.x | 型安全性 |
| UIライブラリ | Ant Design | ^5.x | UIコンポーネント |
| フォーム | React Hook Form | ^7.x | フォーム状態管理 |
| バリデーション | Zod | ^3.x | スキーマバリデーション |
| Markdownエディタ | MDXEditor | ^3.x | text型フィールド用リッチエディタ |
| 仮想スクロール | @tanstack/react-virtual | ^3.x | 大量行の描画最適化 |
| HTTP通信 | ky | ^1.x | APIクライアント |
| ルーティング | React Router | ^6.x | SPA内ルーティング |
| 状態管理 | Zustand | ^4.x | グローバル状態(軽量) |
| アイコン | @ant-design/icons | ^5.x | アイコンセット |
| ライブラリ | 用途 |
|---|---|
| ESLint | コード品質 |
| Prettier | コードフォーマット |
| Vitest | ユニットテスト |
| @testing-library/react | コンポーネントテスト |
src/
├── main.tsx # エントリポイント
├── App.tsx # ルートコンポーネント
├── routes.tsx # ルーティング定義
├── api/ # API通信層
│ ├── client.ts # kyインスタンス設定
│ ├── datasets.ts # Dataset API
│ └── types.ts # API型定義
├── components/ # 共通コンポーネント
│ ├── Layout/
│ │ ├── AppLayout.tsx # 全体レイアウト(3カラム)
│ │ ├── Navbar.tsx # ナビゲーションバー
│ │ ├── Sidebar.tsx # サイドバー(Dataset一覧)
│ │ └── DetailPanel.tsx # 右パネル(編集フォーム/スキーマ表示)
│ ├── DataList/
│ │ ├── DataList.tsx # データ表示切替(テーブル/タイル)
│ │ ├── DataTable.tsx # テーブル表示
│ │ ├── DataTileGrid.tsx # タイル表示
│ │ ├── DataTile.tsx # タイルアイテム
│ │ ├── QuickSearch.tsx # クイック検索
│ │ ├── ColumnFilter.tsx # 列フィルタ
│ │ └── InsertMenu.tsx # 新規追加メニュー
│ ├── Form/
│ │ ├── DynamicForm.tsx # スキーマからフォーム生成
│ │ ├── FormSection.tsx # セクション
│ │ └── widgets/ # フィールドウィジェット
│ │ ├── TextField.tsx
│ │ ├── NumberField.tsx
│ │ ├── SelectField.tsx
│ │ ├── CheckboxField.tsx
│ │ ├── DateField.tsx
│ │ ├── DateTimeField.tsx
│ │ └── MarkdownField.tsx # MDXEditor使用
│ ├── SchemaViewer/
│ │ └── SchemaViewer.tsx # スキーマ仕様表示
│ ├── ErrorBoundary.tsx # エラー境界
│ └── Toast/ # 通知コンポーネント
├── features/ # 機能別モジュール
│ ├── home/
│ │ ├── HomePage.tsx # Home画面
│ │ ├── ConfigPreview.tsx # 設定ファイルプレビュー
│ │ └── DatasetCard.tsx # Datasetカード
│ └── dataset/
│ ├── DatasetPage.tsx # Dataset編集画面(3カラム)
│ ├── EditPanel.tsx # 編集パネル(右側)
│ ├── SaveConfirmModal.tsx # 保存確認モーダル
│ ├── DiffPreviewModal.tsx # 差分プレビュー
│ └── hooks/
│ ├── useDataset.ts # Dataset操作Hook
│ ├── useRows.ts # 行データ管理
│ └── useValidation.ts # バリデーション
├── hooks/ # 共通Hooks
│ ├── useConfig.ts # 設定取得
│ ├── useUnsavedWarning.ts # 未保存警告
│ └── useDebounce.ts # デバウンス
├── stores/ # Zustand stores
│ ├── appStore.ts # アプリ全体状態
│ └── datasetStore.ts # Dataset編集状態
├── lib/ # ユーティリティ
│ ├── schema/
│ │ ├── parser.ts # スキーマパース
│ │ └── zodBuilder.ts # スキーマ→Zod変換
│ └── utils/
│ ├── format.ts # フォーマット関数
│ └── csv.ts # CSV関連ユーティリティ
├── types/ # 型定義
│ ├── schema.ts # スキーマ型
│ ├── dataset.ts # Dataset型
│ └── validation.ts # バリデーション型
└── styles/
├── global.css # グローバルスタイル
└── theme.ts # Ant Design テーマ設定
┌─────────────────────────────────────────────────────────────────────────────┐
│ Navbar [未保存] [Read-only] [保存] │
├─────────┬───────────────────────────────────────────┬───────────────────────┤
│ │ │ │
│ Sidebar │ Main Content │ Detail Panel │
│ (折畳) │ (DataList: Table / Tile) │ (Edit Form / │
│ │ │ Schema Viewer) │
│ Dataset │ ┌─────────────────────────────────────┐ │ │
│ List │ │ [検索] [フィルタ] [+ 追加 ▼] │ │ ┌─────────────────┐ │
│ │ ├─────────────────────────────────────┤ │ │ │ │
│ ・商品 │ │ │ │ │ Edit Form │ │
│ ・カテゴリ │ │ │ │ (選択時) │ │
│ ・... │ │ Table View / Tile View │ │ │ │ │
│ │ │ │ │ │ または │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ Schema Viewer │ │
│ │ │ │ │ │ (未選択時) │ │
│ │ │ │ │ │ │ │
│ │ └─────────────────────────────────────┘ │ └─────────────────┘ │
└─────────┴───────────────────────────────────────────┴───────────────────────┘
| 領域 | 幅 | 説明 |
|---|---|---|
| Navbar | 100% | 上部固定。アプリ名、状態バッジ、保存ボタン |
| Sidebar | 200px(折りたたみ時: 60px) | Dataset一覧、折りたたみ可能 |
| Main Content | flex: 1 | データ一覧(テーブル/タイル切替) |
| Detail Panel | 400px | 編集フォーム または スキーマ仕様表示 |
| パス | コンポーネント | 説明 |
|---|---|---|
/ |
HomePage |
Config プレビュー、Dataset一覧表示 |
/datasets/:id |
DatasetPage |
Dataset編集(3カラムレイアウト) |
責務: アプリ全体のナビゲーション、保存ボタン配置
interface NavbarProps {
appName: string;
isDirty: boolean;
isReadOnly: boolean;
isSaving: boolean;
onSave: () => void;
onShowDiff: () => void;
}UI構成:
┌────────────────────────────────────────────────────────────────────────┐
│ 📊 CSVMS [Home] [⚠ 未保存] [🔒 Read-only] [保存] │
└────────────────────────────────────────────────────────────────────────┘
要素:
- 左側: アプリロゴ/名前、Homeリンク
- 右側:
- 未保存バッジ(黄色、isDirty時のみ表示)
- Read-onlyバッジ(グレー、isReadOnly時のみ表示)
- 「差分確認」ボタン(isDirty時のみ有効)
- 「保存」ボタン(Primary、isReadOnly時はdisabled)
実装:
<Layout.Header>
<div className="navbar">
<div className="navbar-left">
<Logo />
<Link to="/">Home</Link>
</div>
<div className="navbar-right">
{isDirty && <Tag color="warning">未保存の変更があります</Tag>}
{isReadOnly && <Tag color="default">読み取り専用</Tag>}
<Button onClick={onShowDiff} disabled={!isDirty}>差分確認</Button>
<Button type="primary" onClick={onSave} disabled={isReadOnly || !isDirty} loading={isSaving}>
保存
</Button>
</div>
</div>
</Layout.Header>責務: Dataset一覧表示・選択(折りたたみ可能)
interface SidebarProps {
collapsed: boolean;
onCollapse: (collapsed: boolean) => void;
datasets: DatasetSummary[];
currentId?: string;
onSelect: (id: string) => void;
}
interface DatasetSummary {
id: string;
title: string;
rowCount: number;
errorCount: number;
}UI要素:
- Ant Design
Layout.Siderwithcollapsible - 折りたたみ時: アイコンのみ表示
- 各Datasetに件数バッジ、エラー件数バッジ(赤)
- 選択中のDatasetはハイライト
実装:
<Layout.Sider
collapsible
collapsed={collapsed}
onCollapse={onCollapse}
width={200}
collapsedWidth={60}
>
<Menu
mode="inline"
selectedKeys={currentId ? [currentId] : []}
onClick={({ key }) => onSelect(key)}
>
{datasets.map(ds => (
<Menu.Item key={ds.id} icon={<TableOutlined />}>
<span>{ds.title}</span>
<Badge count={ds.rowCount} size="small" />
{ds.errorCount > 0 && <Badge count={ds.errorCount} color="red" size="small" />}
</Menu.Item>
))}
</Menu>
</Layout.Sider>責務: Config設定のプレビュー表示、Dataset一覧
interface HomePageProps {}UI構成:
┌─────────────────────────────────────────────────────────────┐
│ 📊 CSVMS - Home │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Configuration │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ Workspace: /path/to/workspace │ │
│ │ Allow Write: ✓ Yes │ │
│ │ Datasets: 3 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Datasets │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 📄 商品マスタ │ │ 📄 カテゴリ │ │ 📄 ユーザー │ │ │
│ │ │ 150 rows │ │ 25 rows │ │ 500 rows │ │ │
│ │ │ 0 errors │ │ 2 errors │ │ 0 errors │ │ │
│ │ │ [開く →] │ │ [開く →] │ │ [開く →] │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Config File (csvms.config.yaml) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ version: 1 │ │
│ │ workspace: │ │
│ │ root: "." │ │
│ │ allowWrite: true │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
セクション:
- Configuration Summary: 設定概要(Workspace, allowWrite, Dataset数)
- Datasets: Datasetカード一覧(クリックで編集画面へ遷移)
- Config File Preview: YAMLファイルのプレビュー(読み取り専用、シンタックスハイライト)
責務: データ一覧表示(テーブル/タイル切替)
interface DataListProps {
schema: Schema;
rows: Row[];
viewMode: 'table' | 'tile'; // スキーマ ui.list.viewMode で指定
selectedRowIndex: number | null;
errors: ValidationError[];
onRowSelect: (index: number | null) => void;
onInsert: (position: InsertPosition) => void;
onDuplicate: (index: number) => void;
onDelete: (index: number) => void;
onSort: (columnKey: string, order: 'asc' | 'desc') => void;
onFilter: (filters: Filter[]) => void;
onQuickSearch: (query: string) => void;
}
type InsertPosition =
| { type: 'head' }
| { type: 'last' }
| { type: 'before'; index: number }
| { type: 'after'; index: number };
interface Row {
_rowIndex: number; // 元のCSV行番号
_isNew: boolean; // 新規追加行
_isDirty: boolean; // 変更あり
[columnKey: string]: unknown;
}ツールバー:
┌───────────────────────────────────────────────────────────┐
│ [🔍 検索... ] [フィルタ ▼] [+ 追加 ▼] [🔲/☰] │
└───────────────────────────────────────────────────────────┘
- クイック検索入力
- フィルタドロップダウン
- 追加ボタン(InsertMenu)
- 表示切替ボタン(テーブル/タイル)※スキーマで両方有効な場合のみ
責務: 新規レコード追加位置の選択
interface InsertMenuProps {
selectedRowIndex: number | null;
onInsert: (position: InsertPosition) => void;
disabled?: boolean;
}メニュー内容:
| 選択状態 | メニュー項目 |
|---|---|
| 行/タイル選択時 | 「前に挿入」「後に挿入」 |
| 未選択時 | 「先頭に追加」「末尾に追加」 |
実装:
const menuItems = selectedRowIndex !== null
? [
{ key: 'before', label: '前に挿入', icon: <ArrowUpOutlined /> },
{ key: 'after', label: '後に挿入', icon: <ArrowDownOutlined /> },
]
: [
{ key: 'head', label: '先頭に追加', icon: <VerticalAlignTopOutlined /> },
{ key: 'last', label: '末尾に追加', icon: <VerticalAlignBottomOutlined /> },
];
<Dropdown menu={{ items: menuItems, onClick: handleInsert }}>
<Button icon={<PlusOutlined />}>
追加 <DownOutlined />
</Button>
</Dropdown>責務: テーブル形式でのデータ表示
interface DataTableProps {
schema: Schema;
rows: Row[];
selectedRowIndex: number | null;
errors: ValidationError[];
onRowSelect: (index: number | null) => void;
onRowContextMenu: (index: number, event: React.MouseEvent) => void;
onSort: (columnKey: string, order: 'asc' | 'desc') => void;
}機能:
| 機能 | 実装方法 |
|---|---|
| 仮想スクロール | @tanstack/react-virtual |
| ソート | 列ヘッダークリック(単一列) |
| 行選択 | 行クリック → 右パネルに編集フォーム表示 |
| 選択解除 | 選択行を再度クリック または 背景クリック |
| 右クリック | コンテキストメニュー(複製、削除、挿入) |
列幅:
- スキーマ
ui.list.columns[].widthを使用 - 未指定の場合: デフォルト150px
pinned: trueの列は左固定
行スタイル:
- 選択行: 青背景(
ant-table-row-selected) - エラー行: 薄赤背景
- 変更行: 薄黄背景
- 新規行: 薄緑背景
責務: タイル形式でのデータ表示
interface DataTileGridProps {
schema: Schema;
rows: Row[];
selectedRowIndex: number | null;
errors: ValidationError[];
onRowSelect: (index: number | null) => void;
onRowContextMenu: (index: number, event: React.MouseEvent) => void;
}タイル表示設定(スキーマ ui.list.tile):
ui:
list:
viewMode: tile # または 'table' または 'both'
tile:
columns: 3 # グリッド列数
titleField: name # タイトルに表示するフィールド
subtitleField: status # サブタイトルに表示するフィールド
imageField: thumbnail # 画像フィールド(任意)タイルカード:
┌─────────────────┐
│ [画像] │
│ │
│ 商品名 │
│ ステータス │
│ ───────────── │
│ ID: PROD-001 │
└─────────────────┘
責務: 選択行の編集フォーム または スキーマ仕様表示
interface EditPanelProps {
schema: Schema;
row: Row | null;
isNew: boolean;
errors: ValidationError[];
onChange: (values: Partial<Row>) => void;
onDuplicate: () => void;
onDelete: () => void;
readOnly?: boolean;
}表示内容:
| 状態 | 表示内容 |
|---|---|
| 行選択時 | DynamicForm(編集フォーム) |
| 未選択時 | SchemaViewer(スキーマ仕様表示) |
EditPanel ヘッダー(選択時):
┌─────────────────────────────────────────────┐
│ 編集: PROD-001 [複製] [削除] │
├─────────────────────────────────────────────┤
│ │
│ (DynamicForm) │
│ │
└─────────────────────────────────────────────┘
責務: スキーマ仕様のビジュアル表示(未選択時)
interface SchemaViewerProps {
schema: Schema;
}表示内容:
┌─────────────────────────────────────────────┐
│ 📋 スキーマ: 商品マスタ │
├─────────────────────────────────────────────┤
│ │
│ ■ 基本情報 │
│ Primary Key: product_id │
│ 行数: 150 │
│ │
│ ■ カラム定義 │
│ ┌─────────────────────────────────────┐ │
│ │ product_id string 必須 ユニーク │ │
│ │ name string 必須 │ │
│ │ status enum 必須 │ │
│ │ price integer 必須 min:0 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ■ バリデーションルール │
│ • 公開中は価格が1以上である必要があります │
│ │
│ ■ 正規化設定 │
│ ソート: product_id (asc) │
│ クォート: minimal │
│ 改行: LF │
│ │
└─────────────────────────────────────────────┘
セクション:
- 基本情報(ID, Primary Key, 行数)
- カラム定義テーブル
- バリデーションルール一覧
- 正規化設定
責務: スキーマ定義からフォームを動的生成
interface DynamicFormProps {
schema: Schema;
row: Row;
isNew: boolean;
errors: ValidationError[];
onChange: (values: Partial<Row>) => void;
readOnly?: boolean;
}React Hook Form統合:
// zodBuilder.ts でスキーマからZodスキーマを生成
const zodSchema = buildZodSchema(schema.columns);
// DynamicForm内部
const form = useForm({
resolver: zodResolver(zodSchema),
defaultValues: row,
mode: 'onChange', // 即時バリデーション
});レイアウト:
- スキーマ
ui.form.layoutに従ってセクション分け type: sectionsの場合: Ant DesignCardでセクション区切りcolumns: 2の場合: 2列グリッドレイアウト
interface TextFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
pattern?: string;
control: Control;
}実装: Ant Design Input
interface NumberFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
min?: number;
max?: number;
step?: number;
control: Control;
}実装: Ant Design InputNumber
interface SelectFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
options: { value: string; label: string }[];
control: Control;
}実装: Ant Design Select
interface CheckboxFieldProps {
name: string;
label: string;
readOnly?: boolean;
control: Control;
}実装: Ant Design Checkbox
interface DateFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
control: Control;
}実装: Ant Design DatePicker
フォーマット: YYYY-MM-DD
interface DateTimeFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
control: Control;
}実装: Ant Design DatePicker with showTime
フォーマット: YYYY-MM-DDTHH:mm:ss
interface MarkdownFieldProps {
name: string;
label: string;
required?: boolean;
readOnly?: boolean;
control: Control;
}実装: MDXEditor
import { MDXEditor, headingsPlugin, listsPlugin, ... } from '@mdxeditor/editor';
const MarkdownField: React.FC<MarkdownFieldProps> = ({ name, label, control, readOnly }) => {
const { field } = useController({ name, control });
return (
<Form.Item label={label}>
<MDXEditor
markdown={field.value || ''}
onChange={field.onChange}
readOnly={readOnly}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
tablePlugin(),
]}
/>
</Form.Item>
);
};責務: 保存確認ダイアログ
interface SaveConfirmModalProps {
open: boolean;
changeCount: number;
onConfirm: () => void;
onCancel: () => void;
onShowDiff: () => void;
}UI要素:
- 変更件数表示
- 「差分を確認」リンク(DiffPreviewModal起動)
- 「保存」「キャンセル」ボタン
責務: 保存前の差分表示(任意機能)
interface DiffPreviewModalProps {
open: boolean;
diff: string; // unified diff形式
onClose: () => void;
}実装: シンプルなdiff表示(色分け: 追加=緑、削除=赤)
interface AppState {
config: Config | null;
isReadOnly: boolean;
isLoading: boolean;
error: Error | null;
sidebarCollapsed: boolean;
// Actions
loadConfig: () => Promise<void>;
setSidebarCollapsed: (collapsed: boolean) => void;
}interface DatasetState {
currentDatasetId: string | null;
meta: DatasetMeta | null;
originalRows: Row[]; // サーバから取得した元データ
rows: Row[]; // 編集中データ
selectedRowIndex: number | null;
validationErrors: ValidationError[];
isDirty: boolean;
fileHash: string | null;
isSaving: boolean;
// Actions
loadDataset: (id: string) => Promise<void>;
updateRow: (index: number, values: Partial<Row>) => void;
insertRow: (position: InsertPosition) => void; // 挿入位置指定
duplicateRow: (index: number) => void;
deleteRow: (index: number) => void;
selectRow: (index: number | null) => void;
validate: () => Promise<ValidationError[]>;
save: () => Promise<SaveResult>;
reload: () => Promise<void>;
resetChanges: () => void;
}
type InsertPosition =
| { type: 'head' }
| { type: 'last' }
| { type: 'before'; index: number }
| { type: 'after'; index: number };// 変更検知ロジック
const isDirty = useMemo(() => {
if (rows.length !== originalRows.length) return true;
return rows.some((row, i) => {
const original = originalRows[i];
return schema.columns.some(col => row[col.key] !== original[col.key]);
});
}, [rows, originalRows, schema]);// lib/schema/zodBuilder.ts
import { z } from 'zod';
import type { Column } from '@/types/schema';
export function buildZodSchema(columns: Column[]): z.ZodObject<any> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const col of columns) {
let fieldSchema = buildFieldSchema(col);
if (!col.required) {
fieldSchema = fieldSchema.optional();
}
shape[col.key] = fieldSchema;
}
return z.object(shape);
}
function buildFieldSchema(col: Column): z.ZodTypeAny {
switch (col.type) {
case 'string':
let s = z.string();
if (col.pattern) s = s.regex(new RegExp(col.pattern), `${col.label}の形式が不正です`);
return s;
case 'integer':
let i = z.number().int();
if (col.min !== undefined) i = i.min(col.min, `${col.label}は${col.min}以上である必要があります`);
if (col.max !== undefined) i = i.max(col.max, `${col.label}は${col.max}以下である必要があります`);
return i;
case 'number':
let n = z.number();
if (col.min !== undefined) n = n.min(col.min);
if (col.max !== undefined) n = n.max(col.max);
return n;
case 'boolean':
return z.boolean();
case 'date':
return z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日付はYYYY-MM-DD形式で入力してください');
case 'datetime':
return z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/, '日時はYYYY-MM-DDTHH:mm:ss形式で入力してください');
case 'enum':
const values = col.enum!.map(e => e.value);
return z.enum(values as [string, ...string[]]);
default:
return z.unknown();
}
}| タイミング | 検証内容 | 実行場所 |
|---|---|---|
| 入力時(onChange) | required, min/max, pattern, enum | フロントエンド(Zod) |
| 行編集確定時 | 上記 + フィールド整合性 | フロントエンド |
| 保存前 | 上記 + unique, rowRules | サーバAPI |
// api/client.ts
import ky from 'ky';
export const api = ky.create({
prefixUrl: '/api',
timeout: 30000,
hooks: {
beforeError: [
async (error) => {
const { response } = error;
if (response) {
const body = await response.json();
error.message = body.error?.message || error.message;
}
return error;
},
],
},
});// api/datasets.ts
import { api } from './client';
import type { Config, DatasetMeta, Row, ValidationError, SaveResult } from './types';
export const datasetsApi = {
getConfig: () =>
api.get('config').json<{ ok: true; data: Config }>(),
getDatasets: () =>
api.get('datasets').json<{ ok: true; data: DatasetSummary[] }>(),
getMeta: (id: string) =>
api.get(`datasets/${id}/meta`).json<{ ok: true; data: DatasetMeta }>(),
getRows: (id: string) =>
api.get(`datasets/${id}/rows`).json<{ ok: true; data: Row[] }>(),
validate: (id: string, rows: Row[]) =>
api.post(`datasets/${id}/validate`, { json: { rows } })
.json<{ ok: true; data: { errors: ValidationError[] } }>(),
save: (id: string, rows: Row[], expectedFileHash: string) =>
api.post(`datasets/${id}/save`, {
json: { rows, expectedFileHash }
}).json<{ ok: true; data: SaveResult }>(),
getDiff: (id: string, rows: Row[]) =>
api.post(`datasets/${id}/diff`, { json: { rows } })
.json<{ ok: true; data: { diff: string } }>(),
};| エラー種別 | 検出方法 | UI対応 |
|---|---|---|
| ネットワークエラー | fetch失敗 | トースト警告 + リトライボタン |
| 400 Bad Request | バリデーションエラー | フォームにエラー表示 |
| 404 Not Found | Dataset不存在 | エラーページ表示 |
| 409 Conflict | fileHash不一致 | 競合モーダル表示 |
| 500 Server Error | サーバ内部エラー | エラーダイアログ |
const handleConflict = () => {
Modal.confirm({
title: 'ファイルが変更されています',
content: (
<>
<p>外部でファイルが編集されました。</p>
<p>再読み込みすると、現在の変更は失われます。</p>
</>
),
okText: '再読み込み',
cancelText: 'キャンセル',
onOk: () => datasetStore.reload(),
});
};// hooks/useUnsavedWarning.ts
import { useEffect } from 'react';
import { useDatasetStore } from '@/stores/datasetStore';
export function useUnsavedWarning() {
const isDirty = useDatasetStore((s) => s.isDirty);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);
}// styles/theme.ts
import type { ThemeConfig } from 'antd';
export const theme: ThemeConfig = {
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
},
components: {
Layout: {
siderBg: '#001529',
headerBg: '#fff',
headerHeight: 48,
},
Table: {
headerBg: '#fafafa',
rowHoverBg: '#f5f5f5',
},
},
};/* styles/global.css */
/* エラー行ハイライト */
.row-has-error {
background-color: #fff2f0 !important;
}
/* 変更行ハイライト */
.row-is-dirty {
background-color: #fffbe6 !important;
}
/* 新規行ハイライト */
.row-is-new {
background-color: #f6ffed !important;
}
/* MDXEditor カスタマイズ */
.mdxeditor {
border: 1px solid #d9d9d9;
border-radius: 6px;
}
.mdxeditor:focus-within {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}スキーマの ui.widget からコンポーネントへのマッピング:
| widget値 | 型 | コンポーネント |
|---|---|---|
text |
string | TextField |
textarea |
string | TextField (multiline) |
markdown |
string | MarkdownField (MDXEditor) |
number |
integer/number | NumberField |
select |
enum | SelectField |
radio |
enum | RadioField |
checkbox |
boolean | CheckboxField |
switch |
boolean | SwitchField |
date |
date | DateField |
datetime |
datetime | DateTimeField |
@tanstack/react-virtualを使用- 表示領域 + 上下オーバースキャン20行のみDOM生成
- 行高さ: 固定40px(可変高は複雑になるためMVPでは非対応)
// 行コンポーネントのメモ化
const TableRow = React.memo(({ row, columns, errors, ... }) => {
// ...
}, (prev, next) => {
return prev.row === next.row && prev.errors === next.errors;
});- クイック検索: 300ms デバウンス
- フォーム入力のバリデーション: 150ms デバウンス
- Ant Design の標準アクセシビリティサポートを活用
- テーブルに適切な
aria-label - キーボードナビゲーション:
Tab: フォーカス移動Enter: 行選択・編集確定Escape: ドロワー閉じるCtrl+S/Cmd+S: 保存(要検討)
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: '../dist', // CLIパッケージから配信
sourcemap: false,
minify: 'esbuild',
},
server: {
proxy: {
'/api': 'http://localhost:3000', // 開発時のAPIプロキシ
},
},
});- 3カラムレイアウト(サイドバー | データ一覧 | 詳細パネル)が表示される
- サイドバーが折りたたみ可能
- 保存ボタンがナビゲーションバー右側に表示される
- Config設定のプレビューが表示される
- Dataset一覧がカード形式で表示される
- 各Datasetの件数・エラー数が表示される
- Dataset選択でCSV行が一覧表示される(テーブル/タイル)
- スキーマ設定でテーブル/タイル表示を切り替えられる
- 仮想スクロールで1000行以上でもスムーズに動作
- クイック検索・フィルタ・ソートが動作
- 行/タイルクリックで右パネルに編集フォームが表示される
- 未選択時は右パネルにスキーマ仕様が表示される
- フォームはスキーマ定義に従って自動生成される
- 入力時にバリデーションエラーが表示される
- Markdown型フィールドでMDXEditorが使用できる
- 行選択時: 「前に挿入」「後に挿入」が選択できる
- 未選択時: 「先頭に追加」「末尾に追加」が選択できる
- 行の複製・削除ができる
- 保存ボタンで全体保存される
- 競合時にエラーダイアログが表示される
- 未保存状態でのページ離脱時に警告が出る
- Read-onlyモードで編集が無効化される