- Added EntryVersion model with author relation - Implemented automatic versioning on entry create/update - Added API endpoints for version history: - GET /api/knowledge/entries/:slug/versions - list versions - GET /api/knowledge/entries/:slug/versions/:version - get specific - POST /api/knowledge/entries/:slug/restore/:version - restore version - Created VersionHistory.tsx component with timeline view - Added History tab to entry detail page - Supports version viewing and restoring - Includes comprehensive tests for version operations - All TypeScript types are explicit and type-safe
This commit is contained in:
@@ -7,6 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||
import { EntryViewer } from "@/components/knowledge/EntryViewer";
|
||||
import { EntryEditor } from "@/components/knowledge/EntryEditor";
|
||||
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
|
||||
import { VersionHistory } from "@/components/knowledge/VersionHistory";
|
||||
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export default function EntryPage() {
|
||||
const [editTags, setEditTags] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"content" | "history">("content");
|
||||
|
||||
// Load entry data
|
||||
useEffect(() => {
|
||||
@@ -179,6 +181,25 @@ export default function EntryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionRestore = (): void => {
|
||||
// Reload entry after version restore
|
||||
async function reload(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchEntry(slug);
|
||||
setEntry(data);
|
||||
setEditTitle(data.title);
|
||||
setEditContent(data.content);
|
||||
setEditStatus(data.status);
|
||||
setEditVisibility(data.visibility);
|
||||
setEditTags(data.tags.map((tag) => tag.id));
|
||||
setActiveTab("content");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to reload entry");
|
||||
}
|
||||
}
|
||||
void reload();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
@@ -268,12 +289,44 @@ export default function EntryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
{!isEditing && (
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("content")}
|
||||
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === "content"
|
||||
? "border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("history")}
|
||||
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === "history"
|
||||
? "border-blue-600 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-6">
|
||||
{isEditing ? (
|
||||
<EntryEditor content={editContent} onChange={setEditContent} />
|
||||
) : (
|
||||
) : activeTab === "content" ? (
|
||||
<EntryViewer entry={entry} />
|
||||
) : (
|
||||
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal file
223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import type { KnowledgeEntryVersionWithAuthor } from "@mosaic/shared";
|
||||
import { fetchVersions, fetchVersion, restoreVersion } from "@/lib/api/knowledge";
|
||||
|
||||
interface VersionHistoryProps {
|
||||
slug: string;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version History Component
|
||||
* Displays version history timeline for a knowledge entry
|
||||
* Allows viewing and restoring previous versions
|
||||
*/
|
||||
export function VersionHistory({ slug, onRestore }: VersionHistoryProps): JSX.Element {
|
||||
const [versions, setVersions] = useState<KnowledgeEntryVersionWithAuthor[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState<KnowledgeEntryVersionWithAuthor | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Load versions
|
||||
useEffect(() => {
|
||||
async function loadVersions(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await fetchVersions(slug, page, 20);
|
||||
setVersions(response.data);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load version history");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
void loadVersions();
|
||||
}, [slug, page]);
|
||||
|
||||
// Load specific version for preview
|
||||
const handleViewVersion = async (version: number): Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
const versionData = await fetchVersion(slug, version);
|
||||
setSelectedVersion(versionData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load version");
|
||||
}
|
||||
};
|
||||
|
||||
// Restore a version
|
||||
const handleRestore = async (version: number): Promise<void> => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to restore version ${version}? This will create a new version with the content from version ${version}.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRestoring(true);
|
||||
setError(null);
|
||||
await restoreVersion(slug, version, {
|
||||
changeNote: `Restored from version ${version}`,
|
||||
});
|
||||
setSelectedVersion(null);
|
||||
setPage(1); // Reload first page to see new version
|
||||
if (onRestore) {
|
||||
onRestore();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to restore version");
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading && versions.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No version history available</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Version Timeline */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{versions.map((version, index) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
selectedVersion?.id === version.id
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Version {version.version}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
{version.author.name} ({version.author.email})
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{formatDate(version.createdAt)}
|
||||
</p>
|
||||
{version.changeNote && (
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300 italic">
|
||||
"{version.changeNote}"
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{version.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewVersion(version.version)}
|
||||
className="px-3 py-1 text-sm font-medium text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
{selectedVersion?.id === version.id ? "Hide" : "View"}
|
||||
</button>
|
||||
{index !== 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(version.version)}
|
||||
disabled={isRestoring}
|
||||
className="px-3 py-1 text-sm font-medium text-green-700 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 disabled:opacity-50"
|
||||
>
|
||||
{isRestoring ? "Restoring..." : "Restore"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Preview */}
|
||||
{selectedVersion?.id === version.id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Content Preview
|
||||
</h4>
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{selectedVersion.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
export { EntryViewer } from "./EntryViewer";
|
||||
export { EntryEditor } from "./EntryEditor";
|
||||
export { EntryMetadata } from "./EntryMetadata";
|
||||
export { VersionHistory } from "./VersionHistory";
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* Handles knowledge entry-related API requests
|
||||
*/
|
||||
|
||||
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
||||
import type {
|
||||
KnowledgeEntryWithTags,
|
||||
KnowledgeTag,
|
||||
KnowledgeEntryVersionWithAuthor,
|
||||
PaginatedResponse,
|
||||
} from "@mosaic/shared";
|
||||
import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||
|
||||
@@ -44,6 +49,11 @@ export interface UpdateEntryData {
|
||||
status?: EntryStatus;
|
||||
visibility?: Visibility;
|
||||
tags?: string[];
|
||||
changeNote?: string;
|
||||
}
|
||||
|
||||
export interface RestoreVersionData {
|
||||
changeNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,6 +138,49 @@ export async function fetchTags(): Promise<KnowledgeTag[]> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch version history for an entry
|
||||
*/
|
||||
export async function fetchVersions(
|
||||
slug: string,
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResponse<KnowledgeEntryVersionWithAuthor>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", page.toString());
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
return apiGet<PaginatedResponse<KnowledgeEntryVersionWithAuthor>>(
|
||||
`/api/knowledge/entries/${slug}/versions?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific version of an entry
|
||||
*/
|
||||
export async function fetchVersion(
|
||||
slug: string,
|
||||
version: number
|
||||
): Promise<KnowledgeEntryVersionWithAuthor> {
|
||||
return apiGet<KnowledgeEntryVersionWithAuthor>(
|
||||
`/api/knowledge/entries/${slug}/versions/${version}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previous version of an entry
|
||||
*/
|
||||
export async function restoreVersion(
|
||||
slug: string,
|
||||
version: number,
|
||||
data?: RestoreVersionData
|
||||
): Promise<KnowledgeEntryWithTags> {
|
||||
return apiPost<KnowledgeEntryWithTags>(
|
||||
`/api/knowledge/entries/${slug}/restore/${version}`,
|
||||
data || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock entries for development (until backend endpoints are ready)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user