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

@@ -286,6 +286,11 @@ class RequirementGroup(Base):
ForeignKey("groups.id", ondelete="CASCADE"),
primary_key=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
class Validation(Base):
@@ -328,6 +333,7 @@ class RequirementHistory(Base):
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
original_req_id: Mapped[int] = mapped_column(Integer, nullable=False)
project_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
status_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Requirement lifecycle status
req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
@@ -512,3 +518,29 @@ class RequirementCommentReply(Base):
__table_args__ = (
Index("idx_rcr_parent", "parent_comment_id"),
)
class RequirementLinkHistory(Base):
"""
Historical records of requirement link changes (deletions).
Note: This is populated by a database trigger on UPDATE/DELETE.
Stores snapshots of relationship type names to preserve data if types are renamed/deleted.
"""
__tablename__ = "requirement_links_history"
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
original_link_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
source_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
target_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
relationship_type_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
relationship_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved type name
inverse_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved inverse name
created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was created
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was deleted
# Indexes
__table_args__ = (
Index("idx_link_hist_source", "source_req_id"),
Index("idx_link_hist_target", "target_req_id"),
)

View File

@@ -13,6 +13,7 @@ from src.models import (
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult,
RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse,
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
RequirementStatusResponse, DeletedRequirementResponse
@@ -1600,6 +1601,117 @@ async def delete_requirement_link(
await db.commit()
@app.get("/api/requirements/{requirement_id}/links/history", response_model=List[RequirementLinkHistoryResponse])
async def get_requirement_link_history(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get the link history for a requirement (deleted/changed links).
Returns all historical links where this requirement was source or target.
Args:
requirement_id: The requirement to get link history for
Returns:
List of historical links with relationship type snapshots and requirement info.
"""
user = await _get_current_user_db(request, db)
# Check if requirement exists
req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(requirement_id)
if not requirement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Requirement with id {requirement_id} not found"
)
# Verify user is a member of the requirement's project
await _verify_project_membership(requirement.project_id, user.id, db)
# Get link history
link_repo = RequirementLinkRepository(db)
history = await link_repo.get_history_by_requirement_id(requirement_id)
return [RequirementLinkHistoryResponse(**h) for h in history]
@app.get("/api/requirements/{requirement_id}/groups/history", response_model=List[RequirementGroupHistoryResponse])
async def get_requirement_group_history(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get the group association history for a requirement (removed groups).
Returns all historical group associations for this requirement.
Args:
requirement_id: The requirement to get group history for
Returns:
List of historical group associations with group name/color snapshots.
"""
user = await _get_current_user_db(request, db)
# Check if requirement exists
req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(requirement_id)
if not requirement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Requirement with id {requirement_id} not found"
)
# Verify user is a member of the requirement's project
await _verify_project_membership(requirement.project_id, user.id, db)
# Get group history
group_repo = GroupRepository(db)
history = await group_repo.get_group_history_by_requirement_id(requirement_id)
return [RequirementGroupHistoryResponse(**h) for h in history]
@app.get("/api/requirements/{requirement_id}/groups/current", response_model=List[CurrentRequirementGroupResponse])
async def get_requirement_current_groups(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get the current groups for a requirement with their association timestamps.
Returns all current group associations with when they were added.
Args:
requirement_id: The requirement to get current groups for
Returns:
List of current group associations with group name/color and created_at timestamp.
"""
user = await _get_current_user_db(request, db)
# Check if requirement exists
req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(requirement_id)
if not requirement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Requirement with id {requirement_id} not found"
)
# Verify user is a member of the requirement's project
await _verify_project_membership(requirement.project_id, user.id, db)
# Get current groups
group_repo = GroupRepository(db)
groups = await group_repo.get_current_groups_by_requirement_id(requirement_id)
return [CurrentRequirementGroupResponse(**g) for g in groups]
# ===========================================
# Comment Endpoints
# ===========================================

View File

@@ -351,6 +351,26 @@ class RequirementLinkCreateRequest(BaseModel):
target_requirement_id: int
class RequirementLinkHistoryResponse(BaseModel):
"""Response schema for requirement link history (deleted/changed links)."""
history_id: int
original_link_id: Optional[int] = None
source_req_id: Optional[int] = None
target_req_id: Optional[int] = None
source_tag_code: Optional[str] = None # Resolved from source requirement
target_tag_code: Optional[str] = None # Resolved from target requirement
source_req_name: Optional[str] = None
target_req_name: Optional[str] = None
relationship_type_snapshot: Optional[str] = None # Preserved type name
inverse_type_snapshot: Optional[str] = None # Preserved inverse name
created_by_username: Optional[str] = None
valid_from: Optional[datetime] = None # When link was created
valid_to: Optional[datetime] = None # When link was deleted
class Config:
from_attributes = True
# Requirement Search schemas
class RequirementSearchResult(BaseModel):
"""Response schema for requirement search results (for autocomplete)."""
@@ -402,3 +422,29 @@ class CommentCreateRequest(BaseModel):
class ReplyCreateRequest(BaseModel):
"""Request schema for creating a reply."""
reply_text: str
class RequirementGroupHistoryResponse(BaseModel):
"""Response schema for requirement group association history (removed groups)."""
history_id: int
requirement_id: int
group_id: int
group_name_snapshot: Optional[str] = None # Preserved group name at time of removal
group_hex_color_snapshot: Optional[str] = None # Preserved hex color at time of removal
valid_from: Optional[datetime] = None # When the group was associated
valid_to: Optional[datetime] = None # When the group was removed
class Config:
from_attributes = True
class CurrentRequirementGroupResponse(BaseModel):
"""Response schema for current group associations with timestamp."""
requirement_id: int
group_id: int
group_name: str
group_hex_color: str
created_at: Optional[datetime] = None # When the group was added
class Config:
from_attributes = True

View File

@@ -1,8 +1,8 @@
"""
Repository layer for Group database operations.
"""
from typing import List, Optional
from sqlalchemy import select
from typing import List, Optional, Dict, Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Group
import logging
@@ -74,3 +74,81 @@ class GroupRepository:
await self.session.flush()
await self.session.refresh(group)
return group
async def get_group_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
"""
Get the group association history for a requirement.
Returns records of when groups were added or removed from this requirement.
Args:
requirement_id: The requirement ID to get group history for
Returns:
List of historical group associations with snapshots of group data
"""
query = text("""
SELECT
rgh.history_id,
rgh.requirement_id,
rgh.group_id,
rgh.group_name_snapshot,
rgh.group_hex_color_snapshot,
rgh.valid_from,
rgh.valid_to
FROM requirements_groups_history rgh
WHERE rgh.requirement_id = :requirement_id
ORDER BY rgh.valid_to DESC
""")
result = await self.session.execute(query, {"requirement_id": requirement_id})
rows = result.fetchall()
return [
{
"history_id": row.history_id,
"requirement_id": row.requirement_id,
"group_id": row.group_id,
"group_name_snapshot": row.group_name_snapshot,
"group_hex_color_snapshot": row.group_hex_color_snapshot,
"valid_from": row.valid_from,
"valid_to": row.valid_to,
}
for row in rows
]
async def get_current_groups_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
"""
Get the current groups associated with a requirement, including when they were added.
Args:
requirement_id: The requirement ID to get groups for
Returns:
List of current group associations with group data and created_at timestamp
"""
query = text("""
SELECT
rg.requirement_id,
rg.group_id,
g.group_name,
g.hex_color,
rg.created_at
FROM requirements_groups rg
JOIN groups g ON rg.group_id = g.id
WHERE rg.requirement_id = :requirement_id
ORDER BY rg.created_at DESC
""")
result = await self.session.execute(query, {"requirement_id": requirement_id})
rows = result.fetchall()
return [
{
"requirement_id": row.requirement_id,
"group_id": row.group_id,
"group_name": row.group_name,
"group_hex_color": row.hex_color,
"created_at": row.created_at,
}
for row in rows
]

View File

@@ -2,7 +2,7 @@
Repository for RequirementLink database operations.
"""
from typing import List, Optional, Dict, Any
from sqlalchemy import select, or_
from sqlalchemy import select, or_, text
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import RequirementLink, Requirement, RelationshipType, User
@@ -197,3 +197,65 @@ class RequirementLinkRepository:
)
)
return result.scalar_one_or_none() is not None
async def get_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
"""
Get the link history for a requirement (both as source and target).
This data is populated by a database trigger on UPDATE/DELETE.
Args:
requirement_id: The requirement ID to get link history for
Returns:
List of historical links with resolved requirement names and creator info
"""
# Use raw SQL with LEFT JOINs to resolve foreign keys to display names
# Note: source/target requirements may have been deleted, so we use LEFT JOINs
query = text("""
SELECT
lh.history_id,
lh.original_link_id,
lh.source_req_id,
lh.target_req_id,
lh.relationship_type_snapshot,
lh.inverse_type_snapshot,
lh.valid_from,
lh.valid_to,
u.full_name as created_by_full_name,
u.username as created_by_username,
sr.req_name as source_req_name,
st.tag_code as source_tag_code,
tr.req_name as target_req_name,
tt.tag_code as target_tag_code
FROM requirement_links_history lh
LEFT JOIN users u ON lh.created_by = u.id
LEFT JOIN requirements sr ON lh.source_req_id = sr.id
LEFT JOIN tags st ON sr.tag_id = st.id
LEFT JOIN requirements tr ON lh.target_req_id = tr.id
LEFT JOIN tags tt ON tr.tag_id = tt.id
WHERE lh.source_req_id = :requirement_id
OR lh.target_req_id = :requirement_id
ORDER BY lh.valid_to DESC
""")
result = await self.db.execute(query, {"requirement_id": requirement_id})
rows = result.fetchall()
return [
{
"history_id": row.history_id,
"original_link_id": row.original_link_id,
"source_req_id": row.source_req_id,
"target_req_id": row.target_req_id,
"source_tag_code": row.source_tag_code,
"target_tag_code": row.target_tag_code,
"source_req_name": row.source_req_name,
"target_req_name": row.target_req_name,
"relationship_type_snapshot": row.relationship_type_snapshot,
"inverse_type_snapshot": row.inverse_type_snapshot,
"created_by_username": row.created_by_full_name or row.created_by_username,
"valid_from": row.valid_from,
"valid_to": row.valid_to,
}
for row in rows
]

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