Skip to content

Commit

Permalink
feature: paginated infinite scroll (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
AugustDev committed Nov 15, 2023
1 parent 1f89bc3 commit 4707c29
Show file tree
Hide file tree
Showing 12 changed files with 877 additions and 81 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"studio": "npx prisma studio"
},
"dependencies": {
"@ant-design/cssinjs": "^1.17.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.16",
Expand All @@ -29,6 +30,7 @@
"@types/node": "20.4.8",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"antd": "^5.11.1",
"autoprefixer": "10.4.14",
"bytes": "^3.1.2",
"clsx": "^2.0.0",
Expand All @@ -44,6 +46,7 @@
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.10.1",
"react-infinite-scroll-component": "^6.1.0",
"react-plotly.js": "^2.6.0",
"sass": "^1.66.1",
"tailwindcss": "3.3.3",
Expand Down
12 changes: 5 additions & 7 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { prisma } from "@/services/prisma/prisma"
import { NextResponse } from "next/server"
import { SearchRequest, SearchResponse } from "./types"
import { SearchRequest } from "./types"
import { searchWorkflows } from "@/services/prisma"

export async function POST(request: Request) {
const searchRequest: SearchRequest = await request.json()

const workflows = await searchWorkflows({
const searchResults = await searchWorkflows({
term: searchRequest.term,
id: searchRequest.id,
runName: searchRequest.run_name,
Expand All @@ -16,11 +16,9 @@ export async function POST(request: Request) {
after: searchRequest.after,
before: searchRequest.before,
workspaceId: searchRequest.workspace_id,
first: searchRequest.first,
cursor: searchRequest.cursor,
})

const res: SearchResponse = {
workflows: workflows,
}

return NextResponse.json(res)
return NextResponse.json(searchResults)
}
8 changes: 8 additions & 0 deletions src/app/api/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ export type SearchRequest = {
after?: Date
before?: Date
workspace_id?: number
first?: number
cursor?: string
}

export type TPageInfo = {
hasNextPage: boolean
endCursor?: string
}

export type SearchResponse = {
workflows: Workflow[]
pageInfo: TPageInfo
}
2 changes: 1 addition & 1 deletion src/app/components/Tags/Tags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type TagProps = {

export const Tag: React.FC<TagProps> = ({ name }) => {
return (
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 mr-2 text-xs font-medium text-indigo-700">
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 mr-2 mb-1 text-xs font-medium text-indigo-700">
{name}
</span>
)
Expand Down
6 changes: 5 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import "./globals.css"
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import { MainNavigation } from "./components"
import StyledComponentsRegistry from "../lib/AntdRegistry"
// import "@/globals.css"

const inter = Inter({ subsets: ["latin"] })

Expand All @@ -14,7 +16,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" className="h-full bg-gray-100">
<body className={inter.className}>
<MainNavigation child={children} />
<StyledComponentsRegistry>
<MainNavigation child={children} />
</StyledComponentsRegistry>
</body>
</html>
)
Expand Down
41 changes: 37 additions & 4 deletions src/app/runs/components/Main/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { clsx } from "clsx"
import { SearchBar } from "@/app/components"
import { Workflow, Workspace } from "@prisma/client"
import { RunsTable } from "../RunsTable"
import { SearchRequest, SearchResponse } from "@/app/api/search/types"
import { SearchRequest, SearchResponse, TPageInfo } from "@/app/api/search/types"
import styles from "./Main.module.scss"
import moment from "moment"

Expand All @@ -19,6 +19,7 @@ export const Main = (props: TMainProps) => {
const [searchTags, setSearchTags] = useState<string[]>(props.searchTags ?? [])
const [workflows, setWorkflows] = useState<Workflow[]>(props.runs)
const [workspaces, setWorkspaces] = useState<Workspace[]>(props.workspaces)
const [pageInfo, setPageInfo] = useState<TPageInfo>({ hasNextPage: true })

const addSearchTag = (tag: string) => {
if (tag == "" || searchTags.includes(tag)) {
Expand All @@ -30,7 +31,8 @@ export const Main = (props: TMainProps) => {
const removeSearchTag = (tag: string) => {
setSearchTags(searchTags.filter((t) => t !== tag))
}
const executeSearch = async () => {

const searchRuns = async (cursor?: string) => {
const searchBody: SearchRequest = {}

for (const tag of searchTags) {
Expand Down Expand Up @@ -82,30 +84,59 @@ export const Main = (props: TMainProps) => {
}
}

if (cursor) {
searchBody.cursor = cursor
}

const response = await fetch(`/api/search`, {
body: JSON.stringify(searchBody),
method: "POST",
cache: "no-store",
})

const results: SearchResponse = await response.json()
return results
}

const executeSearch = async () => {
const results = await searchRuns()
setWorkflows(results.workflows)
setPageInfo(results.pageInfo)
}

const getLatestRuns = async () => {
const results = await searchRuns()
setWorkflows((prevWorkflows) => {
const newWorkflows = [...prevWorkflows, ...results.workflows]
const idToWorkflowMap = new Map(newWorkflows.map((workflow) => [workflow.id, workflow]))
return Array.from(idToWorkflowMap.values())
})
}

const onWorkflowDeleteClick = async (id: string) => {
await fetch(`/api/runs/${id}`, {
method: "DELETE",
cache: "no-store",
})
executeSearch()
setWorkflows((prevWorkflows) => prevWorkflows.filter((workflow) => workflow.id !== id))
}

const fetchMoreData = async () => {
const results = await searchRuns(pageInfo.endCursor)
setWorkflows((prevWorkflows) => {
const newWorkflows = [...prevWorkflows, ...results.workflows]
const idToWorkflowMap = new Map(newWorkflows.map((workflow) => [workflow.id, workflow]))
return Array.from(idToWorkflowMap.values())
})
setPageInfo(results.pageInfo)
}

useEffect(() => {
executeSearch()

// Execute every 5 seconds
const intervalId = setInterval(() => {
executeSearch()
getLatestRuns()
}, 5000) // 5000 milliseconds = 5 seconds

// Clear interval on component unmount
Expand All @@ -122,6 +153,8 @@ export const Main = (props: TMainProps) => {
runs={workflows}
className={clsx(styles.fadeInBottom, "mt-8")}
onDeleteClick={onWorkflowDeleteClick}
fetchMoreData={fetchMoreData}
pageInfo={pageInfo}
/>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export const OptionsDropdown = ({ deleteWorkflow }: TOptionDropdownProps) => {
<Menu.Item>
{({ active }) => (
<a
href="#"
className={clsx(active ? "bg-gray-100 text-red-900" : "text-red-700", "block px-4 py-2 text-sm")}
onClick={() => setDeleteWorkspaceModal(true)}
>
Expand Down
155 changes: 114 additions & 41 deletions src/app/runs/components/RunsTable/RunsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,132 @@
"use client"

import { useRouter } from "next/navigation"
import { Tag, TimerDisplayDynamic, WorkflowStatusTag } from "@/app/components"
import { TimerDisplayDynamic, WorkflowStatusTag } from "@/app/components"
import { formatDifference, fullDateTime, workflowStatus } from "@/common"
import { Workflow } from "@prisma/client"
import InfiniteScroll from "react-infinite-scroll-component"

import { Table } from "antd"
import type { ColumnsType } from "antd/es/table"

import { clsx } from "clsx"
import { OptionsDropdown } from "../OptionsDropdown"
import Link from "next/link"
import React from "react"
import { TPageInfo } from "@/app/api/search/types"

type RunsTableProps = {
runs: Workflow[]
className?: string
onDeleteClick: (id: string) => void
fetchMoreData: () => void
pageInfo: TPageInfo
}

export const RunsTable: React.FC<RunsTableProps> = ({ runs, className, onDeleteClick }: RunsTableProps) => {
export const RunsTable: React.FC<RunsTableProps> = ({
runs,
className,
onDeleteClick,
fetchMoreData,
pageInfo,
}: RunsTableProps) => {
const columns: ColumnsType<Workflow> = [
{
title: "Description",
dataIndex: "description",
key: "description",
width: 300,
render: (_, run) => (
<Link className="text-black" href={`runs/${run.id}`}>
{run.manifest.description}
</Link>
),
},
{
title: "Project",
dataIndex: "project",
key: "project",
width: 200,
render: (_, run) => (
<div>
<div className="font-medium text-gray-900">
<Link className="text-black" href={`runs/${run.id}`}>
{run.runName}
</Link>
</div>
<div className="mt-1 text-gray-500">{run.projectName}</div>
</div>
),
},
{
title: "Date",
dataIndex: "date",
key: "date",
width: 200,
render: (_, run) => (
<div>
<div className="font-medium text-gray-900">{fullDateTime(run.start)}</div>
<div className="mt-1">
{run.complete && <div>{formatDifference(run.start, run.complete)}</div>}
{!run.complete && <TimerDisplayDynamic startedAt={run.start} />}
</div>
</div>
),
},
{
title: "Tags",
key: "tags",
dataIndex: "tags",
width: 200,
render: (_, { tags }) => (
<>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-md bg-gray-100 px-1.5 py-0.5 mr-1 mb-0.5 text-xs font-medium text-gray-600"
>
{tag}
</span>
))}
</>
),
},
{
title: "Status",
key: "status",
dataIndex: "status",
width: 150,
render: (_, run) => <WorkflowStatusTag status={workflowStatus(run)} />,
},
{
title: "Options",
key: "options",
dataIndex: "options",
width: 100,
align: "center",
render: (_, run) => <OptionsDropdown deleteWorkflow={() => onDeleteClick(run.id)} />,
},
]

return (
<div className={clsx(className, "overflow-x-auto h-full mx-4 md:mx-0 bg-white")}>
<div className="rounded-md text-left bg-white">
{runs.map((run) => (
<Link href={`/runs/${run.id}`} key={run.id} className="">
<div className="grid grid-cols-[minmax(auto,1fr),minmax(auto,1fr),minmax(auto,1fr),minmax(auto,1fr),minmax(auto,1fr),min-content] gap-4 align-middle sm:px-6 lg:px-8 hover:bg-gray-50 cursor-pointer">
<div className="px-6 py-5 text-sm text-gray-500 min-w-[300px]">
<div className="text-gray-900">{run.manifest.description}</div>
</div>
<div className="py-5 px-3 text-sm flex items-center min-w-[200px]">
<div className="ml-4">
<div className="font-medium text-gray-900">{run.runName}</div>
<div className="mt-1 text-gray-500">{run.projectName}</div>
</div>
</div>
<div className="px-3 py-5 text-sm text-gray-500 text-right min-w-[200px]">
<div className="font-medium text-gray-900">{fullDateTime(run.start)}</div>
<div className="mt-1">
{run.complete && <div>{formatDifference(run.start, run.complete)}</div>}
{!run.complete && <TimerDisplayDynamic startedAt={run.start} />}
</div>
</div>
<div className="px-3 py-5 text-sm flex flex-wrap justify-center items-center min-w-[150px]">
{run.tags.map((tag) => (
<Tag key={tag} name={tag} />
))}
</div>
<div className="py-5 text-sm text-gray-500 flex justify-center items-center min-w-[150px]">
<WorkflowStatusTag status={workflowStatus(run)} />
</div>
<div className="py-5 text-sm text-gray-500 flex justify-end items-center">
<OptionsDropdown deleteWorkflow={() => onDeleteClick(run.id)} />
</div>
</div>
</Link>
))}
</div>
</div>
<InfiniteScroll
dataLength={runs.length}
next={fetchMoreData}
hasMore={pageInfo.hasNextPage} // Replace with a condition to check if there's more data to load
loader={<h4>Loading...</h4>}
endMessage={
<p>
<b>End of runs</b>
</p>
}
>
<Table
className="pt-3"
columns={columns}
dataSource={runs}
rowKey={(row) => row.id}
scroll={{ x: 1000 }}
pagination={false}
tableLayout="fixed"
/>
</InfiniteScroll>
)
}
Loading

0 comments on commit 4707c29

Please sign in to comment.