From 72e76e307460938c1f162d23f4f1cb80b8765269 Mon Sep 17 00:00:00 2001 From: Augustinas Malinauskas Date: Wed, 15 Nov 2023 22:25:27 +0000 Subject: [PATCH 1/2] feature: paginated infinite scroll (#26) (#27) --- package.json | 3 + src/app/api/search/route.ts | 12 +- src/app/api/search/types.ts | 8 + src/app/components/Tags/Tags.tsx | 2 +- src/app/layout.tsx | 6 +- src/app/runs/components/Main/Main.tsx | 41 +- .../OptionsDropdown/OptionsDropdown.tsx | 1 - .../runs/components/RunsTable/RunsTable.tsx | 155 +++-- src/app/runs/page.tsx | 16 +- src/lib/AntdRegistry.tsx | 22 + src/services/prisma/search.ts | 76 ++- yarn.lock | 616 +++++++++++++++++- 12 files changed, 877 insertions(+), 81 deletions(-) create mode 100644 src/lib/AntdRegistry.tsx diff --git a/package.json b/package.json index 00be88b..70159f7 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 5893ebf..d970d9f 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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, @@ -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) } diff --git a/src/app/api/search/types.ts b/src/app/api/search/types.ts index 28691ee..8915855 100644 --- a/src/app/api/search/types.ts +++ b/src/app/api/search/types.ts @@ -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 } diff --git a/src/app/components/Tags/Tags.tsx b/src/app/components/Tags/Tags.tsx index 2bf34c9..5066486 100644 --- a/src/app/components/Tags/Tags.tsx +++ b/src/app/components/Tags/Tags.tsx @@ -6,7 +6,7 @@ type TagProps = { export const Tag: React.FC = ({ name }) => { return ( - + {name} ) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d1ffe85..325695b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"] }) @@ -14,7 +16,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - + + + ) diff --git a/src/app/runs/components/Main/Main.tsx b/src/app/runs/components/Main/Main.tsx index 688b3f3..328cc0e 100644 --- a/src/app/runs/components/Main/Main.tsx +++ b/src/app/runs/components/Main/Main.tsx @@ -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" @@ -19,6 +19,7 @@ export const Main = (props: TMainProps) => { const [searchTags, setSearchTags] = useState(props.searchTags ?? []) const [workflows, setWorkflows] = useState(props.runs) const [workspaces, setWorkspaces] = useState(props.workspaces) + const [pageInfo, setPageInfo] = useState({ hasNextPage: true }) const addSearchTag = (tag: string) => { if (tag == "" || searchTags.includes(tag)) { @@ -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) { @@ -82,6 +84,10 @@ export const Main = (props: TMainProps) => { } } + if (cursor) { + searchBody.cursor = cursor + } + const response = await fetch(`/api/search`, { body: JSON.stringify(searchBody), method: "POST", @@ -89,7 +95,22 @@ export const Main = (props: TMainProps) => { }) 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) => { @@ -97,7 +118,17 @@ export const Main = (props: TMainProps) => { 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(() => { @@ -105,7 +136,7 @@ export const Main = (props: TMainProps) => { // Execute every 5 seconds const intervalId = setInterval(() => { - executeSearch() + getLatestRuns() }, 5000) // 5000 milliseconds = 5 seconds // Clear interval on component unmount @@ -122,6 +153,8 @@ export const Main = (props: TMainProps) => { runs={workflows} className={clsx(styles.fadeInBottom, "mt-8")} onDeleteClick={onWorkflowDeleteClick} + fetchMoreData={fetchMoreData} + pageInfo={pageInfo} /> )} diff --git a/src/app/runs/components/OptionsDropdown/OptionsDropdown.tsx b/src/app/runs/components/OptionsDropdown/OptionsDropdown.tsx index 0ff07eb..f5cf3a5 100644 --- a/src/app/runs/components/OptionsDropdown/OptionsDropdown.tsx +++ b/src/app/runs/components/OptionsDropdown/OptionsDropdown.tsx @@ -42,7 +42,6 @@ export const OptionsDropdown = ({ deleteWorkflow }: TOptionDropdownProps) => { {({ active }) => ( setDeleteWorkspaceModal(true)} > diff --git a/src/app/runs/components/RunsTable/RunsTable.tsx b/src/app/runs/components/RunsTable/RunsTable.tsx index 67e5a08..361371c 100644 --- a/src/app/runs/components/RunsTable/RunsTable.tsx +++ b/src/app/runs/components/RunsTable/RunsTable.tsx @@ -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 = ({ runs, className, onDeleteClick }: RunsTableProps) => { +export const RunsTable: React.FC = ({ + runs, + className, + onDeleteClick, + fetchMoreData, + pageInfo, +}: RunsTableProps) => { + const columns: ColumnsType = [ + { + title: "Description", + dataIndex: "description", + key: "description", + width: 300, + render: (_, run) => ( + + {run.manifest.description} + + ), + }, + { + title: "Project", + dataIndex: "project", + key: "project", + width: 200, + render: (_, run) => ( +
+
+ + {run.runName} + +
+
{run.projectName}
+
+ ), + }, + { + title: "Date", + dataIndex: "date", + key: "date", + width: 200, + render: (_, run) => ( +
+
{fullDateTime(run.start)}
+
+ {run.complete &&
{formatDifference(run.start, run.complete)}
} + {!run.complete && } +
+
+ ), + }, + { + title: "Tags", + key: "tags", + dataIndex: "tags", + width: 200, + render: (_, { tags }) => ( + <> + {tags.map((tag) => ( + + {tag} + + ))} + + ), + }, + { + title: "Status", + key: "status", + dataIndex: "status", + width: 150, + render: (_, run) => , + }, + { + title: "Options", + key: "options", + dataIndex: "options", + width: 100, + align: "center", + render: (_, run) => onDeleteClick(run.id)} />, + }, + ] + return ( -
-
- {runs.map((run) => ( - -
-
-
{run.manifest.description}
-
-
-
-
{run.runName}
-
{run.projectName}
-
-
-
-
{fullDateTime(run.start)}
-
- {run.complete &&
{formatDifference(run.start, run.complete)}
} - {!run.complete && } -
-
-
- {run.tags.map((tag) => ( - - ))} -
-
- -
-
- onDeleteClick(run.id)} /> -
-
- - ))} -
-
+ Loading...} + endMessage={ +

+ End of runs +

+ } + > + row.id} + scroll={{ x: 1000 }} + pagination={false} + tableLayout="fixed" + /> + ) } diff --git a/src/app/runs/page.tsx b/src/app/runs/page.tsx index 21b06b3..9490f22 100644 --- a/src/app/runs/page.tsx +++ b/src/app/runs/page.tsx @@ -1,7 +1,7 @@ import { prisma } from "@/services/prisma/prisma" import { Workflow, Workspace } from "@prisma/client" import { Main } from "./components/Main" -import { getAllWorkspaces, GetWorkflows } from "@/services/prisma" +import { getAllWorkspaces, searchWorkflows } from "@/services/prisma" export default async function Page(request: any) { const workspaceId = Number(request.searchParams.workspaceId) @@ -17,19 +17,12 @@ type TRunsPageProps = { } const getData = async (workspaceId: number): Promise => { - let workflows: Workflow[] = [] + let workflows: Partial[] = [] let workspaces: Workspace[] = [] let searchTags: string[] = [] try { - workflows = await prisma.workflow.findMany({ - take: 20, - orderBy: { - updatedAt: "desc", - }, - where: { - workspaceId: workspaceId ? workspaceId : undefined, - }, - }) + const searchRes = await searchWorkflows({ workspaceId: workspaceId }) + workflows = searchRes.workflows workspaces = await getAllWorkspaces() const workspaceName = workspaces.find((w) => w.id == workspaceId)?.name if (workspaceName) { @@ -40,6 +33,7 @@ const getData = async (workspaceId: number): Promise => { } return { + // @ts-ignore runs: workflows, workspaces: workspaces, searchtags: searchTags, diff --git a/src/lib/AntdRegistry.tsx b/src/lib/AntdRegistry.tsx new file mode 100644 index 0000000..8768751 --- /dev/null +++ b/src/lib/AntdRegistry.tsx @@ -0,0 +1,22 @@ +"use client" + +import React from "react" +import { createCache, extractStyle, StyleProvider } from "@ant-design/cssinjs" +import type Entity from "@ant-design/cssinjs/es/Cache" +import { useServerInsertedHTML } from "next/navigation" + +const StyledComponentsRegistry = ({ children }: React.PropsWithChildren) => { + const cache = React.useMemo(() => createCache(), []) + const isServerInserted = React.useRef(false) + useServerInsertedHTML(() => { + // avoid duplicate css insert + if (isServerInserted.current) { + return + } + isServerInserted.current = true + return