Refactoring the history tab of the requirements details

This commit is contained in:
gulimabr
2025-12-03 16:41:19 -03:00
parent fef1545cdb
commit 9298c80eb2
9 changed files with 916 additions and 80 deletions

View File

@@ -12,6 +12,33 @@ import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory,
// Tab types
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
// Timeline event types for unified history view
type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed'
interface TimelineEvent {
id: string
type: TimelineEventType
date: string
// For requirement edits
historyItem?: RequirementHistory
nextHistoryItem?: RequirementHistory | null // The next version (for diff comparison)
// For link events
linkData?: {
isSource: boolean // Whether current requirement is the source
typeName: string
inverseTypeName: string | null
linkedReqId: number | null
linkedReqName: string | null
linkedTagCode: string | null
createdByUsername: string | null
}
// For group removed events
groupData?: {
groupName: string | null
groupHexColor: string | null
}
}
export default function RequirementDetailPage() {
const { user, logout, isAuditor } = useAuth()
const { currentProject } = useProject()
@@ -83,9 +110,10 @@ export default function RequirementDetailPage() {
const [editOptionsLoading, setEditOptionsLoading] = useState(false)
// Requirement history state
const [reqHistory, setReqHistory] = useState<RequirementHistory[]>([])
const [reqHistoryLoading, setReqHistoryLoading] = useState(false)
const [expandedHistoryId, setExpandedHistoryId] = useState<number | null>(null)
const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(null)
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([])
const [showFullDescDiff, setShowFullDescDiff] = useState<string | null>(null) // Event ID for expanded desc diff
// Fetch requirement data on mount
useEffect(() => {
@@ -184,8 +212,106 @@ export default function RequirementDetailPage() {
try {
setReqHistoryLoading(true)
const history = await requirementService.getRequirementHistory(parseInt(id, 10))
setReqHistory(history)
const reqId = parseInt(id, 10)
// Fetch all five data sources in parallel
const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([
requirementService.getRequirementHistory(reqId),
relationshipService.getLinkHistory(reqId),
relationshipService.getRequirementLinks(reqId),
groupService.getGroupHistory(reqId),
groupService.getCurrentGroups(reqId)
])
// Build unified timeline
const events: TimelineEvent[] = []
// Add requirement edit events (comparing each version to the next)
history.forEach((item, index) => {
const nextItem = index > 0 ? history[index - 1] : null // Previous in array = newer version
events.push({
id: `req-${item.history_id}`,
type: 'requirement_edit',
date: item.valid_to || '',
historyItem: item,
nextHistoryItem: nextItem,
})
})
// Add current links as "link_created" events
currentLinksData.forEach((link) => {
const isSource = link.direction === 'outgoing'
events.push({
id: `link-current-${link.id}`,
type: 'link_created',
date: link.created_at || '',
linkData: {
isSource,
typeName: link.type_name,
inverseTypeName: link.inverse_type_name,
linkedReqId: link.linked_requirement.id,
linkedReqName: link.linked_requirement.req_name,
linkedTagCode: link.linked_requirement.tag_code,
createdByUsername: link.created_by_username,
}
})
})
// Add link history as "link_removed" events
linkHistoryData.forEach((linkHist) => {
const isSource = linkHist.source_req_id === reqId
events.push({
id: `link-hist-${linkHist.history_id}`,
type: 'link_removed',
date: linkHist.valid_to || '',
linkData: {
isSource,
typeName: isSource
? (linkHist.relationship_type_snapshot || 'Unknown')
: (linkHist.inverse_type_snapshot || linkHist.relationship_type_snapshot || 'Unknown'),
inverseTypeName: isSource ? linkHist.inverse_type_snapshot : linkHist.relationship_type_snapshot,
linkedReqId: isSource ? linkHist.target_req_id : linkHist.source_req_id,
linkedReqName: isSource ? linkHist.target_req_name : linkHist.source_req_name,
linkedTagCode: isSource ? linkHist.target_tag_code : linkHist.source_tag_code,
createdByUsername: linkHist.created_by_username,
}
})
})
// Add current groups as "group_added" events
currentGroupsData.forEach((group) => {
events.push({
id: `group-current-${group.group_id}`,
type: 'group_added',
date: group.created_at || '',
groupData: {
groupName: group.group_name,
groupHexColor: group.group_hex_color,
}
})
})
// Add group history as "group_removed" events
groupHistoryData.forEach((groupHist) => {
events.push({
id: `group-hist-${groupHist.history_id}`,
type: 'group_removed',
date: groupHist.valid_to || '',
groupData: {
groupName: groupHist.group_name_snapshot,
groupHexColor: groupHist.group_hex_color_snapshot,
}
})
})
// Sort by date descending (newest first)
events.sort((a, b) => {
const dateA = a.date ? new Date(a.date).getTime() : 0
const dateB = b.date ? new Date(b.date).getTime() : 0
return dateB - dateA
})
setTimelineEvents(events)
} catch (err) {
console.error('Failed to fetch requirement history:', err)
} finally {
@@ -1150,6 +1276,18 @@ export default function RequirementDetailPage() {
)
case 'history':
// Helper function to truncate text
const truncateText = (text: string | null, maxLength: number = 100): string => {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
// Helper to check if a field changed between old and new
const hasChange = (oldVal: string | null | undefined, newVal: string | null | undefined): boolean => {
return (oldVal || '') !== (newVal || '')
}
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Version History</h3>
@@ -1168,93 +1306,335 @@ export default function RequirementDetailPage() {
</span>
)}
</div>
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-800">
<span className="font-medium">Note:</span> Group changes are not tracked in the version history.
</div>
{reqHistoryLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading history...</p>
</div>
) : reqHistory.length === 0 ? (
<p className="text-gray-500 text-center py-8">No version history yet. History is recorded when the requirement is edited.</p>
) : timelineEvents.length === 0 ? (
<p className="text-gray-500 text-center py-8">No history yet. History is recorded when the requirement is edited or relationships change.</p>
) : (
<div className="space-y-2">
{reqHistory.map((historyItem) => (
<div key={historyItem.history_id} className="border border-gray-200 rounded">
{/* History Row Header */}
<div
className="flex items-center justify-between px-4 py-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
onClick={() => setExpandedHistoryId(
expandedHistoryId === historyItem.history_id ? null : historyItem.history_id
)}
>
<div className="flex items-center gap-4">
<span className="font-semibold text-gray-800">
v{historyItem.version}
</span>
<span className="text-gray-700">
{historyItem.req_name || <span className="italic text-gray-400">No name</span>}
</span>
{historyItem.tag_code && (
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">
{historyItem.tag_code}
</span>
)}
{historyItem.priority_name && (
<span className="text-sm text-gray-500">
Priority: {historyItem.priority_name}
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
{historyItem.edited_by_username && (
<span>by {historyItem.edited_by_username}</span>
)}
{historyItem.valid_from && historyItem.valid_to && (
<span>
{new Date(historyItem.valid_from).toLocaleDateString()} - {new Date(historyItem.valid_to).toLocaleDateString()}
</span>
)}
<span className="text-gray-400">
{expandedHistoryId === historyItem.history_id ? '▼' : '▶'}
</span>
</div>
</div>
<div className="space-y-3">
{timelineEvents.map((event) => {
if (event.type === 'requirement_edit' && event.historyItem) {
const oldItem = event.historyItem
const newItem = event.nextHistoryItem
const isExpanded = expandedHistoryId === event.id
{/* Expanded Description */}
{expandedHistoryId === historyItem.history_id && (
<div className="px-4 py-3 border-t border-gray-200 bg-white">
<p className="text-sm font-medium text-gray-600 mb-2">Description:</p>
<div className="bg-gray-50 border border-gray-200 rounded p-3 min-h-[100px]">
<p className="text-gray-700 whitespace-pre-wrap">
{historyItem.req_desc || <span className="italic text-gray-400">No description</span>}
</p>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-600">Valid From:</span>{' '}
<span className="text-gray-700">
{historyItem.valid_from
? new Date(historyItem.valid_from).toLocaleString()
: 'N/A'}
// Determine what changed (compare old version to newer version or current)
const newName = newItem?.req_name ?? requirement.req_name
const newDesc = newItem?.req_desc ?? requirement.req_desc
const newTag = newItem?.tag_code ?? requirement.tag.tag_code
const newPriority = newItem?.priority_name ?? requirement.priority?.priority_name
const nameChanged = hasChange(oldItem.req_name, newName)
const descChanged = hasChange(oldItem.req_desc, newDesc)
const tagChanged = hasChange(oldItem.tag_code, newTag)
const priorityChanged = hasChange(oldItem.priority_name, newPriority)
const hasAnyChange = nameChanged || descChanged || tagChanged || priorityChanged
return (
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 bg-amber-50 border-l-4 border-amber-400 cursor-pointer hover:bg-amber-100"
onClick={() => setExpandedHistoryId(isExpanded ? null : event.id)}
>
<div className="flex items-center gap-3">
<span className="text-amber-600 text-lg">📝</span>
<span className="font-semibold text-gray-800">
v{oldItem.version} v{newItem ? newItem.version : requirement.version}
</span>
<span className="text-sm text-gray-600">Requirement edited</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
{oldItem.edited_by_username && (
<span>by {oldItem.edited_by_username}</span>
)}
<span>
{oldItem.valid_to ? new Date(oldItem.valid_to).toLocaleString() : ''}
</span>
<span className="text-gray-400">
{isExpanded ? '▼' : '▶'}
</span>
</div>
<div>
<span className="font-medium text-gray-600">Valid To:</span>{' '}
<span className="text-gray-700">
{historyItem.valid_to
? new Date(historyItem.valid_to).toLocaleString()
: 'N/A'}
</div>
{/* Expanded diff view */}
{isExpanded && (
<div className="px-4 py-3 border-t border-gray-200 bg-white space-y-4">
{!hasAnyChange && (
<p className="text-gray-500 italic text-sm">No visible changes detected (may be a group-only change).</p>
)}
{/* Name diff */}
{nameChanged && (
<div>
<p className="text-sm font-medium text-gray-600 mb-2">Name:</p>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-red-50 border border-red-200 rounded">
<span className="text-xs text-red-600 font-medium">Old</span>
<p className="text-gray-700 mt-1">{oldItem.req_name || <span className="italic text-gray-400">Empty</span>}</p>
</div>
<div className="p-2 bg-green-50 border border-green-200 rounded">
<span className="text-xs text-green-600 font-medium">New</span>
<p className="text-gray-700 mt-1">{newName || <span className="italic text-gray-400">Empty</span>}</p>
</div>
</div>
</div>
)}
{/* Tag diff */}
{tagChanged && (
<div>
<p className="text-sm font-medium text-gray-600 mb-2">Tag:</p>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-red-50 border border-red-200 rounded">
<span className="text-xs text-red-600 font-medium">Old</span>
<p className="text-gray-700 mt-1">
{oldItem.tag_code ? (
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">{oldItem.tag_code}</span>
) : (
<span className="italic text-gray-400">None</span>
)}
</p>
</div>
<div className="p-2 bg-green-50 border border-green-200 rounded">
<span className="text-xs text-green-600 font-medium">New</span>
<p className="text-gray-700 mt-1">
{newTag ? (
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">{newTag}</span>
) : (
<span className="italic text-gray-400">None</span>
)}
</p>
</div>
</div>
</div>
)}
{/* Priority diff */}
{priorityChanged && (
<div>
<p className="text-sm font-medium text-gray-600 mb-2">Priority:</p>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-red-50 border border-red-200 rounded">
<span className="text-xs text-red-600 font-medium">Old</span>
<p className="text-gray-700 mt-1">{oldItem.priority_name || <span className="italic text-gray-400">None</span>}</p>
</div>
<div className="p-2 bg-green-50 border border-green-200 rounded">
<span className="text-xs text-green-600 font-medium">New</span>
<p className="text-gray-700 mt-1">{newPriority || <span className="italic text-gray-400">None</span>}</p>
</div>
</div>
</div>
)}
{/* Description diff */}
{descChanged && (
<div>
<p className="text-sm font-medium text-gray-600 mb-2">Description:</p>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-red-50 border border-red-200 rounded">
<span className="text-xs text-red-600 font-medium">Old</span>
<p className="text-gray-700 mt-1 whitespace-pre-wrap text-sm">
{showFullDescDiff === event.id
? (oldItem.req_desc || <span className="italic text-gray-400">Empty</span>)
: (truncateText(oldItem.req_desc) || <span className="italic text-gray-400">Empty</span>)
}
</p>
</div>
<div className="p-2 bg-green-50 border border-green-200 rounded">
<span className="text-xs text-green-600 font-medium">New</span>
<p className="text-gray-700 mt-1 whitespace-pre-wrap text-sm">
{showFullDescDiff === event.id
? (newDesc || <span className="italic text-gray-400">Empty</span>)
: (truncateText(newDesc) || <span className="italic text-gray-400">Empty</span>)
}
</p>
</div>
</div>
{((oldItem.req_desc && oldItem.req_desc.length > 100) || (newDesc && newDesc.length > 100)) && (
<button
onClick={(e) => {
e.stopPropagation()
setShowFullDescDiff(showFullDescDiff === event.id ? null : event.id)
}}
className="mt-2 text-sm text-teal-600 hover:underline"
>
{showFullDescDiff === event.id ? 'Show less' : 'Show full diff'}
</button>
)}
</div>
)}
</div>
)}
</div>
)
}
if (event.type === 'link_created' && event.linkData) {
const link = event.linkData
return (
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
<div className="flex items-center gap-3">
<span className="text-green-600 text-lg">🔗</span>
<span className="font-medium text-gray-800">Link Created</span>
<span className="text-sm text-gray-600">
{link.isSource ? (
<>
<span className="font-medium">{link.typeName}</span>
{' → '}
{link.linkedTagCode && (
<span className="px-1.5 py-0.5 bg-teal-100 text-teal-800 text-xs rounded mr-1">
{link.linkedTagCode}
</span>
)}
{link.linkedReqName || 'Unknown Requirement'}
</>
) : (
<>
{link.linkedTagCode && (
<span className="px-1.5 py-0.5 bg-teal-100 text-teal-800 text-xs rounded mr-1">
{link.linkedTagCode}
</span>
)}
{link.linkedReqName || 'Unknown Requirement'}
{' → '}
<span className="font-medium">{link.typeName}</span>
</>
)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
{link.createdByUsername && (
<span>by {link.createdByUsername}</span>
)}
<span>
{event.date ? new Date(event.date).toLocaleString() : ''}
</span>
</div>
</div>
</div>
)}
</div>
))}
)
}
if (event.type === 'link_removed' && event.linkData) {
const link = event.linkData
return (
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-red-50 border-l-4 border-red-400">
<div className="flex items-center gap-3">
<span className="text-red-600 text-lg">🔗</span>
<span className="font-medium text-gray-800">Link Removed</span>
<span className="text-sm text-gray-600">
{link.isSource ? (
<>
<span className="font-medium">{link.typeName}</span>
{' → '}
{link.linkedTagCode && (
<span className="px-1.5 py-0.5 bg-gray-200 text-gray-600 text-xs rounded mr-1 line-through">
{link.linkedTagCode}
</span>
)}
<span className="line-through">{link.linkedReqName || 'Deleted Requirement'}</span>
</>
) : (
<>
{link.linkedTagCode && (
<span className="px-1.5 py-0.5 bg-gray-200 text-gray-600 text-xs rounded mr-1 line-through">
{link.linkedTagCode}
</span>
)}
<span className="line-through">{link.linkedReqName || 'Deleted Requirement'}</span>
{' → '}
<span className="font-medium">{link.typeName}</span>
</>
)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
{event.date ? new Date(event.date).toLocaleString() : ''}
</span>
</div>
</div>
</div>
)
}
if (event.type === 'group_added' && event.groupData) {
const group = event.groupData
return (
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
<div className="flex items-center gap-3">
<span className="text-green-600 text-lg">🏷</span>
<span className="font-medium text-gray-800">Group Added</span>
<span className="text-sm text-gray-600">
{group.groupName ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border"
style={{
backgroundColor: group.groupHexColor ? `${group.groupHexColor}30` : '#dcfce7',
borderColor: group.groupHexColor || '#22c55e'
}}
>
{group.groupName}
</span>
) : (
<span className="italic text-gray-400">Unknown Group</span>
)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
{event.date ? new Date(event.date).toLocaleString() : ''}
</span>
</div>
</div>
</div>
)
}
if (event.type === 'group_removed' && event.groupData) {
const group = event.groupData
return (
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-orange-50 border-l-4 border-orange-400">
<div className="flex items-center gap-3">
<span className="text-orange-600 text-lg">🏷</span>
<span className="font-medium text-gray-800">Group Removed</span>
<span className="text-sm text-gray-600">
{group.groupName ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border line-through"
style={{
backgroundColor: group.groupHexColor ? `${group.groupHexColor}30` : '#e5e7eb',
borderColor: group.groupHexColor || '#9ca3af'
}}
>
{group.groupName}
</span>
) : (
<span className="italic text-gray-400 line-through">Unknown Group</span>
)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
{event.date ? new Date(event.date).toLocaleString() : ''}
</span>
</div>
</div>
</div>
)
}
return null
})}
</div>
)}
</div>

View File

@@ -1,3 +1,5 @@
import type { RequirementGroupHistory, CurrentRequirementGroup } from '@/types'
const API_BASE_URL = '/api'
export interface Group {
@@ -31,6 +33,58 @@ class GroupService {
throw error
}
}
/**
* Get group association history for a requirement.
* Returns records of when groups were removed from the requirement.
*/
async getGroupHistory(requirementId: number): Promise<RequirementGroupHistory[]> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/history`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const history: RequirementGroupHistory[] = await response.json()
return history
} catch (error) {
console.error('Failed to fetch group history:', error)
throw error
}
}
/**
* Get current groups for a requirement with their association timestamps.
* Returns records of when groups were added to the requirement.
*/
async getCurrentGroups(requirementId: number): Promise<CurrentRequirementGroup[]> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/current`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const groups: CurrentRequirementGroup[] = await response.json()
return groups
} catch (error) {
console.error('Failed to fetch current groups:', error)
throw error
}
}
}
export const groupService = new GroupService()

View File

@@ -50,6 +50,22 @@ export interface RelationshipTypeUpdateRequest {
inverse_type_name?: string | null
}
export interface RequirementLinkHistory {
history_id: number
original_link_id: number | null
source_req_id: number | null
target_req_id: number | null
source_tag_code: string | null
target_tag_code: string | null
source_req_name: string | null
target_req_name: string | null
relationship_type_snapshot: string | null
inverse_type_snapshot: string | null
created_by_username: string | null
valid_from: string | null
valid_to: string | null
}
class RelationshipService {
/**
* Get all relationship types for a project.
@@ -234,6 +250,25 @@ class RelationshipService {
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
}
}
/**
* Get the link history for a requirement (deleted/changed links).
*/
async getLinkHistory(requirementId: number): Promise<RequirementLinkHistory[]> {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links/history`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
}
}
export const relationshipService = new RelationshipService()

View File

@@ -73,6 +73,43 @@ export interface RequirementHistory {
valid_to: string | null
}
// Requirement Link History types
export interface RequirementLinkHistory {
history_id: number
original_link_id: number | null
source_req_id: number | null
target_req_id: number | null
source_tag_code: string | null
target_tag_code: string | null
source_req_name: string | null
target_req_name: string | null
relationship_type_snapshot: string | null
inverse_type_snapshot: string | null
created_by_username: string | null
valid_from: string | null // When link was created
valid_to: string | null // When link was deleted
}
// Requirement Group History types
export interface RequirementGroupHistory {
history_id: number
requirement_id: number
group_id: number
group_name_snapshot: string | null // Group name at time of removal
group_hex_color_snapshot: string | null // Hex color at time of removal
valid_from: string | null // When group was associated
valid_to: string | null // When group was removed
}
// Current Requirement Group with timestamp
export interface CurrentRequirementGroup {
requirement_id: number
group_id: number
group_name: string
group_hex_color: string
created_at: string | null // When group was added
}
// Deleted Requirement types
export interface DeletedRequirement {
history_id: number