feat(HAI-004): add settings modal with GET/PUT API endpoints
- Extend config schema and store helpers to support settings persistence - Add GET/PUT /settings REST API endpoints in dashboard routes - Create SettingsModal component with frontend API wiring - Add settings trigger button to Header and integrate into App - Add CSS styles for settings modal and header actions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -3,8 +3,8 @@ import { execSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
|
||||
import { VALID_TRANSITIONS } from "./types.js";
|
||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
@@ -44,6 +44,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
const config = await this.readConfig();
|
||||
return { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
}
|
||||
|
||||
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
||||
const config = await this.readConfig();
|
||||
const current = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...current, ...patch };
|
||||
config.settings = updated;
|
||||
await this.writeConfig(config);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async readConfig(): Promise<BoardConfig> {
|
||||
const data = await readFile(this.configPath, "utf-8");
|
||||
return JSON.parse(data);
|
||||
|
||||
@@ -24,8 +24,19 @@ export interface TaskCreateInput {
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
maxConcurrent: number;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
maxConcurrent: 2,
|
||||
pollIntervalMs: 15000,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
nextId: number;
|
||||
settings?: Settings;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchConfig } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
@@ -11,6 +12,7 @@ import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
function AppInner() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
|
||||
|
||||
@@ -41,7 +43,7 @@ function AppInner() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<Board
|
||||
tasks={tasks}
|
||||
maxConcurrent={maxConcurrent}
|
||||
@@ -63,6 +65,9 @@ function AppInner() {
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal onClose={() => setSettingsOpen(false)} addToast={addToast} />
|
||||
)}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult } from "@hai/core";
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
@@ -43,3 +43,14 @@ export function mergeTask(id: string): Promise<MergeResult> {
|
||||
export function fetchConfig(): Promise<{ maxConcurrent: number }> {
|
||||
return api<{ maxConcurrent: number }>("/config");
|
||||
}
|
||||
|
||||
export function fetchSettings(): Promise<Settings> {
|
||||
return api<Settings>("/settings");
|
||||
}
|
||||
|
||||
export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
||||
return api<Settings>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onOpenSettings }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<h1 className="logo">hai</h1>
|
||||
<span className="logo-sub">board</span>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
104
packages/dashboard/app/components/SettingsModal.tsx
Normal file
104
packages/dashboard/app/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Settings } from "@hai/core";
|
||||
import { fetchSettings, updateSettings } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, pollIntervalMs: 15000 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((s) => {
|
||||
setForm(s);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
addToast(err.message, "error");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
await updateSettings(form);
|
||||
addToast("Settings saved", "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, onClose, addToast]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>Settings</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>Loading…</div>
|
||||
) : (
|
||||
<div className="settings-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
||||
<input
|
||||
id="maxConcurrent"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={form.maxConcurrent}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
|
||||
<input
|
||||
id="pollIntervalMs"
|
||||
type="number"
|
||||
min={5000}
|
||||
step={1000}
|
||||
value={form.pollIntervalMs}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, pollIntervalMs: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={loading}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,22 @@ html, body {
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: baseline; gap: 8px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
color: var(--text);
|
||||
background: var(--border);
|
||||
}
|
||||
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
@@ -309,6 +325,14 @@ html, body {
|
||||
}
|
||||
.form-group textarea { resize: vertical; }
|
||||
|
||||
/* === Settings Form === */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* === Detail Modal === */
|
||||
.detail-title-row { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
|
||||
@@ -6,9 +6,33 @@ import type { ServerOptions } from "./server.js";
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
// Scheduler config
|
||||
router.get("/config", (_req, res) => {
|
||||
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
|
||||
// Scheduler config (includes persisted settings)
|
||||
router.get("/config", async (_req, res) => {
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2 });
|
||||
} catch {
|
||||
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
|
||||
}
|
||||
});
|
||||
|
||||
// Settings CRUD
|
||||
router.get("/settings", async (_req, res) => {
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/settings", async (req, res) => {
|
||||
try {
|
||||
const settings = await store.updateSettings(req.body);
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// List all tasks
|
||||
|
||||
Reference in New Issue
Block a user