fix issues indexing with jira connector

This commit is contained in:
CREDO23 2025-07-25 00:17:06 +02:00
parent 4984aab3f1
commit 655352fc09
4 changed files with 1262 additions and 1056 deletions

View file

@ -0,0 +1 @@
{"2d0ec64d93969318101ee479b664221b32241665":{"files":{"surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx":["k+EnkTKWK6V5ATYvtszJhNn43kI=",true]},"modified":1753395412751}}

View file

@ -252,7 +252,7 @@ class JiraConnector:
fields.append("comment") fields.append("comment")
params = { params = {
"jql": jql, "jql": "",
"fields": ",".join(fields), "fields": ",".join(fields),
"maxResults": 100, "maxResults": 100,
"startAt": 0, "startAt": 0,

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@ -1376,9 +1375,9 @@ async def index_linear_issues(
# Process each issue # Process each issue
for issue in issues: for issue in issues:
try: try:
issue_id = issue.get("id") issue_id = issue.get("key")
issue_identifier = issue.get("identifier", "") issue_identifier = issue.get("id", "")
issue_title = issue.get("title", "") issue_title = issue.get("key", "")
if not issue_id or not issue_title: if not issue_id or not issue_title:
logger.warning( logger.warning(
@ -2026,11 +2025,13 @@ async def index_jira_issues(
try: try:
# Get the connector from the database # Get the connector from the database
result = await session.execute( result = await session.execute(
select(SearchSourceConnector).where( select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id SearchSourceConnector.id == connector_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
) )
) )
connector = result.scalar_one_or_none() connector = result.scalars().first()
if not connector: if not connector:
await task_logger.log_task_failure( await task_logger.log_task_failure(
@ -2071,15 +2072,43 @@ async def index_jira_issues(
# Fall back to calculating dates based on last_indexed_at # Fall back to calculating dates based on last_indexed_at
calculated_end_date = datetime.now() calculated_end_date = datetime.now()
# Use last_indexed_at as start date if available, otherwise use 365 days ago
if connector.last_indexed_at: if connector.last_indexed_at:
calculated_start_date = connector.last_indexed_at # Convert dates to be comparable (both timezone-naive)
else: last_indexed_naive = (
# If never indexed, go back 30 days connector.last_indexed_at.replace(tzinfo=None)
calculated_start_date = calculated_end_date - timedelta(days=30) if connector.last_indexed_at.tzinfo
else connector.last_indexed_at
)
start_date_str = calculated_start_date.strftime("%Y-%m-%d") # Check if last_indexed_at is in the future or after end_date
end_date_str = calculated_end_date.strftime("%Y-%m-%d") if last_indexed_naive > calculated_end_date:
logger.warning(
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 365 days ago instead."
)
calculated_start_date = calculated_end_date - timedelta(days=365)
else: else:
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
else:
calculated_start_date = calculated_end_date - timedelta(
days=365
) # Use 365 days as default
logger.info(
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (365 days ago) as start date"
)
# Use calculated dates if not provided
start_date_str = (
start_date if start_date else calculated_start_date.strftime("%Y-%m-%d")
)
end_date_str = (
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
)
else:
# Use provided dates
start_date_str = start_date start_date_str = start_date
end_date_str = end_date end_date_str = end_date
@ -2099,8 +2128,6 @@ async def index_jira_issues(
start_date=start_date_str, end_date=end_date_str, include_comments=True start_date=start_date_str, end_date=end_date_str, include_comments=True
) )
print(json.dumps(issues, indent=2))
if error: if error:
logger.error(f"Failed to get Jira issues: {error}") logger.error(f"Failed to get Jira issues: {error}")
@ -2133,146 +2160,174 @@ async def index_jira_issues(
logger.info(f"Retrieved {len(issues)} issues from Jira API") logger.info(f"Retrieved {len(issues)} issues from Jira API")
await task_logger.log_task_progress(
log_entry,
f"Retrieved {len(issues)} issues from Jira API",
{"stage": "processing_issues", "issues_found": len(issues)},
)
except Exception as e: except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Error fetching Jira issues: {str(e)}",
"Fetch Error",
{"error_type": type(e).__name__},
)
logger.error(f"Error fetching Jira issues: {str(e)}", exc_info=True) logger.error(f"Error fetching Jira issues: {str(e)}", exc_info=True)
return 0, f"Error fetching Jira issues: {str(e)}" return 0, f"Error fetching Jira issues: {str(e)}"
# Process and index each issue # Process and index each issue
indexed_count = 0 documents_indexed = 0
skipped_issues = []
documents_skipped = 0
for issue in issues: for issue in issues:
try: try:
issue_id = issue.get("key")
issue_identifier = issue.get("key", "")
issue_title = issue.get("id", "")
if not issue_id or not issue_title:
logger.warning(
f"Skipping issue with missing ID or title: {issue_id or 'Unknown'}"
)
skipped_issues.append(
f"{issue_identifier or 'Unknown'} (missing data)"
)
documents_skipped += 1
continue
# Format the issue for better readability # Format the issue for better readability
formatted_issue = jira_client.format_issue(issue) formatted_issue = jira_client.format_issue(issue)
# Convert to markdown # Convert to markdown
issue_markdown = jira_client.format_issue_to_markdown(formatted_issue) issue_content = jira_client.format_issue_to_markdown(formatted_issue)
# Create document metadata if not issue_content:
metadata = { logger.warning(
"issue_key": formatted_issue.get("key", ""), f"Skipping issue with no content: {issue_identifier} - {issue_title}"
"issue_title": formatted_issue.get("title", ""),
"status": formatted_issue.get("status", ""),
"priority": formatted_issue.get("priority", ""),
"issue_type": formatted_issue.get("issue_type", ""),
"project": formatted_issue.get("project", ""),
"assignee": (
formatted_issue.get("assignee", {}).get("display_name", "")
if formatted_issue.get("assignee")
else ""
),
"reporter": formatted_issue.get("reporter", {}).get(
"display_name", ""
),
"created_at": formatted_issue.get("created_at", ""),
"updated_at": formatted_issue.get("updated_at", ""),
"comment_count": len(formatted_issue.get("comments", [])),
"connector_id": connector_id,
"source": "jira",
"base_url": jira_base_url,
}
# Generate content hash
content_hash = generate_content_hash(issue_markdown)
# Check if document already exists
existing_doc_result = await session.execute(
select(Document).where(Document.content_hash == content_hash)
)
existing_doc = existing_doc_result.scalar_one_or_none()
if existing_doc:
logger.debug(
f"Document with hash {content_hash} already exists, skipping"
) )
skipped_issues.append(f"{issue_identifier} (no content)")
documents_skipped += 1
continue continue
# Create new document # Create a simple summary
document = Document( summary_content = f"Jira Issue {issue_identifier}: {issue_title}\n\nStatus: {formatted_issue.get('status', 'Unknown')}\n\n"
title=f"Jira: {formatted_issue.get('key', 'Unknown')} - {formatted_issue.get('title', 'Untitled')}", if formatted_issue.get("description"):
document_type=DocumentType.JIRA_CONNECTOR, summary_content += (
document_metadata=metadata, f"Description: {formatted_issue.get('description')}\n\n"
content=issue_markdown,
content_hash=content_hash,
search_space_id=search_space_id,
) )
# Generate embedding # Add comment count
embedding = await config.embedding_model_instance.get_embedding( comment_count = len(formatted_issue.get("comments", []))
issue_markdown summary_content += f"Comments: {comment_count}"
# Generate content hash
content_hash = generate_content_hash(issue_content, search_space_id)
# Check if document already exists
existing_doc_by_hash_result = await session.execute(
select(Document).where(Document.content_hash == content_hash)
)
existing_document_by_hash = (
existing_doc_by_hash_result.scalars().first()
)
if existing_document_by_hash:
logger.info(
f"Document with content hash {content_hash} already exists for issue {issue_identifier}. Skipping processing."
)
documents_skipped += 1
continue
# Generate embedding for the summary
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
# Process chunks - using the full issue content with comments
chunks = [
Chunk(
content=chunk.text,
embedding=config.embedding_model_instance.embed(chunk.text),
)
for chunk in config.chunker_instance.chunk(issue_content)
]
# Create and store new document
logger.info(
f"Creating new document for issue {issue_identifier} - {issue_title}"
)
document = Document(
search_space_id=search_space_id,
title=f"Jira - {issue_identifier}: {issue_title}",
document_type=DocumentType.JIRA_CONNECTOR,
document_metadata={
"issue_id": issue_id,
"issue_identifier": issue_identifier,
"issue_title": issue_title,
"state": formatted_issue.get("status", "Unknown"),
"comment_count": comment_count,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
content=summary_content,
content_hash=content_hash,
embedding=summary_embedding,
chunks=chunks,
) )
document.embedding = embedding
session.add(document) session.add(document)
await session.flush() # Flush to get the document ID documents_indexed += 1
logger.info(
# Create chunks for the document f"Successfully indexed new issue {issue_identifier} - {issue_title}"
chunks = await config.chunking_model_instance.chunk_document(
issue_markdown
)
for chunk_content in chunks:
chunk_embedding = (
await config.embedding_model_instance.get_embedding(
chunk_content
)
)
chunk = Chunk(
content=chunk_content,
embedding=chunk_embedding,
document_id=document.id,
)
session.add(chunk)
indexed_count += 1
logger.debug(
f"Indexed Jira issue: {formatted_issue.get('key', 'Unknown')}"
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error processing Jira issue {issue.get('key', 'Unknown')}: {str(e)}", f"Error processing issue {issue.get('identifier', 'Unknown')}: {str(e)}",
exc_info=True, exc_info=True,
) )
continue skipped_issues.append(
f"{issue.get('identifier', 'Unknown')} (processing error)"
)
documents_skipped += 1
continue # Skip this issue and continue with others
# Update the last_indexed_at timestamp for the connector only if requested
total_processed = documents_indexed
if update_last_indexed:
connector.last_indexed_at = datetime.now()
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
# Commit all changes # Commit all changes
await session.commit() await session.commit()
logger.info("Successfully committed all JIRA document changes to database")
# Update last_indexed_at timestamp # Log success
if update_last_indexed:
connector.last_indexed_at = datetime.now()
await session.commit()
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
f"Successfully indexed {indexed_count} Jira issues", f"Successfully completed JIRA indexing for connector {connector_id}",
{"issues_indexed": indexed_count}, {
"issues_processed": total_processed,
"documents_indexed": documents_indexed,
"documents_skipped": documents_skipped,
"skipped_issues_count": len(skipped_issues),
},
) )
logger.info(f"Successfully indexed {indexed_count} Jira issues") logger.info(
return indexed_count, None f"JIRA indexing completed: {documents_indexed} new issues, {documents_skipped} skipped"
)
return (
total_processed,
None,
) # Return None as the error message to indicate success
except Exception as e: except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
f"Failed to index Jira issues: {str(e)}", f"Database error during JIRA indexing for connector {connector_id}",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error(f"Database error: {str(db_error)}", exc_info=True)
return 0, f"Database error: {str(db_error)}"
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Failed to index JIRA issues for connector {connector_id}",
str(e), str(e),
{"error_type": type(e).__name__}, {"error_type": type(e).__name__},
) )
logger.error(f"Failed to index Jira issues: {str(e)}", exc_info=True) logger.error(f"Failed to index JIRA issues: {str(e)}", exc_info=True)
return 0, f"Failed to index Jira issues: {str(e)}" return 0, f"Failed to index JIRA issues: {str(e)}"

View file

@ -26,8 +26,16 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -45,7 +53,15 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useDocuments } from "@/hooks/use-documents"; import { useDocuments } from "@/hooks/use-documents";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { IconBrandDiscord, IconBrandGithub, IconBrandNotion, IconBrandSlack, IconBrandYoutube, IconLayoutKanban } from "@tabler/icons-react"; import {
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
IconBrandSlack,
IconBrandYoutube,
IconLayoutKanban,
IconBrandTrello,
} from "@tabler/icons-react";
import { import {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
@ -81,10 +97,17 @@ import {
ListFilter, ListFilter,
MoreHorizontal, MoreHorizontal,
Trash, Trash,
Webhook Webhook,
} from "lucide-react"; } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import React, { useContext, useEffect, useId, useMemo, useRef, useState } from "react"; import React, {
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize from "rehype-sanitize";
@ -97,19 +120,27 @@ const fadeInScale = {
visible: { visible: {
opacity: 1, opacity: 1,
scale: 1, scale: 1,
transition: { type: "spring", stiffness: 300, damping: 30 } transition: { type: "spring", stiffness: 300, damping: 30 },
}, },
exit: { exit: {
opacity: 0, opacity: 0,
scale: 0.95, scale: 0.95,
transition: { duration: 0.15 } transition: { duration: 0.15 },
} },
}; };
type Document = { type Document = {
id: number; id: number;
title: string; title: string;
document_type: "EXTENSION" | "CRAWLED_URL" | "SLACK_CONNECTOR" | "NOTION_CONNECTOR" | "FILE" | "YOUTUBE_VIDEO" | "LINEAR_CONNECTOR" | "DISCORD_CONNECTOR"; document_type:
| "EXTENSION"
| "CRAWLED_URL"
| "SLACK_CONNECTOR"
| "NOTION_CONNECTOR"
| "FILE"
| "YOUTUBE_VIDEO"
| "LINEAR_CONNECTOR"
| "DISCORD_CONNECTOR";
document_metadata: any; document_metadata: any;
content: string; content: string;
created_at: string; created_at: string;
@ -117,13 +148,21 @@ type Document = {
}; };
// Custom filter function for multi-column searching // Custom filter function for multi-column searching
const multiColumnFilterFn: FilterFn<Document> = (row, columnId, filterValue) => { const multiColumnFilterFn: FilterFn<Document> = (
row,
columnId,
filterValue,
) => {
const searchableRowContent = `${row.original.title}`.toLowerCase(); const searchableRowContent = `${row.original.title}`.toLowerCase();
const searchTerm = (filterValue ?? "").toLowerCase(); const searchTerm = (filterValue ?? "").toLowerCase();
return searchableRowContent.includes(searchTerm); return searchableRowContent.includes(searchTerm);
}; };
const statusFilterFn: FilterFn<Document> = (row, columnId, filterValue: string[]) => { const statusFilterFn: FilterFn<Document> = (
row,
columnId,
filterValue: string[],
) => {
if (!filterValue?.length) return true; if (!filterValue?.length) return true;
const status = row.getValue(columnId) as string; const status = row.getValue(columnId) as string;
return filterValue.includes(status); return filterValue.includes(status);
@ -139,6 +178,7 @@ const documentTypeIcons = {
YOUTUBE_VIDEO: IconBrandYoutube, YOUTUBE_VIDEO: IconBrandYoutube,
GITHUB_CONNECTOR: IconBrandGithub, GITHUB_CONNECTOR: IconBrandGithub,
LINEAR_CONNECTOR: IconLayoutKanban, LINEAR_CONNECTOR: IconLayoutKanban,
JIRA_CONNECTOR: IconBrandTrello,
DISCORD_CONNECTOR: IconBrandDiscord, DISCORD_CONNECTOR: IconBrandDiscord,
} as const; } as const;
@ -148,7 +188,8 @@ const columns: ColumnDef<Document>[] = [
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={ checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
} }
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Select all"
@ -175,7 +216,7 @@ const columns: ColumnDef<Document>[] = [
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300 }} transition={{ type: "spring", stiffness: 300 }}
style={{ display: 'flex' }} style={{ display: "flex" }}
> >
<Icon size={16} className="text-muted-foreground shrink-0" /> <Icon size={16} className="text-muted-foreground shrink-0" />
<span>{row.getValue("title")}</span> <span>{row.getValue("title")}</span>
@ -188,7 +229,9 @@ const columns: ColumnDef<Document>[] = [
header: "Type", header: "Type",
accessorKey: "document_type", accessorKey: "document_type",
cell: ({ row }) => { cell: ({ row }) => {
const type = row.getValue("document_type") as keyof typeof documentTypeIcons; const type = row.getValue(
"document_type",
) as keyof typeof documentTypeIcons;
const Icon = documentTypeIcons[type]; const Icon = documentTypeIcons[type];
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -196,7 +239,10 @@ const columns: ColumnDef<Document>[] = [
<Icon size={20} className="text-primary" /> <Icon size={20} className="text-primary" />
</div> </div>
<span className="font-medium text-xs"> <span className="font-medium text-xs">
{type.split('_').map(word => word.charAt(0) + word.slice(1).toLowerCase()).join(' ')} {type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ")}
</span> </span>
</div> </div>
); );
@ -211,9 +257,8 @@ const columns: ColumnDef<Document>[] = [
const title = row.getValue("title") as string; const title = row.getValue("title") as string;
// Create a truncated preview (first 150 characters) // Create a truncated preview (first 150 characters)
const previewContent = content.length > 150 const previewContent =
? content.substring(0, 150) + "..." content.length > 150 ? content.substring(0, 150) + "..." : content;
: content;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -223,17 +268,37 @@ const columns: ColumnDef<Document>[] = [
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
// Define custom components for markdown elements // Define custom components for markdown elements
p: ({node, ...props}) => <p className="markdown-paragraph" {...props} />, p: ({ node, ...props }) => (
a: ({node, ...props}) => <a className="text-primary hover:underline" {...props} />, <p className="markdown-paragraph" {...props} />
ul: ({node, ...props}) => <ul className="list-disc pl-5" {...props} />, ),
ol: ({node, ...props}) => <ol className="list-decimal pl-5" {...props} />, a: ({ node, ...props }) => (
<a className="text-primary hover:underline" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="list-disc pl-5" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal pl-5" {...props} />
),
code: ({ node, className, children, ...props }: any) => { code: ({ node, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || "");
const isInline = !match; const isInline = !match;
return isInline return isInline ? (
? <code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>{children}</code> <code
: <code className="block bg-muted p-2 rounded text-xs" {...props}>{children}</code> className="bg-muted px-1 py-0.5 rounded text-xs"
} {...props}
>
{children}
</code>
) : (
<code
className="block bg-muted p-2 rounded text-xs"
{...props}
>
{children}
</code>
);
},
}} }}
> >
{previewContent} {previewContent}
@ -281,7 +346,8 @@ export default function DocumentsTable() {
const id = useId(); const id = useId();
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const { documents, loading, error, refreshDocuments, deleteDocument } = useDocuments(searchSpaceId); const { documents, loading, error, refreshDocuments, deleteDocument } =
useDocuments(searchSpaceId);
// console.log("Search Space ID:", searchSpaceId); // console.log("Search Space ID:", searchSpaceId);
// console.log("Documents loaded:", documents?.length); // console.log("Documents loaded:", documents?.length);
@ -323,7 +389,7 @@ export default function DocumentsTable() {
} }
// Create an array of promises for each delete operation // Create an array of promises for each delete operation
const deletePromises = selectedRows.map(row => { const deletePromises = selectedRows.map((row) => {
// console.log("Deleting row with ID:", row.original.id); // console.log("Deleting row with ID:", row.original.id);
return deleteDocument(row.original.id); return deleteDocument(row.original.id);
}); });
@ -334,10 +400,12 @@ export default function DocumentsTable() {
// console.log("Delete results:", results); // console.log("Delete results:", results);
// Check if all deletions were successful // Check if all deletions were successful
const allSuccessful = results.every(result => result === true); const allSuccessful = results.every((result) => result === true);
if (allSuccessful) { if (allSuccessful) {
toast.success(`Successfully deleted ${selectedRows.length} document(s)`); toast.success(
`Successfully deleted ${selectedRows.length} document(s)`,
);
} else { } else {
toast.error("Some documents could not be deleted"); toast.error("Some documents could not be deleted");
} }
@ -391,12 +459,16 @@ export default function DocumentsTable() {
}, [table.getColumn("document_type")?.getFacetedUniqueValues()]); }, [table.getColumn("document_type")?.getFacetedUniqueValues()]);
const selectedStatuses = useMemo(() => { const selectedStatuses = useMemo(() => {
const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; const filterValue = table
.getColumn("document_type")
?.getFilterValue() as string[];
return filterValue ?? []; return filterValue ?? [];
}, [table.getColumn("document_type")?.getFilterValue()]); }, [table.getColumn("document_type")?.getFilterValue()]);
const handleStatusChange = (checked: boolean, value: string) => { const handleStatusChange = (checked: boolean, value: string) => {
const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; const filterValue = table
.getColumn("document_type")
?.getFilterValue() as string[];
const newFilterValue = filterValue ? [...filterValue] : []; const newFilterValue = filterValue ? [...filterValue] : [];
if (checked) { if (checked) {
@ -408,14 +480,18 @@ export default function DocumentsTable() {
} }
} }
table.getColumn("document_type")?.setFilterValue(newFilterValue.length ? newFilterValue : undefined); table
.getColumn("document_type")
?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
}; };
return ( return (
<DocumentsContext.Provider value={{ <DocumentsContext.Provider
value={{
deleteDocument: deleteDocument || (() => Promise.resolve(false)), deleteDocument: deleteDocument || (() => Promise.resolve(false)),
refreshDocuments: refreshDocuments || (() => Promise.resolve()) refreshDocuments: refreshDocuments || (() => Promise.resolve()),
}}> }}
>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -431,7 +507,7 @@ export default function DocumentsTable() {
type: "spring", type: "spring",
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
delay: 0.1 delay: 0.1,
}} }}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -449,8 +525,12 @@ export default function DocumentsTable() {
"peer min-w-60 ps-9", "peer min-w-60 ps-9",
Boolean(table.getColumn("title")?.getFilterValue()) && "pe-9", Boolean(table.getColumn("title")?.getFilterValue()) && "pe-9",
)} )}
value={(table.getColumn("title")?.getFilterValue() ?? "") as string} value={
onChange={(e) => table.getColumn("title")?.setFilterValue(e.target.value)} (table.getColumn("title")?.getFilterValue() ?? "") as string
}
onChange={(e) =>
table.getColumn("title")?.setFilterValue(e.target.value)
}
placeholder="Filter by title..." placeholder="Filter by title..."
type="text" type="text"
aria-label="Filter by title" aria-label="Filter by title"
@ -519,7 +599,9 @@ export default function DocumentsTable() {
variants={fadeInScale} variants={fadeInScale}
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Filters</div> <div className="text-xs font-medium text-muted-foreground">
Filters
</div>
<div className="space-y-3"> <div className="space-y-3">
<AnimatePresence> <AnimatePresence>
{uniqueStatusValues.map((value, i) => ( {uniqueStatusValues.map((value, i) => (
@ -534,7 +616,9 @@ export default function DocumentsTable() {
<Checkbox <Checkbox
id={`${id}-${i}`} id={`${id}-${i}`}
checked={selectedStatuses.includes(value)} checked={selectedStatuses.includes(value)}
onCheckedChange={(checked: boolean) => handleStatusChange(checked, value)} onCheckedChange={(checked: boolean) =>
handleStatusChange(checked, value)
}
/> />
<Label <Label
htmlFor={`${id}-${i}`} htmlFor={`${id}-${i}`}
@ -589,7 +673,9 @@ export default function DocumentsTable() {
key={column.id} key={column.id}
className="capitalize" className="capitalize"
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
> >
{column.id} {column.id}
@ -624,20 +710,32 @@ export default function DocumentsTable() {
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border" className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border"
aria-hidden="true" aria-hidden="true"
> >
<CircleAlert className="opacity-80" size={16} strokeWidth={2} /> <CircleAlert
className="opacity-80"
size={16}
strokeWidth={2}
/>
</div> </div>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete{" "} This action cannot be undone. This will permanently
{table.getSelectedRowModel().rows.length} selected{" "} delete {table.getSelectedRowModel().rows.length}{" "}
{table.getSelectedRowModel().rows.length === 1 ? "row" : "rows"}. selected{" "}
{table.getSelectedRowModel().rows.length === 1
? "row"
: "rows"}
.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>Delete</AlertDialogAction> <AlertDialogAction onClick={handleDeleteRows}>
Delete
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@ -659,21 +757,25 @@ export default function DocumentsTable() {
type: "spring", type: "spring",
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
delay: 0.2 delay: 0.2,
}} }}
> >
{loading ? ( {loading ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading documents...</p> <p className="text-sm text-muted-foreground">
Loading documents...
</p>
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">Error loading documents</p> <p className="text-sm text-destructive">
Error loading documents
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -688,14 +790,19 @@ export default function DocumentsTable() {
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-muted-foreground" /> <FileX className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No documents found</p> <p className="text-sm text-muted-foreground">
No documents found
</p>
</div> </div>
</div> </div>
) : ( ) : (
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent"> <TableRow
key={headerGroup.id}
className="hover:bg-transparent"
>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead <TableHead
@ -720,9 +827,14 @@ export default function DocumentsTable() {
header.column.getToggleSortingHandler()?.(e); header.column.getToggleSortingHandler()?.(e);
} }
}} }}
tabIndex={header.column.getCanSort() ? 0 : undefined} tabIndex={
header.column.getCanSort() ? 0 : undefined
}
> >
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{{ {{
asc: ( asc: (
<ChevronUp <ChevronUp
@ -743,7 +855,10 @@ export default function DocumentsTable() {
}[header.column.getIsSorted() as string] ?? null} }[header.column.getIsSorted() as string] ?? null}
</div> </div>
) : ( ) : (
flexRender(header.column.columnDef.header, header.getContext()) flexRender(
header.column.columnDef.header,
header.getContext(),
)
)} )}
</TableHead> </TableHead>
); );
@ -765,25 +880,34 @@ export default function DocumentsTable() {
type: "spring", type: "spring",
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
delay: index * 0.03 delay: index * 0.03,
} },
}} }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50", "border-b transition-colors hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "" row.getIsSelected() ? "bg-muted/50" : "",
)} )}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3 last:py-3"> <TableCell
{flexRender(cell.column.columnDef.cell, cell.getContext())} key={cell.id}
className="px-4 py-3 last:py-3"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell> </TableCell>
))} ))}
</motion.tr> </motion.tr>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center p-6"> <TableCell
colSpan={columns.length}
className="h-24 text-center p-6"
>
No documents found. No documents found.
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -823,7 +947,7 @@ export default function DocumentsTable() {
animate={{ animate={{
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { delay: index * 0.05 } transition: { delay: index * 0.05 },
}} }}
> >
<SelectItem value={pageSize.toString()}> <SelectItem value={pageSize.toString()}>
@ -841,19 +965,29 @@ export default function DocumentsTable() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
<p className="whitespace-nowrap text-sm text-muted-foreground" aria-live="polite"> <p
className="whitespace-nowrap text-sm text-muted-foreground"
aria-live="polite"
>
<span className="text-foreground"> <span className="text-foreground">
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}- {table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}
-
{Math.min( {Math.min(
Math.max( Math.max(
table.getState().pagination.pageIndex * table.getState().pagination.pageSize + table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
table.getState().pagination.pageSize, table.getState().pagination.pageSize,
0, 0,
), ),
table.getRowCount(), table.getRowCount(),
)} )}
</span>{" "} </span>{" "}
of <span className="text-foreground">{table.getRowCount().toString()}</span> of{" "}
<span className="text-foreground">
{table.getRowCount().toString()}
</span>
</p> </p>
</motion.div> </motion.div>
@ -876,7 +1010,11 @@ export default function DocumentsTable() {
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
aria-label="Go to first page" aria-label="Go to first page"
> >
<ChevronFirst size={16} strokeWidth={2} aria-hidden="true" /> <ChevronFirst
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button> </Button>
</motion.div> </motion.div>
</PaginationItem> </PaginationItem>
@ -895,7 +1033,11 @@ export default function DocumentsTable() {
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
aria-label="Go to previous page" aria-label="Go to previous page"
> >
<ChevronLeft size={16} strokeWidth={2} aria-hidden="true" /> <ChevronLeft
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button> </Button>
</motion.div> </motion.div>
</PaginationItem> </PaginationItem>
@ -914,7 +1056,11 @@ export default function DocumentsTable() {
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
aria-label="Go to next page" aria-label="Go to next page"
> >
<ChevronRight size={16} strokeWidth={2} aria-hidden="true" /> <ChevronRight
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button> </Button>
</motion.div> </motion.div>
</PaginationItem> </PaginationItem>
@ -933,7 +1079,11 @@ export default function DocumentsTable() {
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
aria-label="Go to last page" aria-label="Go to last page"
> >
<ChevronLast size={16} strokeWidth={2} aria-hidden="true" /> <ChevronLast
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button> </Button>
</motion.div> </motion.div>
</PaginationItem> </PaginationItem>
@ -1003,7 +1153,8 @@ function RowActions({ row }: { row: Row<Document> }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the document. This action cannot be undone. This will permanently delete the
document.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -1027,4 +1178,3 @@ function RowActions({ row }: { row: Row<Document> }) {
} }
export { DocumentsTable }; export { DocumentsTable };