Release: CI/CD Pipeline & Architecture Updates #177
@@ -7,11 +7,11 @@ const mockPush = vi.fn();
|
||||
const mockSearchParams = new Map<string, string>();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
useRouter: (): { push: typeof mockPush } => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => mockSearchParams.get(key),
|
||||
useSearchParams: (): { get: (key: string) => string | undefined } => ({
|
||||
get: (key: string): string | undefined => mockSearchParams.get(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import LoginPage from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
useRouter: (): { push: ReturnType<typeof vi.fn> } => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -53,8 +53,10 @@ export default function KnowledgePage(): ReactElement {
|
||||
filtered = filtered.filter(
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.summary?.toLowerCase().includes(query) ||
|
||||
entry.tags.some((tag: { name: string }) => tag.name.toLowerCase().includes(query))
|
||||
(entry.summary?.toLowerCase().includes(query) ?? false) ||
|
||||
entry.tags.some((tag: { name: string }): boolean =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +87,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleFilterChange = (callback: () => void) => {
|
||||
const handleFilterChange = (callback: () => void): void => {
|
||||
callback();
|
||||
setCurrentPage(1);
|
||||
};
|
||||
@@ -93,7 +95,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
const handleSortChange = (
|
||||
newSortBy: "updatedAt" | "createdAt" | "title",
|
||||
newSortOrder: "asc" | "desc"
|
||||
) => {
|
||||
): void => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
setCurrentPage(1);
|
||||
|
||||
@@ -86,9 +86,13 @@ export default function WorkspaceDetailPage({
|
||||
const [workspace, setWorkspace] = useState<Workspace>(mockWorkspace);
|
||||
const [members, setMembers] = useState<WorkspaceMemberWithUser[]>(mockMembers);
|
||||
const currentUserId = "user-1"; // TODO: Get from auth context
|
||||
const currentUserRole = WorkspaceMemberRole.OWNER; // TODO: Get from API
|
||||
const currentUserRole: WorkspaceMemberRole = WorkspaceMemberRole.OWNER; // TODO: Get from API
|
||||
|
||||
const canInvite = currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
||||
// TODO: Replace with actual role check when API is implemented
|
||||
// Currently hardcoded to OWNER in mock data (line 89)
|
||||
const canInvite =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
const handleUpdateWorkspace = async (name: string): Promise<void> => {
|
||||
// TODO: Replace with real API call
|
||||
|
||||
@@ -41,12 +41,12 @@ export default function WorkspacesPage(): ReactElement {
|
||||
const membership = mockMemberships.find((m) => m.workspaceId === workspace.id);
|
||||
return {
|
||||
...workspace,
|
||||
userRole: membership?.role || WorkspaceMemberRole.GUEST,
|
||||
memberCount: membership?.memberCount || 0,
|
||||
userRole: membership?.role ?? WorkspaceMemberRole.GUEST,
|
||||
memberCount: membership?.memberCount ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
const handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!newWorkspaceName.trim()) return;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import TasksPage from "./page";
|
||||
|
||||
// Mock the TaskList component
|
||||
vi.mock("@/components/tasks/TaskList", () => ({
|
||||
TaskList: ({ tasks, isLoading }: { tasks: unknown[]; isLoading: boolean }) => (
|
||||
TaskList: ({ tasks, isLoading }: { tasks: unknown[]; isLoading: boolean }): React.JSX.Element => (
|
||||
<div data-testid="task-list">{isLoading ? "Loading" : `${String(tasks.length)} tasks`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -28,19 +28,19 @@ export default function ChatPage(): ReactElement {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
|
||||
const handleConversationChange = (conversationId: string | null) => {
|
||||
const handleConversationChange = (conversationId: string | null): void => {
|
||||
setCurrentConversationId(conversationId);
|
||||
// NOTE: Update sidebar when conversation changes (see issue #TBD)
|
||||
};
|
||||
|
||||
const handleSelectConversation = async (conversationId: string | null) => {
|
||||
const handleSelectConversation = async (conversationId: string | null): Promise<void> => {
|
||||
if (conversationId) {
|
||||
await chatRef.current?.loadConversation(conversationId);
|
||||
setCurrentConversationId(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewConversation = (projectId?: string | null) => {
|
||||
const handleNewConversation = (projectId?: string | null): void => {
|
||||
chatRef.current?.startNewConversation(projectId);
|
||||
setCurrentConversationId(null);
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ const initialTasks: Task[] = [
|
||||
export default function KanbanDemoPage(): ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks);
|
||||
|
||||
const handleStatusChange = (taskId: string, newStatus: TaskStatus) => {
|
||||
const handleStatusChange = (taskId: string, newStatus: TaskStatus): void => {
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task.id === taskId
|
||||
|
||||
@@ -5,7 +5,11 @@ import Home from "./page";
|
||||
// Mock Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
useRouter: (): {
|
||||
push: typeof mockPush;
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
prefetch: ReturnType<typeof vi.fn>;
|
||||
} => ({
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
@@ -14,7 +18,13 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
useAuth: (): {
|
||||
user: null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
signOut: ReturnType<typeof vi.fn>;
|
||||
refreshSession: ReturnType<typeof vi.fn>;
|
||||
} => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
|
||||
@@ -52,34 +52,38 @@ export default function TeamDetailPage(): ReactElement {
|
||||
const [team] = useState(mockTeamWithMembers);
|
||||
const [isLoading] = useState(false);
|
||||
|
||||
const handleUpdateTeam = async (data: { name?: string; description?: string }): Promise<void> => {
|
||||
const handleUpdateTeam = (data: { name?: string; description?: string }): Promise<void> => {
|
||||
// TODO: Replace with real API call
|
||||
// await updateTeam(workspaceId, teamId, data);
|
||||
console.log("Updating team:", data);
|
||||
// TODO: Refetch team data
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (): Promise<void> => {
|
||||
const handleDeleteTeam = (): Promise<void> => {
|
||||
// TODO: Replace with real API call
|
||||
// await deleteTeam(workspaceId, teamId);
|
||||
console.log("Deleting team");
|
||||
|
||||
// Navigate back to teams list
|
||||
router.push(`/settings/workspaces/${workspaceId}/teams`);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId: string, role?: TeamMemberRole): Promise<void> => {
|
||||
const handleAddMember = (userId: string, role?: TeamMemberRole): Promise<void> => {
|
||||
// TODO: Replace with real API call
|
||||
// await addTeamMember(workspaceId, teamId, { userId, role });
|
||||
console.log("Adding member:", { userId, role });
|
||||
// TODO: Refetch team data
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string): Promise<void> => {
|
||||
const handleRemoveMember = (userId: string): Promise<void> => {
|
||||
// TODO: Replace with real API call
|
||||
// await removeTeamMember(workspaceId, teamId, userId);
|
||||
console.log("Removing member:", userId);
|
||||
// TODO: Refetch team data
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { Button } from "@mosaic/ui";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
export function LoginButton() {
|
||||
const handleLogin = () => {
|
||||
export function LoginButton(): React.JSX.Element {
|
||||
const handleLogin = (): void => {
|
||||
// Redirect to the backend OIDC authentication endpoint
|
||||
// BetterAuth will handle the OIDC flow and redirect back to the callback
|
||||
window.location.assign(`${API_URL}/auth/callback/authentik`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LogoutButton } from "./LogoutButton";
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
useRouter: (): { push: typeof mockPush } => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
|
||||
// Mock auth context
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
useAuth: (): { signOut: typeof mockSignOut } => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
@@ -51,7 +51,9 @@ describe("LogoutButton", (): void => {
|
||||
mockSignOut.mockRejectedValue(new Error("Sign out failed"));
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
// Intentionally empty - suppressing error output
|
||||
});
|
||||
|
||||
render(<LogoutButton />);
|
||||
|
||||
|
||||
@@ -9,11 +9,14 @@ interface LogoutButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoutButton({ variant = "secondary", className }: LogoutButtonProps) {
|
||||
export function LogoutButton({
|
||||
variant = "secondary",
|
||||
className,
|
||||
}: LogoutButtonProps): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
const handleSignOut = async (): Promise<void> => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface CalendarProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function Calendar({ events, isLoading }: CalendarProps) {
|
||||
export function Calendar({ events, isLoading }: CalendarProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
@@ -29,9 +29,7 @@ export function Calendar({ events, isLoading }: CalendarProps) {
|
||||
// Group events by date
|
||||
const groupedEvents = events.reduce<Record<string, Event[]>>((groups, event) => {
|
||||
const label = getDateGroupLabel(event.startTime);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label] ??= [] as Event[];
|
||||
groups[label].push(event);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
@@ -5,7 +5,7 @@ interface EventCardProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
export function EventCard({ event }: EventCardProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg border-l-4 border-blue-500 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState } from "react";
|
||||
*
|
||||
* NOTE: Integrate with actual backend status checking hook (see issue #TBD)
|
||||
*/
|
||||
export function BackendStatusBanner() {
|
||||
export function BackendStatusBanner(): React.JSX.Element | null {
|
||||
const [isAvailable, _setIsAvailable] = useState(true);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
const [_retryIn, _setRetryIn] = useState(0);
|
||||
@@ -16,7 +16,7 @@ export function BackendStatusBanner() {
|
||||
// NOTE: Replace with actual useBackendStatus hook (see issue #TBD)
|
||||
// const { isAvailable, error, retryIn, manualRetry } = useBackendStatus();
|
||||
|
||||
const manualRetry = () => {
|
||||
const manualRetry = (): void => {
|
||||
// NOTE: Implement manual retry logic (see issue #TBD)
|
||||
void 0; // Placeholder until implemented
|
||||
};
|
||||
@@ -63,8 +63,8 @@ export function BackendStatusBanner() {
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{_error || "Backend temporarily unavailable."}
|
||||
{_retryIn > 0 && <span className="ml-1">Retrying in {_retryIn}s...</span>}
|
||||
{_error ?? "Backend temporarily unavailable."}
|
||||
{_retryIn > 0 && <span className="ml-1">Retrying in {String(_retryIn)}s...</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -96,13 +96,13 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadConversation: async (conversationId: string) => {
|
||||
loadConversation: async (conversationId: string): Promise<void> => {
|
||||
await loadConversation(conversationId);
|
||||
},
|
||||
startNewConversation: (projectId?: string | null) => {
|
||||
startNewConversation: (projectId?: string | null): void => {
|
||||
startNewConversation(projectId);
|
||||
},
|
||||
getCurrentConversationId: () => conversationId,
|
||||
getCurrentConversationId: (): string | null => conversationId,
|
||||
}));
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -130,7 +130,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
|
||||
@@ -9,14 +9,19 @@ interface ChatInputProps {
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
interface VersionData {
|
||||
version?: string;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
fetch("/version.json")
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.json() as Promise<VersionData>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
@@ -83,10 +88,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||
minHeight: "48px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
onInput={(e) => {
|
||||
onInput={(e): void => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px";
|
||||
target.style.height = `${String(Math.min(target.scrollHeight, 200))}px`;
|
||||
}}
|
||||
aria-label="Message input"
|
||||
aria-describedby="input-help"
|
||||
@@ -96,7 +101,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim() || isOverLimit}
|
||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle, useCallback } from "react";
|
||||
@@ -54,7 +55,7 @@ export const ConversationSidebar = forwardRef<ConversationSidebarRef, Conversati
|
||||
id: idea.id,
|
||||
title: idea.title ?? null,
|
||||
projectId: idea.projectId ?? null,
|
||||
updatedAt: idea.updatedAt ?? null,
|
||||
updatedAt: idea.updatedAt,
|
||||
messageCount,
|
||||
};
|
||||
}, []);
|
||||
@@ -94,18 +95,21 @@ export const ConversationSidebar = forwardRef<ConversationSidebarRef, Conversati
|
||||
}, [fetchConversations]);
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: async () => {
|
||||
await fetchConversations();
|
||||
},
|
||||
addConversation: (conversation: ConversationSummary) => {
|
||||
setConversations((prev) => [conversation, ...prev]);
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ConversationSidebarRef => ({
|
||||
refresh: async (): Promise<void> => {
|
||||
await fetchConversations();
|
||||
},
|
||||
addConversation: (conversation: ConversationSummary): void => {
|
||||
setConversations((prev) => [conversation, ...prev]);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const filteredConversations = conversations.filter((conv) => {
|
||||
const filteredConversations = conversations.filter((conv): boolean => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const title = conv.title || "Untitled conversation";
|
||||
const title = conv.title ?? "Untitled conversation";
|
||||
return title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
@@ -118,14 +122,14 @@ export const ConversationSidebar = forwardRef<ConversationSidebarRef, Conversati
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMins < 60) return `${String(diffMins)}m ago`;
|
||||
if (diffHours < 24) return `${String(diffHours)}h ago`;
|
||||
if (diffDays < 7) return `${String(diffDays)}d ago`;
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const truncateTitle = (title: string | null, maxLength = 32): string => {
|
||||
const displayTitle = title || "Untitled conversation";
|
||||
const displayTitle = title ?? "Untitled conversation";
|
||||
if (displayTitle.length <= maxLength) return displayTitle;
|
||||
return displayTitle.substring(0, maxLength - 1) + "…";
|
||||
};
|
||||
|
||||
@@ -32,13 +32,18 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
||||
// Remove thinking blocks from response
|
||||
const response = content.replace(thinkingRegex, "").trim();
|
||||
|
||||
const trimmedThinking = thinking.trim();
|
||||
return {
|
||||
thinking: thinking.trim() || null,
|
||||
thinking: trimmedThinking.length > 0 ? trimmedThinking : null,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isLoading, loadingQuip }: MessageListProps) {
|
||||
export function MessageList({
|
||||
messages,
|
||||
isLoading,
|
||||
loadingQuip,
|
||||
}: MessageListProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||
{messages.map((message) => (
|
||||
@@ -50,7 +55,7 @@ export function MessageList({ messages, isLoading, loadingQuip }: MessageListPro
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
@@ -122,7 +127,7 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
color: "rgb(var(--text-muted))",
|
||||
}}
|
||||
title={`Prompt: ${message.promptTokens?.toLocaleString() || "0"} tokens, Completion: ${message.completionTokens?.toLocaleString() || "0"} tokens`}
|
||||
title={`Prompt: ${message.promptTokens?.toLocaleString() ?? "0"} tokens, Completion: ${message.completionTokens?.toLocaleString() ?? "0"} tokens`}
|
||||
>
|
||||
{formatTokenCount(message.totalTokens)} tokens
|
||||
</span>
|
||||
@@ -238,7 +243,7 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIndicator({ quip }: { quip?: string | null }) {
|
||||
function LoadingIndicator({ quip }: { quip?: string | null }): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
|
||||
{/* Avatar */}
|
||||
|
||||
@@ -6,7 +6,10 @@ interface DomainOverviewWidgetProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetProps) {
|
||||
export function DomainOverviewWidget({
|
||||
tasks,
|
||||
isLoading,
|
||||
}: DomainOverviewWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
@@ -25,7 +28,15 @@ export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetP
|
||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
||||
};
|
||||
|
||||
const StatCard = ({ label, value, color }: { label: string; value: number; color: string }) => (
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}): React.JSX.Element => (
|
||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
||||
<div className="text-sm text-white/90">{label}</div>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useState } from "react";
|
||||
import { Button } from "@mosaic/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function QuickCaptureWidget() {
|
||||
export function QuickCaptureWidget(): React.JSX.Element {
|
||||
const [idea, setIdea] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
if (!idea.trim()) return;
|
||||
|
||||
@@ -18,7 +18,7 @@ export function QuickCaptureWidget() {
|
||||
setIdea("");
|
||||
};
|
||||
|
||||
const goToTasks = () => {
|
||||
const goToTasks = (): void => {
|
||||
router.push("/tasks");
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const statusIcons: Record<TaskStatus, string> = {
|
||||
[TaskStatus.ARCHIVED]: "💤",
|
||||
};
|
||||
|
||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps) {
|
||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
|
||||
@@ -7,7 +7,10 @@ interface UpcomingEventsWidgetProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function UpcomingEventsWidget({ events, isLoading }: UpcomingEventsWidgetProps) {
|
||||
export function UpcomingEventsWidget({
|
||||
events,
|
||||
isLoading,
|
||||
}: UpcomingEventsWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DomainFilter({
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: selectedDomain === domain.id ? domain.color || "#374151" : undefined,
|
||||
backgroundColor: selectedDomain === domain.id ? (domain.color ?? "#374151") : undefined,
|
||||
}}
|
||||
aria-label={`Filter by ${domain.name}`}
|
||||
aria-pressed={selectedDomain === domain.id}
|
||||
|
||||
@@ -54,8 +54,11 @@ describe("DomainList", (): void => {
|
||||
render(<DomainList domains={mockDomains} isLoading={false} onEdit={onEdit} />);
|
||||
|
||||
const editButtons = screen.getAllByRole("button", { name: /edit/i });
|
||||
editButtons[0]!.click();
|
||||
expect(onEdit).toHaveBeenCalledWith(mockDomains[0]);
|
||||
const firstButton = editButtons[0];
|
||||
if (firstButton) {
|
||||
firstButton.click();
|
||||
expect(onEdit).toHaveBeenCalledWith(mockDomains[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should call onDelete when delete button clicked", (): void => {
|
||||
@@ -63,8 +66,11 @@ describe("DomainList", (): void => {
|
||||
render(<DomainList domains={mockDomains} isLoading={false} onDelete={onDelete} />);
|
||||
|
||||
const deleteButtons = screen.getAllByRole("button", { name: /delete/i });
|
||||
deleteButtons[0]!.click();
|
||||
expect(onDelete).toHaveBeenCalledWith(mockDomains[0]);
|
||||
const firstButton = deleteButtons[0];
|
||||
if (firstButton) {
|
||||
firstButton.click();
|
||||
expect(onDelete).toHaveBeenCalledWith(mockDomains[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle undefined domains gracefully", (): void => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function DomainList({
|
||||
);
|
||||
}
|
||||
|
||||
if (!domains || domains.length === 0) {
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No domains created yet</p>
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("DomainSelector", (): void => {
|
||||
const onChange = vi.fn();
|
||||
render(<DomainSelector domains={mockDomains} value="domain-1" onChange={onChange} />);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("domain-1");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
|
||||
// Component that throws an error for testing
|
||||
function ThrowError({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
function ThrowError({ shouldThrow }: { shouldThrow: boolean }): React.JSX.Element {
|
||||
if (shouldThrow) {
|
||||
throw new Error("Test error");
|
||||
}
|
||||
@@ -49,7 +50,7 @@ describe("ErrorBoundary", (): void => {
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const errorText = screen.getByText(/something unexpected happened/i).textContent || "";
|
||||
const errorText = screen.getByText(/something unexpected happened/i).textContent ?? "";
|
||||
|
||||
// Should NOT contain demanding/harsh words
|
||||
expect(errorText.toLowerCase()).not.toMatch(/error|critical|urgent|must|required/);
|
||||
@@ -108,7 +109,7 @@ describe("ErrorBoundary", (): void => {
|
||||
const container = screen.getByText(/something unexpected happened/i).closest("div");
|
||||
|
||||
// Should not have aggressive red colors (check for calm colors)
|
||||
const className = container?.className || "";
|
||||
const className = container?.className ?? "";
|
||||
expect(className).not.toMatch(/bg-red-|text-red-/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,16 +29,16 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
// Log to console for debugging (could also send to error tracking service)
|
||||
console.error("Component error:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
|
||||
@@ -23,9 +23,9 @@ export function FilterBar({
|
||||
onFilterChange,
|
||||
initialFilters = {},
|
||||
debounceMs = 300,
|
||||
}: FilterBarProps) {
|
||||
}: FilterBarProps): React.JSX.Element {
|
||||
const [filters, setFilters] = useState<FilterValues>(initialFilters);
|
||||
const [searchValue, setSearchValue] = useState(initialFilters.search || "");
|
||||
const [searchValue, setSearchValue] = useState(initialFilters.search ?? "");
|
||||
const [showStatusDropdown, setShowStatusDropdown] = useState(false);
|
||||
const [showPriorityDropdown, setShowPriorityDropdown] = useState(false);
|
||||
|
||||
@@ -50,42 +50,46 @@ export function FilterBar({
|
||||
}, [searchValue, debounceMs]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof FilterValues, value: FilterValues[keyof FilterValues]) => {
|
||||
(key: keyof FilterValues, value: FilterValues[keyof FilterValues]): void => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
delete newFilters[key];
|
||||
// Use Object.assign to avoid dynamic delete
|
||||
const { [key]: _removed, ...rest } = newFilters;
|
||||
setFilters(rest);
|
||||
onFilterChange(rest);
|
||||
} else {
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
}
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
},
|
||||
[filters, onFilterChange]
|
||||
);
|
||||
|
||||
const handleStatusToggle = (status: TaskStatus) => {
|
||||
const currentStatuses = filters.status || [];
|
||||
const handleStatusToggle = (status: TaskStatus): void => {
|
||||
const currentStatuses = filters.status ?? [];
|
||||
const newStatuses = currentStatuses.includes(status)
|
||||
? currentStatuses.filter((s) => s !== status)
|
||||
: [...currentStatuses, status];
|
||||
handleFilterChange("status", newStatuses.length > 0 ? newStatuses : undefined);
|
||||
};
|
||||
|
||||
const handlePriorityToggle = (priority: TaskPriority) => {
|
||||
const currentPriorities = filters.priority || [];
|
||||
const handlePriorityToggle = (priority: TaskPriority): void => {
|
||||
const currentPriorities = filters.priority ?? [];
|
||||
const newPriorities = currentPriorities.includes(priority)
|
||||
? currentPriorities.filter((p) => p !== priority)
|
||||
: [...currentPriorities, priority];
|
||||
handleFilterChange("priority", newPriorities.length > 0 ? newPriorities : undefined);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const clearAllFilters = (): void => {
|
||||
setFilters({});
|
||||
setSearchValue("");
|
||||
onFilterChange({});
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
(filters.status?.length || 0) +
|
||||
(filters.priority?.length || 0) +
|
||||
(filters.status?.length ?? 0) +
|
||||
(filters.priority?.length ?? 0) +
|
||||
(filters.search ? 1 : 0) +
|
||||
(filters.dateFrom ? 1 : 0) +
|
||||
(filters.dateTo ? 1 : 0);
|
||||
@@ -132,7 +136,7 @@ export function FilterBar({
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.status?.includes(status) || false}
|
||||
checked={filters.status?.includes(status) ?? false}
|
||||
onChange={() => {
|
||||
handleStatusToggle(status);
|
||||
}}
|
||||
@@ -170,7 +174,7 @@ export function FilterBar({
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.priority?.includes(priority) || false}
|
||||
checked={filters.priority?.includes(priority) ?? false}
|
||||
onChange={() => {
|
||||
handlePriorityToggle(priority);
|
||||
}}
|
||||
@@ -188,7 +192,7 @@ export function FilterBar({
|
||||
<input
|
||||
type="date"
|
||||
placeholder="From date"
|
||||
value={filters.dateFrom || ""}
|
||||
value={filters.dateFrom ?? ""}
|
||||
onChange={(e) => {
|
||||
handleFilterChange("dateFrom", e.target.value || undefined);
|
||||
}}
|
||||
@@ -198,7 +202,7 @@ export function FilterBar({
|
||||
<input
|
||||
type="date"
|
||||
placeholder="To date"
|
||||
value={filters.dateTo || ""}
|
||||
value={filters.dateTo ?? ""}
|
||||
onChange={(e) => {
|
||||
handleFilterChange("dateTo", e.target.value || undefined);
|
||||
}}
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("GanttChart", (): void => {
|
||||
const baseDate = new Date("2026-02-01T00:00:00Z");
|
||||
|
||||
const createGanttTask = (overrides: Partial<GanttTask> = {}): GanttTask => ({
|
||||
id: `task-${Math.random()}`,
|
||||
id: `task-${String(Math.random())}`,
|
||||
workspaceId: "workspace-1",
|
||||
title: "Sample Task",
|
||||
description: null,
|
||||
@@ -90,7 +90,8 @@ describe("GanttChart", (): void => {
|
||||
|
||||
render(<GanttChart tasks={tasks} />);
|
||||
|
||||
const taskRow = screen.getAllByText("Completed Task")[0]!.closest("[role='row']");
|
||||
const taskElements = screen.getAllByText("Completed Task");
|
||||
const taskRow = taskElements[0]?.closest("[role='row']");
|
||||
expect(taskRow?.className).toMatch(/Completed/i);
|
||||
});
|
||||
|
||||
@@ -105,7 +106,8 @@ describe("GanttChart", (): void => {
|
||||
|
||||
render(<GanttChart tasks={tasks} />);
|
||||
|
||||
const taskRow = screen.getAllByText("Active Task")[0]!.closest("[role='row']");
|
||||
const taskElements = screen.getAllByText("Active Task");
|
||||
const taskRow = taskElements[0]?.closest("[role='row']");
|
||||
expect(taskRow?.className).toMatch(/InProgress/i);
|
||||
});
|
||||
});
|
||||
@@ -219,8 +221,8 @@ describe("GanttChart", (): void => {
|
||||
expect(bars).toHaveLength(2);
|
||||
|
||||
// Second bar should be wider (more days)
|
||||
const bar1Width = bars[0]!.style.width;
|
||||
const bar2Width = bars[1]!.style.width;
|
||||
const bar1Width = bars[0]?.style.width;
|
||||
const bar2Width = bars[1]?.style.width;
|
||||
|
||||
// Basic check that widths are set (exact values depend on implementation)
|
||||
expect(bar1Width).toBeTruthy();
|
||||
@@ -344,11 +346,15 @@ describe("GanttChart", (): void => {
|
||||
|
||||
render(<GanttChart tasks={tasks} />);
|
||||
|
||||
const taskNames = screen.getAllByRole("row").map((row) => row.textContent);
|
||||
const taskNames = screen.getAllByRole("row").map((row): string | null => row.textContent);
|
||||
|
||||
// Early Task should appear first
|
||||
const earlyIndex = taskNames.findIndex((name) => name?.includes("Early Task"));
|
||||
const lateIndex = taskNames.findIndex((name) => name?.includes("Late Task"));
|
||||
const earlyIndex = taskNames.findIndex(
|
||||
(name): boolean => name?.includes("Early Task") ?? false
|
||||
);
|
||||
const lateIndex = taskNames.findIndex(
|
||||
(name): boolean => name?.includes("Late Task") ?? false
|
||||
);
|
||||
|
||||
expect(earlyIndex).toBeLessThan(lateIndex);
|
||||
});
|
||||
|
||||
@@ -34,10 +34,22 @@ function calculateTimelineRange(tasks: GanttTask[]): TimelineRange {
|
||||
};
|
||||
}
|
||||
|
||||
let earliest = tasks[0]!.startDate;
|
||||
let latest = tasks[0]!.endDate;
|
||||
const firstTask = tasks[0];
|
||||
if (!firstTask) {
|
||||
// This should not happen due to the check above, but makes TS happy
|
||||
const now = new Date();
|
||||
const oneMonthLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
return {
|
||||
start: now,
|
||||
end: oneMonthLater,
|
||||
totalDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
tasks.forEach((task) => {
|
||||
let earliest = firstTask.startDate;
|
||||
let latest = firstTask.endDate;
|
||||
|
||||
tasks.forEach((task): void => {
|
||||
if (task.startDate < earliest) {
|
||||
earliest = task.startDate;
|
||||
}
|
||||
@@ -82,8 +94,8 @@ function calculateBarPosition(
|
||||
const widthPercent = (taskDuration / totalDays) * 100;
|
||||
|
||||
const result: GanttBarPosition = {
|
||||
left: `${leftPercent}%`,
|
||||
width: `${widthPercent}%`,
|
||||
left: `${String(leftPercent)}%`,
|
||||
width: `${String(widthPercent)}%`,
|
||||
top: rowIndex * 48, // 48px row height
|
||||
};
|
||||
|
||||
@@ -114,11 +126,11 @@ function getStatusClass(status: TaskStatus): string {
|
||||
function getRowStatusClass(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case TaskStatus.COMPLETED:
|
||||
return styles.rowCompleted || "";
|
||||
return styles.rowCompleted ?? "";
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return styles.rowInProgress || "";
|
||||
return styles.rowInProgress ?? "";
|
||||
case TaskStatus.PAUSED:
|
||||
return styles.rowPaused || "";
|
||||
return styles.rowPaused ?? "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -172,13 +184,16 @@ function calculateDependencyLines(
|
||||
return;
|
||||
}
|
||||
|
||||
task.dependencies.forEach((depId) => {
|
||||
task.dependencies.forEach((depId): void => {
|
||||
const fromIndex = taskIndexMap.get(depId);
|
||||
if (fromIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromTask = tasks[fromIndex]!;
|
||||
const fromTask = tasks[fromIndex];
|
||||
if (!fromTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate positions (as percentages)
|
||||
const fromEndOffset = Math.max(
|
||||
|
||||
@@ -201,8 +201,8 @@ describe("Gantt Types Helpers", (): void => {
|
||||
const ganttTasks = toGanttTasks(tasks);
|
||||
|
||||
expect(ganttTasks).toHaveLength(2);
|
||||
expect(ganttTasks[0]!.id).toBe("task-1");
|
||||
expect(ganttTasks[1]!.id).toBe("task-2");
|
||||
expect(ganttTasks[0]?.id).toBe("task-1");
|
||||
expect(ganttTasks[1]?.id).toBe("task-2");
|
||||
});
|
||||
|
||||
it("should filter out tasks that cannot be converted", (): void => {
|
||||
@@ -240,9 +240,9 @@ describe("Gantt Types Helpers", (): void => {
|
||||
|
||||
const ganttTasks = toGanttTasks(tasks);
|
||||
|
||||
expect(ganttTasks[0]!.id).toBe("first");
|
||||
expect(ganttTasks[1]!.id).toBe("second");
|
||||
expect(ganttTasks[2]!.id).toBe("third");
|
||||
expect(ganttTasks[0]?.id).toBe("first");
|
||||
expect(ganttTasks[1]?.id).toBe("second");
|
||||
expect(ganttTasks[2]?.id).toBe("third");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,25 +78,20 @@ function isStringArray(value: unknown): value is string[] {
|
||||
*/
|
||||
export function toGanttTask(task: Task): GanttTask | null {
|
||||
// For Gantt chart, we need both start and end dates
|
||||
const metadataStartDate = task.metadata?.startDate;
|
||||
const metadataStartDate = task.metadata.startDate;
|
||||
const startDate = isDateString(metadataStartDate) ? new Date(metadataStartDate) : task.createdAt;
|
||||
|
||||
const endDate = task.dueDate ?? new Date();
|
||||
|
||||
// Validate dates
|
||||
if (!startDate || !endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract dependencies with type guard
|
||||
const metadataDependencies = task.metadata?.dependencies;
|
||||
const metadataDependencies = task.metadata.dependencies;
|
||||
const dependencies = isStringArray(metadataDependencies) ? metadataDependencies : undefined;
|
||||
|
||||
const ganttTask: GanttTask = {
|
||||
...task,
|
||||
startDate,
|
||||
endDate,
|
||||
isMilestone: task.metadata?.isMilestone === true,
|
||||
isMilestone: task.metadata.isMilestone === true,
|
||||
};
|
||||
|
||||
if (dependencies) {
|
||||
|
||||
@@ -59,7 +59,7 @@ const WIDGET_REGISTRY = {
|
||||
|
||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||
|
||||
export function HUD({ className = "" }: HUDProps) {
|
||||
export function HUD({ className = "" }: HUDProps): React.JSX.Element {
|
||||
const { currentLayout, updateLayout, addWidget, removeWidget, switchLayout, resetLayout } =
|
||||
useLayout();
|
||||
|
||||
@@ -67,16 +67,16 @@ export function HUD({ className = "" }: HUDProps) {
|
||||
|
||||
const handleLayoutChange = (
|
||||
newLayout: readonly { i: string; x: number; y: number; w: number; h: number }[]
|
||||
) => {
|
||||
): void => {
|
||||
updateLayout([...newLayout] as WidgetPlacement[]);
|
||||
};
|
||||
|
||||
const handleAddWidget = (widgetType: WidgetRegistryKey) => {
|
||||
const handleAddWidget = (widgetType: WidgetRegistryKey): void => {
|
||||
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
||||
const widgetId = `${widgetType.toLowerCase()}-${Date.now()}`;
|
||||
const widgetId = `${widgetType.toLowerCase()}-${String(Date.now())}`;
|
||||
|
||||
// Find the next available position
|
||||
const maxY = currentLayout?.layout.reduce((max, w) => Math.max(max, w.y + w.h), 0) || 0;
|
||||
const maxY = currentLayout?.layout.reduce((max, w): number => Math.max(max, w.y + w.h), 0) ?? 0;
|
||||
|
||||
const newWidget = {
|
||||
i: widgetId,
|
||||
@@ -93,7 +93,7 @@ export function HUD({ className = "" }: HUDProps) {
|
||||
addWidget(newWidget);
|
||||
};
|
||||
|
||||
const handleResetLayout = () => {
|
||||
const handleResetLayout = (): void => {
|
||||
if (confirm("Are you sure you want to reset the layout? This will remove all widgets.")) {
|
||||
resetLayout();
|
||||
}
|
||||
@@ -120,8 +120,8 @@ export function HUD({ className = "" }: HUDProps) {
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={currentLayout?.id || ""}
|
||||
onChange={(e) => {
|
||||
value={currentLayout?.id ?? ""}
|
||||
onChange={(e): void => {
|
||||
switchLayout(e.target.value);
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@@ -143,10 +143,10 @@ export function HUD({ className = "" }: HUDProps) {
|
||||
{/* Widget type selector */}
|
||||
<div className="relative">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const widgetType = e.target.value as WidgetRegistryKey;
|
||||
if (widgetType) {
|
||||
handleAddWidget(widgetType);
|
||||
onChange={(e): void => {
|
||||
const widgetType = e.target.value;
|
||||
if (widgetType && widgetType in WIDGET_REGISTRY) {
|
||||
handleAddWidget(widgetType as WidgetRegistryKey);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
@@ -169,7 +169,7 @@ export function HUD({ className = "" }: HUDProps) {
|
||||
|
||||
{/* Widget Grid */}
|
||||
<WidgetGrid
|
||||
layout={currentLayout?.layout || []}
|
||||
layout={currentLayout?.layout ?? []}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function WidgetGrid({
|
||||
margin = [16, 16],
|
||||
containerPadding = [16, 16],
|
||||
className = "",
|
||||
}: WidgetGridProps) {
|
||||
}: WidgetGridProps): React.JSX.Element {
|
||||
// Use hook to measure container width
|
||||
const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
|
||||
// Convert our WidgetPlacement to react-grid-layout's Layout format
|
||||
@@ -74,8 +74,8 @@ export function WidgetGrid({
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
minW: widget.minW || 1,
|
||||
minH: widget.minH || 1,
|
||||
minW: widget.minW ?? 1,
|
||||
minH: widget.minH ?? 1,
|
||||
};
|
||||
|
||||
if (widget.maxW !== undefined) layoutItem.maxW = widget.maxW;
|
||||
@@ -87,7 +87,7 @@ export function WidgetGrid({
|
||||
return layoutItem;
|
||||
});
|
||||
|
||||
const handleLayoutChange = (layout: readonly WidgetPlacement[]) => {
|
||||
const handleLayoutChange = (layout: readonly WidgetPlacement[]): void => {
|
||||
if (onLayoutChange) {
|
||||
onLayoutChange(layout);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function WidgetGrid({
|
||||
width={width}
|
||||
>
|
||||
{children.map((child, index) => (
|
||||
<div key={layout[index]?.i || index}>{child}</div>
|
||||
<div key={layout[index]?.i ?? index}>{child}</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Widget renderer - renders the appropriate widget component based on type
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
|
||||
import { WidgetWrapper } from "./WidgetWrapper";
|
||||
import {
|
||||
TasksWidget,
|
||||
@@ -43,7 +45,11 @@ const WIDGET_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRendererProps) {
|
||||
export function WidgetRenderer({
|
||||
widget,
|
||||
isEditing = false,
|
||||
onRemove,
|
||||
}: WidgetRendererProps): React.JSX.Element {
|
||||
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
|
||||
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
|
||||
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
||||
@@ -55,7 +61,7 @@ export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRe
|
||||
title: "Unknown Widget",
|
||||
isEditing: isEditing,
|
||||
...(onRemove && {
|
||||
onRemove: () => {
|
||||
onRemove: (): void => {
|
||||
onRemove(widget.i);
|
||||
},
|
||||
}),
|
||||
@@ -73,7 +79,7 @@ export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRe
|
||||
title: config.displayName,
|
||||
isEditing: isEditing,
|
||||
...(onRemove && {
|
||||
onRemove: () => {
|
||||
onRemove: (): void => {
|
||||
onRemove(widget.i);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -29,7 +29,7 @@ export function WidgetWrapper({
|
||||
onRemove,
|
||||
onToggleCollapse,
|
||||
className = "",
|
||||
}: WidgetWrapperProps) {
|
||||
}: WidgetWrapperProps): React.JSX.Element {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
@@ -12,21 +14,21 @@ vi.mock("@dnd-kit/core", async () => {
|
||||
const actual = await vi.importActual("@dnd-kit/core");
|
||||
return {
|
||||
...actual,
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => (
|
||||
DndContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<div data-testid="dnd-context">{children}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => (
|
||||
SortableContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<div data-testid="sortable-context">{children}</div>
|
||||
),
|
||||
verticalListSortingStrategy: {},
|
||||
useSortable: () => ({
|
||||
useSortable: (): object => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: () => {},
|
||||
setNodeRef: (): void => {},
|
||||
transform: null,
|
||||
transition: null,
|
||||
}),
|
||||
@@ -291,7 +293,7 @@ describe("KanbanBoard", (): void => {
|
||||
|
||||
const boardGrid = container.querySelector('[data-testid="kanban-grid"]');
|
||||
expect(boardGrid).toBeInTheDocument();
|
||||
const className = boardGrid?.className || "";
|
||||
const className = boardGrid?.className ?? "";
|
||||
expect(className).toMatch(/grid/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||
import { EntryStatus } from "@mosaic/shared";
|
||||
import Link from "next/link";
|
||||
@@ -31,7 +32,7 @@ const visibilityIcons = {
|
||||
PUBLIC: <Eye className="w-3 h-3" />,
|
||||
};
|
||||
|
||||
export function EntryCard({ entry }: EntryCardProps) {
|
||||
export function EntryCard({ entry }: EntryCardProps): React.JSX.Element {
|
||||
const statusInfo = statusConfig[entry.status];
|
||||
const visibilityIcon = visibilityIcons[entry.visibility];
|
||||
|
||||
@@ -65,7 +66,7 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: tag.color ? `${tag.color}20` : "#E5E7EB",
|
||||
color: tag.color || "#6B7280",
|
||||
color: tag.color ?? "#6B7280",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface EntryEditorProps {
|
||||
/**
|
||||
* EntryEditor - Markdown editor with live preview and link autocomplete
|
||||
*/
|
||||
export function EntryEditor({ content, onChange }: EntryEditorProps) {
|
||||
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@ import { EntryStatus } from "@mosaic/shared";
|
||||
import type { KnowledgeTag } from "@mosaic/shared";
|
||||
import { Search, Filter } from "lucide-react";
|
||||
|
||||
type TagFilter = "all" | (string & Record<never, never>);
|
||||
|
||||
interface EntryFiltersProps {
|
||||
selectedStatus: EntryStatus | "all";
|
||||
selectedTag: string | "all";
|
||||
selectedTag: TagFilter;
|
||||
searchQuery: string;
|
||||
sortBy: "updatedAt" | "createdAt" | "title";
|
||||
sortOrder: "asc" | "desc";
|
||||
tags: KnowledgeTag[];
|
||||
onStatusChange: (status: EntryStatus | "all") => void;
|
||||
onTagChange: (tag: string | "all") => void;
|
||||
onTagChange: (tag: TagFilter) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSortChange: (sortBy: "updatedAt" | "createdAt" | "title", sortOrder: "asc" | "desc") => void;
|
||||
}
|
||||
@@ -26,7 +28,7 @@ export function EntryFilters({
|
||||
onTagChange,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
}: EntryFiltersProps) {
|
||||
}: EntryFiltersProps): React.JSX.Element {
|
||||
const statusOptions: { value: EntryStatus | "all"; label: string }[] = [
|
||||
{ value: "all", label: "All Status" },
|
||||
{ value: EntryStatus.DRAFT, label: "Draft" },
|
||||
|
||||
@@ -41,7 +41,10 @@ interface EntryGraphViewerProps {
|
||||
initialDepth?: number;
|
||||
}
|
||||
|
||||
export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerProps) {
|
||||
export function EntryGraphViewer({
|
||||
slug,
|
||||
initialDepth = 1,
|
||||
}: EntryGraphViewerProps): React.JSX.Element {
|
||||
const [graphData, setGraphData] = useState<EntryGraphResponse | null>(null);
|
||||
const [depth, setDepth] = useState(initialDepth);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -65,7 +68,7 @@ export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerPro
|
||||
void loadGraph();
|
||||
}, [loadGraph]);
|
||||
|
||||
const handleDepthChange = (newDepth: number) => {
|
||||
const handleDepthChange = (newDepth: number): void => {
|
||||
setDepth(newDepth);
|
||||
};
|
||||
|
||||
@@ -77,7 +80,7 @@ export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerPro
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !graphData) {
|
||||
if (error ?? !graphData) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="text-red-500 mb-2">Error loading graph</div>
|
||||
@@ -91,7 +94,7 @@ export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerPro
|
||||
// Group nodes by depth for better visualization
|
||||
const nodesByDepth = nodes.reduce<Record<number, GraphNode[]>>((acc, node) => {
|
||||
const d = node.depth;
|
||||
if (!acc[d]) acc[d] = [];
|
||||
acc[d] ??= [];
|
||||
acc[d].push(node);
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -194,7 +197,7 @@ export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerPro
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: tag.color || "#6B7280",
|
||||
backgroundColor: tag.color ?? "#6B7280",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
@@ -242,7 +245,13 @@ interface NodeCardProps {
|
||||
connections?: { incoming: number; outgoing: number };
|
||||
}
|
||||
|
||||
function NodeCard({ node, isCenter, onClick, isSelected, connections }: NodeCardProps) {
|
||||
function NodeCard({
|
||||
node,
|
||||
isCenter,
|
||||
onClick,
|
||||
isSelected,
|
||||
connections,
|
||||
}: NodeCardProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -269,7 +278,7 @@ function NodeCard({ node, isCenter, onClick, isSelected, connections }: NodeCard
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: tag.color || "#6B7280",
|
||||
backgroundColor: tag.color ?? "#6B7280",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
@@ -294,7 +303,10 @@ function NodeCard({ node, isCenter, onClick, isSelected, connections }: NodeCard
|
||||
);
|
||||
}
|
||||
|
||||
function getNodeConnections(nodeId: string, edges: GraphEdge[]) {
|
||||
function getNodeConnections(
|
||||
nodeId: string,
|
||||
edges: GraphEdge[]
|
||||
): { incoming: number; outgoing: number } {
|
||||
const incoming = edges.filter((e) => e.targetId === nodeId).length;
|
||||
const outgoing = edges.filter((e) => e.sourceId === nodeId).length;
|
||||
return { incoming, outgoing };
|
||||
|
||||
@@ -16,7 +16,7 @@ export function EntryList({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: EntryListProps) {
|
||||
}: EntryListProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
@@ -26,7 +26,7 @@ export function EntryList({
|
||||
);
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
@@ -76,7 +76,7 @@ export function EntryList({
|
||||
|
||||
if (showEllipsisBefore || showEllipsisAfter) {
|
||||
return (
|
||||
<span key={`ellipsis-${page}`} className="px-2 text-gray-500">
|
||||
<span key={`ellipsis-${String(page)}`} className="px-2 text-gray-500">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export function EntryMetadata({
|
||||
onStatusChange,
|
||||
onVisibilityChange,
|
||||
onTagsChange,
|
||||
}: EntryMetadataProps) {
|
||||
}: EntryMetadataProps): React.JSX.Element {
|
||||
const handleTagToggle = (tagId: string): void => {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
onTagsChange(selectedTags.filter((id) => id !== tagId));
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ImportExportActionsProps {
|
||||
export function ImportExportActions({
|
||||
selectedEntryIds = [],
|
||||
onImportComplete,
|
||||
}: ImportExportActionsProps) {
|
||||
}: ImportExportActionsProps): React.JSX.Element {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
|
||||
@@ -38,14 +38,14 @@ export function ImportExportActions({
|
||||
/**
|
||||
* Handle import file selection
|
||||
*/
|
||||
const handleImportClick = () => {
|
||||
const handleImportClick = (): void => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle file upload and import
|
||||
*/
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -69,11 +69,11 @@ export function ImportExportActions({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || "Import failed");
|
||||
const error = (await response.json()) as { message?: string };
|
||||
throw new Error(error.message ?? "Import failed");
|
||||
}
|
||||
|
||||
const result: ImportResponse = await response.json();
|
||||
const result = (await response.json()) as ImportResponse;
|
||||
setImportResult(result);
|
||||
|
||||
// Notify parent component
|
||||
@@ -96,7 +96,7 @@ export function ImportExportActions({
|
||||
/**
|
||||
* Handle export
|
||||
*/
|
||||
const handleExport = async (format: "markdown" | "json" = "markdown") => {
|
||||
const handleExport = async (format: "markdown" | "json" = "markdown"): Promise<void> => {
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
@@ -123,7 +123,7 @@ export function ImportExportActions({
|
||||
// Get filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch?.[1] || `knowledge-export-${format}.zip`;
|
||||
const filename = filenameMatch?.[1] ?? `knowledge-export-${format}.zip`;
|
||||
|
||||
// Download file
|
||||
const blob = await response.blob();
|
||||
@@ -146,7 +146,7 @@ export function ImportExportActions({
|
||||
/**
|
||||
* Close import dialog
|
||||
*/
|
||||
const handleCloseImportDialog = () => {
|
||||
const handleCloseImportDialog = (): void => {
|
||||
setShowImportDialog(false);
|
||||
setImportResult(null);
|
||||
};
|
||||
@@ -281,7 +281,7 @@ export function ImportExportActions({
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">
|
||||
{result.title || result.filename}
|
||||
{result.title ?? result.filename}
|
||||
</div>
|
||||
{result.success ? (
|
||||
<div className="text-xs text-gray-600">
|
||||
|
||||
@@ -138,10 +138,9 @@ export function LinkAutocomplete({
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
mirror.style.width = `${String(textarea.clientWidth)}px`;
|
||||
mirror.style.height = "auto";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
|
||||
// Get text up to cursor
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorIndex);
|
||||
@@ -341,8 +340,8 @@ export function LinkAutocomplete({
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-64 overflow-y-auto min-w-[300px] max-w-[500px]"
|
||||
style={{
|
||||
top: `${state.position.top}px`,
|
||||
left: `${state.position.left}px`,
|
||||
top: `${String(state.position.top)}px`,
|
||||
left: `${String(state.position.left)}px`,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -37,13 +37,13 @@ interface KnowledgeStats {
|
||||
}[];
|
||||
}
|
||||
|
||||
export function StatsDashboard() {
|
||||
export function StatsDashboard(): React.JSX.Element {
|
||||
const [stats, setStats] = useState<KnowledgeStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
async function loadStats(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchKnowledgeStats();
|
||||
@@ -91,7 +91,7 @@ export function StatsDashboard() {
|
||||
<StatsCard
|
||||
title="Total Entries"
|
||||
value={overview.totalEntries}
|
||||
subtitle={`${overview.publishedEntries} published • ${overview.draftEntries} drafts`}
|
||||
subtitle={`${String(overview.publishedEntries)} published • ${String(overview.draftEntries)} drafts`}
|
||||
icon="📚"
|
||||
/>
|
||||
<StatsCard
|
||||
@@ -229,7 +229,7 @@ function StatsCard({
|
||||
value: number;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
}) {
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -68,7 +68,7 @@ export function VersionHistory({ slug, onRestore }: VersionHistoryProps): React.
|
||||
setIsRestoring(true);
|
||||
setError(null);
|
||||
await restoreVersion(slug, version, {
|
||||
changeNote: `Restored from version ${version}`,
|
||||
changeNote: `Restored from version ${String(version)}`,
|
||||
});
|
||||
setSelectedVersion(null);
|
||||
setPage(1); // Reload first page to see new version
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable security/detect-unsafe-regex */
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
@@ -53,7 +54,7 @@ function parseWikiLinks(html: string): string {
|
||||
|
||||
return html.replace(wikiLinkRegex, (match, slug: string, displayText?: string) => {
|
||||
const trimmedSlug = slug.trim();
|
||||
const text = displayText?.trim() || trimmedSlug;
|
||||
const text = displayText?.trim() ?? trimmedSlug;
|
||||
|
||||
// Create a styled link
|
||||
// Using data-wiki-link attribute for styling and click handling
|
||||
@@ -77,7 +78,7 @@ function handleWikiLinkClick(e: React.MouseEvent<HTMLDivElement>): void {
|
||||
// Check if the clicked element is a wiki-link
|
||||
if (target.tagName === "A" && target.dataset.wikiLink === "true") {
|
||||
const href = target.getAttribute("href");
|
||||
if (href && href.startsWith("/knowledge/")) {
|
||||
if (href?.startsWith("/knowledge/")) {
|
||||
// Let Next.js Link handle navigation naturally
|
||||
// No need to preventDefault - the href will work
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { KnowledgeBacklink } from "@mosaic/shared";
|
||||
|
||||
// Mock Next.js Link component
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => {
|
||||
return <a href={href}>{children}</a>;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,7 +6,9 @@ import { EntryEditor } from "../EntryEditor";
|
||||
|
||||
// Mock the LinkAutocomplete component
|
||||
vi.mock("../LinkAutocomplete", () => ({
|
||||
LinkAutocomplete: () => <div data-testid="link-autocomplete">LinkAutocomplete</div>,
|
||||
LinkAutocomplete: (): React.JSX.Element => (
|
||||
<div data-testid="link-autocomplete">LinkAutocomplete</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EntryEditor", (): void => {
|
||||
@@ -31,6 +33,7 @@ describe("EntryEditor", (): void => {
|
||||
const content = "# Test Content\n\nThis is a test.";
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
});
|
||||
@@ -112,6 +115,7 @@ describe("EntryEditor", (): void => {
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// Verify content in edit mode
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
|
||||
@@ -121,7 +125,10 @@ describe("EntryEditor", (): void => {
|
||||
|
||||
// Toggle back to edit
|
||||
await user.click(screen.getByText("Edit"));
|
||||
const textareaAfter = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textareaAfter = screen.getByPlaceholderText(
|
||||
/Write your content here/
|
||||
) as HTMLTextAreaElement;
|
||||
expect(textareaAfter.value).toBe(content);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WikiLinkRenderer } from "../WikiLinkRenderer";
|
||||
|
||||
// Mock Next.js Link component
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => {
|
||||
return <a href={href}>{children}</a>;
|
||||
},
|
||||
}));
|
||||
@@ -76,7 +76,7 @@ describe("WikiLinkRenderer", (): void => {
|
||||
expect(link).toBeInTheDocument();
|
||||
|
||||
// Script tags should be escaped
|
||||
const linkHtml = link?.innerHTML || "";
|
||||
const linkHtml = link?.innerHTML ?? "";
|
||||
expect(linkHtml).not.toContain("<script>");
|
||||
expect(linkHtml).toContain("<script>");
|
||||
});
|
||||
@@ -158,8 +158,6 @@ describe("WikiLinkRenderer", (): void => {
|
||||
|
||||
// Should handle empty slugs (though they're not valid)
|
||||
// The regex should match but create a link with empty slug
|
||||
const links = container.querySelectorAll('a[data-wiki-link="true"]');
|
||||
|
||||
// Depending on implementation, this might create a link or skip it
|
||||
// Either way, it shouldn't crash
|
||||
expect(container.textContent).toContain("Empty link:");
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
|
||||
export function Navigation() {
|
||||
export function Navigation(): React.JSX.Element {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className = "" }: ThemeToggleProps) {
|
||||
export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Element {
|
||||
const { resolvedTheme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -9,7 +10,11 @@ interface MermaidViewerProps {
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function MermaidViewer({ diagram, className = "", onNodeClick }: MermaidViewerProps) {
|
||||
export function MermaidViewer({
|
||||
diagram,
|
||||
className = "",
|
||||
onNodeClick,
|
||||
}: MermaidViewerProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -38,20 +43,21 @@ export function MermaidViewer({ diagram, className = "", onNodeClick }: MermaidV
|
||||
});
|
||||
|
||||
// Generate unique ID for this render
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
const id = `mermaid-${String(Date.now())}`;
|
||||
|
||||
// Render the diagram
|
||||
const { svg } = await mermaid.render(id, diagram);
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = svg;
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.innerHTML = svg;
|
||||
|
||||
// Add click handlers to nodes if callback provided
|
||||
if (onNodeClick) {
|
||||
const nodes = containerRef.current.querySelectorAll(".node");
|
||||
const nodes = container.querySelectorAll(".node");
|
||||
nodes.forEach((node) => {
|
||||
node.addEventListener("click", () => {
|
||||
const nodeId = node.id?.replace(/^flowchart-/, "").replace(/-\d+$/, "");
|
||||
const nodeId = node.id.replace(/^flowchart-/, "").replace(/-\d+$/, "");
|
||||
if (nodeId) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
@@ -68,7 +74,7 @@ export function MermaidViewer({ diagram, className = "", onNodeClick }: MermaidV
|
||||
}, [diagram, onNodeClick]);
|
||||
|
||||
useEffect(() => {
|
||||
renderDiagram();
|
||||
void renderDiagram();
|
||||
}, [renderDiagram]);
|
||||
|
||||
// Re-render on theme change
|
||||
@@ -76,7 +82,7 @@ export function MermaidViewer({ diagram, className = "", onNodeClick }: MermaidV
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
renderDiagram();
|
||||
void renderDiagram();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
@@ -23,7 +24,7 @@ export function MindmapViewer({
|
||||
maxDepth = 3,
|
||||
className = "",
|
||||
readOnly = false,
|
||||
}: MindmapViewerProps) {
|
||||
}: MindmapViewerProps): React.JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("interactive");
|
||||
const [mermaidStyle, setMermaidStyle] = useState<MermaidStyle>("flowchart");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
@@ -101,7 +102,7 @@ export function MindmapViewer({
|
||||
try {
|
||||
const results = await searchNodes(query);
|
||||
setSearchResults(results);
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
// Search failed - results will remain empty
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -93,34 +94,36 @@ function convertToReactFlowNodes(nodes: KnowledgeNode[]): Node[] {
|
||||
updated_at: node.updated_at,
|
||||
},
|
||||
style: {
|
||||
borderColor: NODE_COLORS[node.node_type] || NODE_COLORS.concept,
|
||||
borderColor: NODE_COLORS[node.node_type] ?? NODE_COLORS.concept,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function convertToReactFlowEdges(edges: KnowledgeEdge[]): Edge[] {
|
||||
return edges.map((edge) => ({
|
||||
// Use stable ID based on source, target, and relation type
|
||||
id: `${edge.source_id}-${edge.target_id}-${edge.relation_type}`,
|
||||
source: edge.source_id,
|
||||
target: edge.target_id,
|
||||
label: RELATION_LABELS[edge.relation_type] || edge.relation_type,
|
||||
type: "smoothstep",
|
||||
animated: edge.relation_type === "depends_on" || edge.relation_type === "blocks",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
data: {
|
||||
relationType: edge.relation_type,
|
||||
weight: edge.weight,
|
||||
},
|
||||
style: {
|
||||
strokeWidth: Math.max(1, edge.weight * 3),
|
||||
opacity: 0.6 + edge.weight * 0.4,
|
||||
},
|
||||
}));
|
||||
return edges.map(
|
||||
(edge): Edge => ({
|
||||
// Use stable ID based on source, target, and relation type
|
||||
id: `${edge.source_id}-${edge.target_id}-${edge.relation_type}`,
|
||||
source: edge.source_id,
|
||||
target: edge.target_id,
|
||||
label: RELATION_LABELS[edge.relation_type] ?? edge.relation_type,
|
||||
type: "smoothstep",
|
||||
animated: edge.relation_type === "depends_on" || edge.relation_type === "blocks",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
data: {
|
||||
relationType: edge.relation_type,
|
||||
weight: edge.weight,
|
||||
},
|
||||
style: {
|
||||
strokeWidth: Math.max(1, edge.weight * 3),
|
||||
opacity: 0.6 + edge.weight * 0.4,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function ReactFlowEditor({
|
||||
@@ -131,7 +134,7 @@ export function ReactFlowEditor({
|
||||
onEdgeCreate,
|
||||
className = "",
|
||||
readOnly = false,
|
||||
}: ReactFlowEditorProps) {
|
||||
}: ReactFlowEditorProps): React.JSX.Element {
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
const initialNodes = useMemo(() => convertToReactFlowNodes(graphData.nodes), [graphData.nodes]);
|
||||
@@ -142,13 +145,13 @@ export function ReactFlowEditor({
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
// Update nodes/edges when graphData changes
|
||||
useEffect(() => {
|
||||
useEffect((): void => {
|
||||
setNodes(convertToReactFlowNodes(graphData.nodes));
|
||||
setEdges(convertToReactFlowEdges(graphData.edges));
|
||||
}, [graphData, setNodes, setEdges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
(params: Connection): void => {
|
||||
if (readOnly || !params.source || !params.target) return;
|
||||
|
||||
// Create edge in backend
|
||||
@@ -177,17 +180,17 @@ export function ReactFlowEditor({
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
(_event: React.MouseEvent, node: Node): void => {
|
||||
setSelectedNode(node.id);
|
||||
if (onNodeSelect) {
|
||||
const knowledgeNode = graphData.nodes.find((n) => n.id === node.id);
|
||||
onNodeSelect(knowledgeNode || null);
|
||||
const knowledgeNode = graphData.nodes.find((n): boolean => n.id === node.id);
|
||||
onNodeSelect(knowledgeNode ?? null);
|
||||
}
|
||||
},
|
||||
[graphData.nodes, onNodeSelect]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
const onPaneClick = useCallback((): void => {
|
||||
setSelectedNode(null);
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(null);
|
||||
@@ -195,7 +198,7 @@ export function ReactFlowEditor({
|
||||
}, [onNodeSelect]);
|
||||
|
||||
const onNodeDragStop = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
(_event: React.MouseEvent, node: Node): void => {
|
||||
if (readOnly) return;
|
||||
// Could save position to metadata if needed
|
||||
if (onNodeUpdate) {
|
||||
@@ -208,7 +211,7 @@ export function ReactFlowEditor({
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
if (readOnly || !selectedNode) return;
|
||||
if (readOnly ?? !selectedNode) return;
|
||||
|
||||
if (onNodeDelete) {
|
||||
onNodeDelete(selectedNode);
|
||||
@@ -220,8 +223,8 @@ export function ReactFlowEditor({
|
||||
}, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
useEffect((): (() => void) => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
@@ -273,7 +276,7 @@ export function ReactFlowEditor({
|
||||
className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={(node) => NODE_COLORS[node.data?.nodeType as string] || "#6366f1"}
|
||||
nodeColor={(node): string => NODE_COLORS[node.data.nodeType as string] ?? "#6366f1"}
|
||||
maskColor={isDark ? "rgba(0, 0, 0, 0.8)" : "rgba(255, 255, 255, 0.8)"}
|
||||
className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
|
||||
@@ -10,13 +10,13 @@ interface ExportButtonProps {
|
||||
|
||||
type ExportFormat = "json" | "mermaid" | "png" | "svg";
|
||||
|
||||
export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
export function ExportButton({ graph, mermaid }: ExportButtonProps): React.JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
useEffect((): (() => void) => {
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const downloadFile = (content: string, filename: string, mimeType: string) => {
|
||||
const downloadFile = (content: string, filename: string, mimeType: string): void => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -40,19 +40,19 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportAsJson = () => {
|
||||
const exportAsJson = (): void => {
|
||||
if (!graph) return;
|
||||
const content = JSON.stringify(graph, null, 2);
|
||||
downloadFile(content, "knowledge-graph.json", "application/json");
|
||||
};
|
||||
|
||||
const exportAsMermaid = () => {
|
||||
const exportAsMermaid = (): void => {
|
||||
if (!mermaid) return;
|
||||
downloadFile(mermaid.diagram, "knowledge-graph.mmd", "text/plain");
|
||||
};
|
||||
|
||||
const exportAsPng = (): void => {
|
||||
const svgElement = document.querySelector(".mermaid-container svg")!;
|
||||
const svgElement = document.querySelector(".mermaid-container svg");
|
||||
if (!svgElement) {
|
||||
alert("Please switch to Diagram view first");
|
||||
return;
|
||||
@@ -69,7 +69,7 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
img.onload = (): void => {
|
||||
canvas.width = img.width * 2;
|
||||
canvas.height = img.height * 2;
|
||||
ctx.scale(2, 2);
|
||||
@@ -78,7 +78,7 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
canvas.toBlob((blob): void => {
|
||||
if (blob) {
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -92,19 +92,19 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
setIsExporting(false);
|
||||
}, "image/png");
|
||||
};
|
||||
img.onerror = () => {
|
||||
img.onerror = (): void => {
|
||||
setIsExporting(false);
|
||||
alert("Failed to export image");
|
||||
};
|
||||
img.src = url;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
setIsExporting(false);
|
||||
alert("Failed to export image");
|
||||
}
|
||||
};
|
||||
|
||||
const exportAsSvg = () => {
|
||||
const svgElement = document.querySelector(".mermaid-container svg")!;
|
||||
const exportAsSvg = (): void => {
|
||||
const svgElement = document.querySelector(".mermaid-container svg");
|
||||
if (!svgElement) {
|
||||
alert("Please switch to Diagram view first");
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
downloadFile(svgData, "knowledge-graph.svg", "image/svg+xml");
|
||||
};
|
||||
|
||||
const handleExport = async (format: ExportFormat) => {
|
||||
const handleExport = (format: ExportFormat): void => {
|
||||
setIsOpen(false);
|
||||
switch (format) {
|
||||
case "json":
|
||||
@@ -123,10 +123,9 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
case "mermaid":
|
||||
exportAsMermaid();
|
||||
break;
|
||||
case "png": {
|
||||
await exportAsPng();
|
||||
case "png":
|
||||
exportAsPng();
|
||||
break;
|
||||
}
|
||||
case "svg":
|
||||
exportAsSvg();
|
||||
break;
|
||||
@@ -136,7 +135,7 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(): void => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
disabled={isExporting}
|
||||
@@ -179,7 +178,9 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||
<button
|
||||
onClick={() => handleExport("json")}
|
||||
onClick={(): void => {
|
||||
handleExport("json");
|
||||
}}
|
||||
disabled={!graph}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -196,7 +197,9 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("mermaid")}
|
||||
onClick={(): void => {
|
||||
handleExport("mermaid");
|
||||
}}
|
||||
disabled={!mermaid}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -214,7 +217,9 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
</button>
|
||||
<hr className="my-1 border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
onClick={() => handleExport("svg")}
|
||||
onClick={(): void => {
|
||||
handleExport("svg");
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -230,7 +235,9 @@ export function ExportButton({ graph, mermaid }: ExportButtonProps) {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("png")}
|
||||
onClick={(): void => {
|
||||
handleExport("png");
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -18,7 +18,7 @@ interface NodeCreateModalProps {
|
||||
onCreate: (node: NodeCreateInput) => void;
|
||||
}
|
||||
|
||||
export function NodeCreateModal({ onClose, onCreate }: NodeCreateModalProps) {
|
||||
export function NodeCreateModal({ onClose, onCreate }: NodeCreateModalProps): React.JSX.Element {
|
||||
const [title, setTitle] = useState("");
|
||||
const [nodeType, setNodeType] = useState("concept");
|
||||
const [content, setContent] = useState("");
|
||||
@@ -26,13 +26,13 @@ export function NodeCreateModal({ onClose, onCreate }: NodeCreateModalProps) {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await onCreate({
|
||||
onCreate({
|
||||
title: title.trim(),
|
||||
node_type: nodeType,
|
||||
content: content.trim() || null,
|
||||
@@ -43,7 +43,6 @@ export function NodeCreateModal({ onClose, onCreate }: NodeCreateModalProps) {
|
||||
domain: domain.trim() || null,
|
||||
metadata: {},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -118,7 +119,7 @@ interface UseGraphDataResult {
|
||||
searchNodes: (query: string) => Promise<KnowledgeNode[]>;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||
|
||||
async function apiFetch<T>(
|
||||
endpoint: string,
|
||||
@@ -152,26 +153,31 @@ async function apiFetch<T>(
|
||||
handleSessionExpired();
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(error.detail || error.message || "API request failed");
|
||||
const error = (await response
|
||||
.json()
|
||||
.catch((): { detail?: string; message?: string } => ({ detail: response.statusText }))) as {
|
||||
detail?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new Error(error.detail ?? error.message ?? "API request failed");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Transform Knowledge Entry to Graph Node
|
||||
function entryToNode(entry: EntryDto): KnowledgeNode {
|
||||
const tags = entry.tags || [];
|
||||
const tags = entry.tags;
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
node_type: tags[0]?.slug || "concept", // Use first tag as node type, fallback to 'concept'
|
||||
content: entry.content || entry.summary || null,
|
||||
tags: tags.map((t) => t.slug),
|
||||
node_type: tags[0]?.slug ?? "concept", // Use first tag as node type, fallback to 'concept'
|
||||
content: entry.content ?? entry.summary ?? null,
|
||||
tags: tags.map((t): string => t.slug),
|
||||
domain: tags.length > 0 ? (tags[0]?.name ?? null) : null,
|
||||
metadata: {
|
||||
slug: entry.slug,
|
||||
@@ -191,8 +197,8 @@ function nodeToCreateDto(
|
||||
): CreateEntryDto {
|
||||
return {
|
||||
title: node.title,
|
||||
content: node.content || "",
|
||||
summary: node.content?.slice(0, 200) || "",
|
||||
content: node.content ?? "",
|
||||
summary: node.content?.slice(0, 200) ?? "",
|
||||
tags: node.tags.length > 0 ? node.tags : [node.node_type],
|
||||
status: "PUBLISHED",
|
||||
visibility: "WORKSPACE",
|
||||
@@ -206,7 +212,7 @@ function nodeToUpdateDto(updates: Partial<KnowledgeNode>): UpdateEntryDto {
|
||||
if (updates.title !== undefined) dto.title = updates.title;
|
||||
if (updates.content !== undefined) {
|
||||
dto.content = updates.content;
|
||||
dto.summary = updates.content?.slice(0, 200) || "";
|
||||
dto.summary = updates.content?.slice(0, 200) ?? "";
|
||||
}
|
||||
if (updates.tags !== undefined) dto.tags = updates.tags;
|
||||
|
||||
@@ -227,7 +233,7 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchGraph = useCallback(async () => {
|
||||
const fetchGraph = useCallback(async (): Promise<void> => {
|
||||
if (!accessToken) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
@@ -237,7 +243,7 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
try {
|
||||
// Fetch all entries
|
||||
const response = await apiFetch<EntriesResponse>("/entries?limit=100", accessToken);
|
||||
const entries = response.data || [];
|
||||
const entries = response.data;
|
||||
|
||||
// Transform entries to nodes
|
||||
const nodes: KnowledgeNode[] = entries.map(entryToNode);
|
||||
@@ -253,23 +259,21 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
accessToken
|
||||
);
|
||||
|
||||
if (backlinksResponse.backlinks) {
|
||||
for (const backlink of backlinksResponse.backlinks) {
|
||||
const edgeId = `${backlink.id}-${entry.id}`;
|
||||
if (!edgeSet.has(edgeId)) {
|
||||
edges.push({
|
||||
source_id: backlink.id,
|
||||
target_id: entry.id,
|
||||
relation_type: "relates_to",
|
||||
weight: 1.0,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
edgeSet.add(edgeId);
|
||||
}
|
||||
for (const backlink of backlinksResponse.backlinks) {
|
||||
const edgeId = `${backlink.id}-${entry.id}`;
|
||||
if (!edgeSet.has(edgeId)) {
|
||||
edges.push({
|
||||
source_id: backlink.id,
|
||||
target_id: entry.id,
|
||||
relation_type: "relates_to",
|
||||
weight: 1.0,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
edgeSet.add(edgeId);
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
// Silently skip backlink errors for individual entries
|
||||
// Logging suppressed to avoid console pollution in production
|
||||
}
|
||||
@@ -284,10 +288,10 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
}, [accessToken]);
|
||||
|
||||
const fetchMermaid = useCallback(
|
||||
async (style: "flowchart" | "mindmap" = "flowchart"): Promise<void> => {
|
||||
(style: "flowchart" | "mindmap" = "flowchart"): Promise<void> => {
|
||||
if (!graph) {
|
||||
setError("No graph data available");
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -301,18 +305,16 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
|
||||
// Group nodes by type
|
||||
const nodesByType: Record<string, KnowledgeNode[]> = {};
|
||||
graph.nodes.forEach((node) => {
|
||||
graph.nodes.forEach((node): void => {
|
||||
const nodeType = node.node_type;
|
||||
if (!nodesByType[nodeType]) {
|
||||
nodesByType[nodeType] = [];
|
||||
}
|
||||
nodesByType[nodeType] ??= [];
|
||||
nodesByType[nodeType].push(node);
|
||||
});
|
||||
|
||||
// Add nodes by type
|
||||
Object.entries(nodesByType).forEach(([type, nodes]) => {
|
||||
Object.entries(nodesByType).forEach(([type, nodes]): void => {
|
||||
diagram += ` ${type}\n`;
|
||||
nodes.forEach((node) => {
|
||||
nodes.forEach((node): void => {
|
||||
diagram += ` ${node.title}\n`;
|
||||
});
|
||||
});
|
||||
@@ -320,9 +322,9 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
diagram = "graph TD\n";
|
||||
|
||||
// Add all edges
|
||||
graph.edges.forEach((edge) => {
|
||||
const source = graph.nodes.find((n) => n.id === edge.source_id);
|
||||
const target = graph.nodes.find((n) => n.id === edge.target_id);
|
||||
graph.edges.forEach((edge): void => {
|
||||
const source = graph.nodes.find((n): boolean => n.id === edge.source_id);
|
||||
const target = graph.nodes.find((n): boolean => n.id === edge.target_id);
|
||||
|
||||
if (source && target) {
|
||||
const sourceLabel = source.title.replace(/["\n]/g, " ");
|
||||
@@ -332,9 +334,9 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
});
|
||||
|
||||
// Add standalone nodes (no edges)
|
||||
graph.nodes.forEach((node) => {
|
||||
graph.nodes.forEach((node): void => {
|
||||
const hasEdge = graph.edges.some(
|
||||
(e) => e.source_id === node.id || e.target_id === node.id
|
||||
(e): boolean => e.source_id === node.id || e.target_id === node.id
|
||||
);
|
||||
if (!hasEdge) {
|
||||
const label = node.title.replace(/["\n]/g, " ");
|
||||
@@ -352,23 +354,24 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
[graph]
|
||||
);
|
||||
|
||||
const fetchStatistics = useCallback(async (): Promise<void> => {
|
||||
if (!graph) return;
|
||||
const fetchStatistics = useCallback((): Promise<void> => {
|
||||
if (!graph) return Promise.resolve();
|
||||
|
||||
try {
|
||||
const nodesByType: Record<string, number> = {};
|
||||
const edgesByType: Record<string, number> = {};
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
nodesByType[node.node_type] = (nodesByType[node.node_type] || 0) + 1;
|
||||
graph.nodes.forEach((node): void => {
|
||||
nodesByType[node.node_type] = (nodesByType[node.node_type] ?? 0) + 1;
|
||||
});
|
||||
|
||||
graph.edges.forEach((edge) => {
|
||||
edgesByType[edge.relation_type] = (edgesByType[edge.relation_type] || 0) + 1;
|
||||
graph.edges.forEach((edge): void => {
|
||||
edgesByType[edge.relation_type] = (edgesByType[edge.relation_type] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setStatistics({
|
||||
@@ -377,10 +380,10 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
nodes_by_type: nodesByType,
|
||||
edges_by_type: edgesByType,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Silently fail - statistics are non-critical
|
||||
void err;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [graph]);
|
||||
|
||||
const createNode = useCallback(
|
||||
@@ -474,8 +477,8 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
// To properly create a link, we'd need to update the source entry's content to include a wiki-link
|
||||
|
||||
// Find source and target nodes
|
||||
const sourceNode = graph?.nodes.find((n) => n.id === edge.source_id);
|
||||
const targetNode = graph?.nodes.find((n) => n.id === edge.target_id);
|
||||
const sourceNode = graph?.nodes.find((n): boolean => n.id === edge.source_id);
|
||||
const targetNode = graph?.nodes.find((n): boolean => n.id === edge.target_id);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
throw new Error("Source or target node not found");
|
||||
@@ -519,16 +522,17 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
}
|
||||
try {
|
||||
// To delete an edge, we need to remove the wiki-link from the source content
|
||||
const sourceNode = graph?.nodes.find((n) => n.id === sourceId);
|
||||
const targetNode = graph?.nodes.find((n) => n.id === targetId);
|
||||
const sourceNode = graph?.nodes.find((n): boolean => n.id === sourceId);
|
||||
const targetNode = graph?.nodes.find((n): boolean => n.id === targetId);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
throw new Error("Source or target node not found");
|
||||
}
|
||||
|
||||
const targetSlug = targetNode.metadata.slug as string;
|
||||
// eslint-disable-next-line security/detect-non-literal-regexp
|
||||
const wikiLinkPattern = new RegExp(`\\[\\[${targetSlug}(?:\\|[^\\]]+)?\\]\\]`, "g");
|
||||
const updatedContent = sourceNode.content?.replace(wikiLinkPattern, "") || "";
|
||||
const updatedContent = sourceNode.content?.replace(wikiLinkPattern, "") ?? "";
|
||||
|
||||
const slug = sourceNode.metadata.slug as string;
|
||||
await apiFetch(`/entries/${slug}`, accessToken, {
|
||||
@@ -557,7 +561,7 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query, limit: "50" });
|
||||
const response = await apiFetch<EntriesResponse>(`/search?${params}`, accessToken);
|
||||
const results = response.data || [];
|
||||
const results = response.data;
|
||||
return results.map(entryToNode);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to search");
|
||||
@@ -575,9 +579,9 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
|
||||
}, [autoFetch, accessToken, fetchGraph]);
|
||||
|
||||
// Update statistics when graph changes
|
||||
useEffect(() => {
|
||||
useEffect((): void => {
|
||||
if (graph) {
|
||||
fetchStatistics();
|
||||
void fetchStatistics();
|
||||
}
|
||||
}, [graph, fetchStatistics]);
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ interface BaseNodeProps extends NodeProps {
|
||||
borderStyle?: "solid" | "dashed" | "dotted";
|
||||
}
|
||||
|
||||
export function BaseNode({ data, selected, icon, color, borderStyle = "solid" }: BaseNodeProps) {
|
||||
export function BaseNode({
|
||||
data,
|
||||
selected,
|
||||
icon,
|
||||
color,
|
||||
borderStyle = "solid",
|
||||
}: BaseNodeProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeProps } from "@xyflow/react";
|
||||
import type { BaseNodeData } from "./BaseNode";
|
||||
import { BaseNode } from "./BaseNode";
|
||||
|
||||
export function ConceptNode(props: NodeProps) {
|
||||
export function ConceptNode(props: NodeProps): React.JSX.Element {
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeProps } from "@xyflow/react";
|
||||
import type { BaseNodeData } from "./BaseNode";
|
||||
import { BaseNode } from "./BaseNode";
|
||||
|
||||
export function IdeaNode(props: NodeProps) {
|
||||
export function IdeaNode(props: NodeProps): React.JSX.Element {
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeProps } from "@xyflow/react";
|
||||
import type { BaseNodeData } from "./BaseNode";
|
||||
import { BaseNode } from "./BaseNode";
|
||||
|
||||
export function ProjectNode(props: NodeProps) {
|
||||
export function ProjectNode(props: NodeProps): React.JSX.Element {
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeProps } from "@xyflow/react";
|
||||
import type { BaseNodeData } from "./BaseNode";
|
||||
import { BaseNode } from "./BaseNode";
|
||||
|
||||
export function TaskNode(props: NodeProps) {
|
||||
export function TaskNode(props: NodeProps): React.JSX.Element {
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
|
||||
@@ -46,12 +46,12 @@ export function PersonalityForm({
|
||||
onCancel,
|
||||
}: PersonalityFormProps): React.ReactElement {
|
||||
const [formData, setFormData] = useState<PersonalityFormData>({
|
||||
name: personality?.name || "",
|
||||
description: personality?.description || "",
|
||||
tone: personality?.tone || "",
|
||||
name: personality?.name ?? "",
|
||||
description: personality?.description ?? "",
|
||||
tone: personality?.tone ?? "",
|
||||
formalityLevel: (personality?.formalityLevel ?? "NEUTRAL") as FormalityLevel,
|
||||
systemPromptTemplate: personality?.systemPromptTemplate || "",
|
||||
isDefault: personality?.isDefault || false,
|
||||
systemPromptTemplate: personality?.systemPromptTemplate ?? "",
|
||||
isDefault: personality?.isDefault ?? false,
|
||||
isActive: personality?.isActive ?? true,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Personality } from "@mosaic/shared";
|
||||
|
||||
@@ -30,7 +30,7 @@ export function PersonalitySelector({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPersonalities();
|
||||
void loadPersonalities();
|
||||
}, []);
|
||||
|
||||
async function loadPersonalities(): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
||||
@@ -20,7 +21,7 @@ const priorityLabels: Record<TaskPriority, string> = {
|
||||
[TaskPriority.LOW]: "Low priority",
|
||||
};
|
||||
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
export function TaskItem({ task }: TaskItemProps): React.JSX.Element {
|
||||
const statusIcon = statusIcons[task.status];
|
||||
const priorityLabel = priorityLabels[task.priority];
|
||||
|
||||
|
||||
@@ -85,21 +85,23 @@ describe("TaskList", (): void => {
|
||||
|
||||
describe("error states", (): void => {
|
||||
it("should handle undefined tasks gracefully", (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={undefined as any} isLoading={false} />);
|
||||
render(<TaskList tasks={undefined as unknown as Task[]} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null tasks gracefully", (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={null as any} isLoading={false} />);
|
||||
render(<TaskList tasks={null as unknown as Task[]} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with missing required fields", (): void => {
|
||||
const firstTask = mockTasks[0];
|
||||
if (!firstTask) {
|
||||
throw new Error("Mock task not found");
|
||||
}
|
||||
const malformedTasks: Task[] = [
|
||||
{
|
||||
...mockTasks[0]!,
|
||||
...firstTask,
|
||||
title: "", // Empty title
|
||||
},
|
||||
];
|
||||
@@ -110,9 +112,13 @@ describe("TaskList", (): void => {
|
||||
});
|
||||
|
||||
it("should handle tasks with invalid dates", (): void => {
|
||||
const firstTask = mockTasks[0];
|
||||
if (!firstTask) {
|
||||
throw new Error("Mock task not found");
|
||||
}
|
||||
const tasksWithBadDates: Task[] = [
|
||||
{
|
||||
...mockTasks[0]!,
|
||||
...firstTask,
|
||||
dueDate: new Date("invalid-date"),
|
||||
},
|
||||
];
|
||||
@@ -122,10 +128,14 @@ describe("TaskList", (): void => {
|
||||
});
|
||||
|
||||
it("should handle extremely large task lists", (): void => {
|
||||
const firstTask = mockTasks[0];
|
||||
if (!firstTask) {
|
||||
throw new Error("Mock task not found");
|
||||
}
|
||||
const largeTasks: Task[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...mockTasks[0]!,
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
...firstTask,
|
||||
id: `task-${String(i)}`,
|
||||
title: `Task ${String(i)}`,
|
||||
}));
|
||||
|
||||
render(<TaskList tasks={largeTasks} isLoading={false} />);
|
||||
@@ -133,8 +143,12 @@ describe("TaskList", (): void => {
|
||||
});
|
||||
|
||||
it("should handle tasks with very long titles", (): void => {
|
||||
const firstTask = mockTasks[0];
|
||||
if (!firstTask) {
|
||||
throw new Error("Mock task not found");
|
||||
}
|
||||
const longTitleTask: Task = {
|
||||
...mockTasks[0]!,
|
||||
...firstTask,
|
||||
title: "A".repeat(500),
|
||||
};
|
||||
|
||||
@@ -143,8 +157,12 @@ describe("TaskList", (): void => {
|
||||
});
|
||||
|
||||
it("should handle tasks with special characters in title", (): void => {
|
||||
const firstTask = mockTasks[0];
|
||||
if (!firstTask) {
|
||||
throw new Error("Mock task not found");
|
||||
}
|
||||
const specialCharTask: Task = {
|
||||
...mockTasks[0]!,
|
||||
...firstTask,
|
||||
title: '<script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
import { getDateGroupLabel } from "@/lib/utils/date-format";
|
||||
@@ -7,7 +8,7 @@ interface TaskListProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, isLoading }: TaskListProps) {
|
||||
export function TaskList({ tasks, isLoading }: TaskListProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
@@ -18,7 +19,7 @@ export function TaskList({ tasks, isLoading }: TaskListProps) {
|
||||
}
|
||||
|
||||
// Handle null/undefined tasks gracefully
|
||||
if (!tasks || tasks.length === 0) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No tasks scheduled</p>
|
||||
@@ -33,10 +34,8 @@ export function TaskList({ tasks, isLoading }: TaskListProps) {
|
||||
return groups;
|
||||
}
|
||||
const label = getDateGroupLabel(task.dueDate);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label].push(task);
|
||||
groups[label] ??= [];
|
||||
groups[label]?.push(task);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ interface TeamCardProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function TeamCard({ team, workspaceId }: TeamCardProps) {
|
||||
export function TeamCard({ team, workspaceId }: TeamCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Link href={`/settings/workspaces/${workspaceId}/teams/${team.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
|
||||
@@ -27,13 +27,13 @@ export function TeamMemberList({
|
||||
onAddMember,
|
||||
onRemoveMember,
|
||||
availableUsers = [],
|
||||
}: TeamMemberListProps) {
|
||||
}: TeamMemberListProps): React.JSX.Element {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState(TeamMemberRole.MEMBER);
|
||||
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
|
||||
|
||||
const handleAddMember = async () => {
|
||||
const handleAddMember = async (): Promise<void> => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
setIsAdding(true);
|
||||
@@ -49,7 +49,7 @@ export function TeamMemberList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
const handleRemoveMember = async (userId: string): Promise<void> => {
|
||||
setRemovingUserId(userId);
|
||||
try {
|
||||
await onRemoveMember(userId);
|
||||
|
||||
@@ -10,17 +10,17 @@ interface TeamSettingsProps {
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function TeamSettings({ team, onUpdate, onDelete }: TeamSettingsProps) {
|
||||
export function TeamSettings({ team, onUpdate, onDelete }: TeamSettingsProps): React.JSX.Element {
|
||||
const [name, setName] = useState(team.name);
|
||||
const [description, setDescription] = useState(team.description || "");
|
||||
const [description, setDescription] = useState(team.description ?? "");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const hasChanges = name !== team.name || description !== (team.description || "");
|
||||
const hasChanges = name !== team.name || description !== (team.description ?? "");
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!hasChanges) return;
|
||||
|
||||
setIsSaving(true);
|
||||
@@ -29,7 +29,7 @@ export function TeamSettings({ team, onUpdate, onDelete }: TeamSettingsProps) {
|
||||
if (name !== team.name) {
|
||||
updates.name = name;
|
||||
}
|
||||
if (description !== (team.description || "")) {
|
||||
if (description !== (team.description ?? "")) {
|
||||
updates.description = description;
|
||||
}
|
||||
await onUpdate(updates);
|
||||
@@ -42,13 +42,13 @@ export function TeamSettings({ team, onUpdate, onDelete }: TeamSettingsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancel = (): void => {
|
||||
setName(team.name);
|
||||
setDescription(team.description || "");
|
||||
setDescription(team.description ?? "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
|
||||
@@ -44,7 +44,7 @@ const AlertDialogContext = React.createContext<{
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}>({});
|
||||
|
||||
export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps) {
|
||||
export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps): React.JSX.Element {
|
||||
const contextValue: { open?: boolean; onOpenChange?: (open: boolean) => void } = {};
|
||||
if (open !== undefined) {
|
||||
contextValue.open = open;
|
||||
@@ -56,7 +56,10 @@ export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps)
|
||||
return <AlertDialogContext.Provider value={contextValue}>{children}</AlertDialogContext.Provider>;
|
||||
}
|
||||
|
||||
export function AlertDialogTrigger({ children, asChild }: AlertDialogTriggerProps) {
|
||||
export function AlertDialogTrigger({
|
||||
children,
|
||||
asChild,
|
||||
}: AlertDialogTriggerProps): React.JSX.Element {
|
||||
const { onOpenChange } = React.useContext(AlertDialogContext);
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
@@ -68,7 +71,9 @@ export function AlertDialogTrigger({ children, asChild }: AlertDialogTriggerProp
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function AlertDialogContent({ children }: AlertDialogContentProps) {
|
||||
export function AlertDialogContent({
|
||||
children,
|
||||
}: AlertDialogContentProps): React.JSX.Element | null {
|
||||
const { open, onOpenChange } = React.useContext(AlertDialogContext);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -83,23 +88,28 @@ export function AlertDialogContent({ children }: AlertDialogContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogHeader({ children }: AlertDialogHeaderProps) {
|
||||
export function AlertDialogHeader({ children }: AlertDialogHeaderProps): React.JSX.Element {
|
||||
return <div className="mb-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function AlertDialogFooter({ children }: AlertDialogFooterProps) {
|
||||
export function AlertDialogFooter({ children }: AlertDialogFooterProps): React.JSX.Element {
|
||||
return <div className="mt-4 flex justify-end gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
export function AlertDialogTitle({ children }: AlertDialogTitleProps) {
|
||||
export function AlertDialogTitle({ children }: AlertDialogTitleProps): React.JSX.Element {
|
||||
return <h2 className="text-lg font-semibold">{children}</h2>;
|
||||
}
|
||||
|
||||
export function AlertDialogDescription({ children }: AlertDialogDescriptionProps) {
|
||||
export function AlertDialogDescription({
|
||||
children,
|
||||
}: AlertDialogDescriptionProps): React.JSX.Element {
|
||||
return <p className="text-sm text-gray-600">{children}</p>;
|
||||
}
|
||||
|
||||
export function AlertDialogAction({ children, ...props }: AlertDialogActionProps) {
|
||||
export function AlertDialogAction({
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogActionProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
@@ -110,7 +120,10 @@ export function AlertDialogAction({ children, ...props }: AlertDialogActionProps
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogCancel({ children, ...props }: AlertDialogCancelProps) {
|
||||
export function AlertDialogCancel({
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogCancelProps): React.JSX.Element {
|
||||
const { onOpenChange } = React.useContext(AlertDialogContext);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@ const variantMap: Record<string, BaseBadgeVariant> = {
|
||||
destructive: "status-error",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "default", ...props }: BadgeProps) {
|
||||
const mappedVariant = (variantMap[variant] || variant) as BaseBadgeVariant;
|
||||
export function Badge({ variant = "default", ...props }: BadgeProps): React.JSX.Element {
|
||||
const mappedVariant = (variantMap[variant] ?? variant) as BaseBadgeVariant;
|
||||
return <BaseBadge variant={mappedVariant} {...props} />;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ const variantMap: Record<string, "primary" | "secondary" | "danger" | "ghost"> =
|
||||
link: "ghost",
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", size = "md", ...props }: ButtonProps) {
|
||||
const mappedVariant = variantMap[variant] || variant;
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
...props
|
||||
}: ButtonProps): React.JSX.Element {
|
||||
const mappedVariant = variantMap[variant] ?? variant;
|
||||
const mappedSize = size === "icon" ? "sm" : size;
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,9 @@ export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } fr
|
||||
// Additional Card sub-components for shadcn/ui compatibility
|
||||
import * as React from "react";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
|
||||
@@ -39,13 +39,18 @@ const SelectContext = React.createContext<{
|
||||
},
|
||||
});
|
||||
|
||||
export function Select({ value, onValueChange, defaultValue, disabled, children }: SelectProps) {
|
||||
export function Select({
|
||||
value,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
children,
|
||||
}: SelectProps): React.JSX.Element {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue);
|
||||
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
const currentValue = value ?? internalValue;
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
const handleValueChange = (newValue: string): void => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
@@ -72,7 +77,11 @@ export function Select({ value, onValueChange, defaultValue, disabled, children
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectTrigger({ id, className = "", children }: SelectTriggerProps) {
|
||||
export function SelectTrigger({
|
||||
id,
|
||||
className = "",
|
||||
children,
|
||||
}: SelectTriggerProps): React.JSX.Element {
|
||||
const { isOpen, setIsOpen } = React.useContext(SelectContext);
|
||||
|
||||
return (
|
||||
@@ -89,13 +98,13 @@ export function SelectTrigger({ id, className = "", children }: SelectTriggerPro
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectValue({ placeholder }: SelectValueProps) {
|
||||
export function SelectValue({ placeholder }: SelectValueProps): React.JSX.Element {
|
||||
const { value } = React.useContext(SelectContext);
|
||||
|
||||
return <span>{value || placeholder}</span>;
|
||||
return <span>{value ?? placeholder}</span>;
|
||||
}
|
||||
|
||||
export function SelectContent({ children }: SelectContentProps) {
|
||||
export function SelectContent({ children }: SelectContentProps): React.JSX.Element | null {
|
||||
const { isOpen } = React.useContext(SelectContext);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -107,7 +116,7 @@ export function SelectContent({ children }: SelectContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectItem({ value, children }: SelectItemProps) {
|
||||
export function SelectItem({ value, children }: SelectItemProps): React.JSX.Element {
|
||||
const { onValueChange } = React.useContext(SelectContext);
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Agent {
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: Agent["status"]) => {
|
||||
const getStatusIcon = (status: Agent["status"]): React.JSX.Element => {
|
||||
switch (status) {
|
||||
case "WORKING":
|
||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||
@@ -69,19 +69,19 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Agent["status"]) => {
|
||||
const getStatusText = (status: Agent["status"]): string => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
const getTimeSinceLastHeartbeat = (timestamp: string) => {
|
||||
const getTimeSinceLastHeartbeat = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const last = new Date(timestamp);
|
||||
const diffMs = now.getTime() - last.getTime();
|
||||
|
||||
if (diffMs < 60000) return "Just now";
|
||||
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
|
||||
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
|
||||
return `${Math.floor(diffMs / 86400000)}d ago`;
|
||||
if (diffMs < 3600000) return `${String(Math.floor(diffMs / 60000))}m ago`;
|
||||
if (diffMs < 86400000) return `${String(Math.floor(diffMs / 3600000))}h ago`;
|
||||
return `${String(Math.floor(diffMs / 86400000))}d ago`;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function BaseWidget({
|
||||
className,
|
||||
isLoading = false,
|
||||
error,
|
||||
}: BaseWidgetProps) {
|
||||
}: BaseWidgetProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-widget-id={id}
|
||||
@@ -50,7 +50,7 @@ export function BaseWidget({
|
||||
</div>
|
||||
|
||||
{/* Control buttons - only show if handlers provided */}
|
||||
{(onEdit || onRemove) && (
|
||||
{(onEdit ?? onRemove) && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Event {
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
@@ -66,7 +66,7 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const formatDay = (dateString: string) => {
|
||||
const formatDay = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
@@ -80,7 +80,7 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const getUpcomingEvents = () => {
|
||||
const getUpcomingEvents = (): Event[] => {
|
||||
const now = new Date();
|
||||
return events
|
||||
.filter((e) => new Date(e.startTime) > now)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { Send, Lightbulb } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
export function QuickCaptureWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
export function QuickCaptureWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [input, setInput] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [recentCaptures, setRecentCaptures] = useState<string[]>([]);
|
||||
|
||||
@@ -14,7 +14,8 @@ interface Task {
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export function TasksWidget({}: WidgetProps) {
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -50,7 +51,7 @@ export function TasksWidget({}: WidgetProps) {
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
||||
switch (priority) {
|
||||
case "HIGH":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
@@ -63,7 +64,7 @@ export function TasksWidget({}: WidgetProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
return status === "COMPLETED" ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Uses react-grid-layout for drag-and-drop functionality
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import GridLayout from "react-grid-layout";
|
||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||
@@ -30,7 +32,7 @@ export function WidgetGrid({
|
||||
onRemoveWidget,
|
||||
isEditing = false,
|
||||
className,
|
||||
}: WidgetGridProps) {
|
||||
}: WidgetGridProps): React.JSX.Element {
|
||||
// Convert WidgetPlacement to react-grid-layout Layout format
|
||||
const gridLayout: Layout = useMemo(
|
||||
() =>
|
||||
@@ -147,7 +149,7 @@ export function WidgetGrid({
|
||||
description={widgetDef.description}
|
||||
{...(isEditing &&
|
||||
onRemoveWidget && {
|
||||
onRemove: () => {
|
||||
onRemove: (): void => {
|
||||
handleRemoveWidget(item.i);
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -16,7 +16,12 @@ describe("TasksWidget", (): void => {
|
||||
});
|
||||
|
||||
it("should render loading state initially", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally empty - creates a never-resolving promise for loading state
|
||||
})
|
||||
);
|
||||
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
@@ -106,8 +111,8 @@ describe("TasksWidget", (): void => {
|
||||
|
||||
it("should limit displayed tasks to 5", async (): Promise<void> => {
|
||||
const mockTasks = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
title: `Task ${i + 1}`,
|
||||
id: String(i + 1),
|
||||
title: `Task ${String(i + 1)}`,
|
||||
status: "NOT_STARTED",
|
||||
priority: "MEDIUM",
|
||||
}));
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
// Mock react-grid-layout
|
||||
vi.mock("react-grid-layout", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<div data-testid="grid-layout">{children}</div>
|
||||
),
|
||||
Responsive: ({ children }: { children: React.ReactNode }) => (
|
||||
Responsive: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<div data-testid="responsive-grid-layout">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Following TDD - write tests first!
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { widgetRegistry } from "../WidgetRegistry";
|
||||
import { TasksWidget } from "../TasksWidget";
|
||||
|
||||
@@ -7,13 +7,13 @@ interface InviteMemberProps {
|
||||
onInvite: (email: string, role: WorkspaceMemberRole) => Promise<void>;
|
||||
}
|
||||
|
||||
export function InviteMember({ onInvite }: InviteMemberProps) {
|
||||
export function InviteMember({ onInvite }: InviteMemberProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ export function MemberList({
|
||||
workspaceOwnerId,
|
||||
onRoleChange,
|
||||
onRemove,
|
||||
}: MemberListProps) {
|
||||
}: MemberListProps): React.JSX.Element {
|
||||
const canManageMembers =
|
||||
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole) => {
|
||||
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
|
||||
try {
|
||||
await onRoleChange(userId, newRole);
|
||||
} catch (error) {
|
||||
@@ -43,7 +43,7 @@ export function MemberList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (userId: string) => {
|
||||
const handleRemove = async (userId: string): Promise<void> => {
|
||||
if (!confirm("Are you sure you want to remove this member?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ const roleLabels: Record<WorkspaceMemberRole, string> = {
|
||||
[WorkspaceMemberRole.GUEST]: "Guest",
|
||||
};
|
||||
|
||||
export function WorkspaceCard({ workspace, userRole, memberCount }: WorkspaceCardProps) {
|
||||
export function WorkspaceCard({
|
||||
workspace,
|
||||
userRole,
|
||||
memberCount,
|
||||
}: WorkspaceCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Link
|
||||
href={`/settings/workspaces/${workspace.id}`}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function WorkspaceSettings({
|
||||
userRole,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: WorkspaceSettingsProps) {
|
||||
}: WorkspaceSettingsProps): React.JSX.Element {
|
||||
const [name, setName] = useState(workspace.name);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -26,7 +26,7 @@ export function WorkspaceSettings({
|
||||
const canEdit = userRole === WorkspaceMemberRole.OWNER || userRole === WorkspaceMemberRole.ADMIN;
|
||||
const canDelete = userRole === WorkspaceMemberRole.OWNER;
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (name.trim() === "" || name === workspace.name) {
|
||||
setIsEditing(false);
|
||||
setName(workspace.name);
|
||||
@@ -45,7 +45,7 @@ export function WorkspaceSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
|
||||
@@ -21,7 +21,7 @@ const createWrapper = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
return ({ children }: { children: ReactNode }): React.JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +64,12 @@ describe("useLayouts", (): void => {
|
||||
});
|
||||
|
||||
it("should show loading state", (): void => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() =>
|
||||
new Promise((): void => {
|
||||
// Intentionally empty to keep promise pending
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLayouts(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user