diff --git a/components/dashboard/src/components/forms/TextInputField.tsx b/components/dashboard/src/components/forms/TextInputField.tsx index 922b2dfe307dd2..664bb9ec91a0db 100644 --- a/components/dashboard/src/components/forms/TextInputField.tsx +++ b/components/dashboard/src/components/forms/TextInputField.tsx @@ -4,10 +4,10 @@ * See License.AGPL.txt in the project root for license information. */ -import classNames from "classnames"; import { FunctionComponent, memo, ReactNode, useCallback } from "react"; import { useId } from "../../hooks/useId"; import { InputField } from "./InputField"; +import { cn } from "@podkit/lib/cn"; type TextInputFieldTypes = "text" | "password" | "email" | "url"; @@ -97,7 +97,7 @@ export const TextInput: FunctionComponent = memo( return ( = ({ title, subtitle, action }) => { + return ( +
+
+ {title} + {subtitle && {subtitle}} +
+ {action && action} +
+ ); +}; diff --git a/components/dashboard/src/components/podkit/tables/Table.tsx b/components/dashboard/src/components/podkit/tables/Table.tsx new file mode 100644 index 00000000000000..e9cf888cc4010a --- /dev/null +++ b/components/dashboard/src/components/podkit/tables/Table.tsx @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { cn } from "@podkit/lib/cn"; +import React from "react"; + +type HideableCellProps = { + hideOnSmallScreen?: boolean; +}; + +export const Table = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ + + ); + }, +); +Table.displayName = "Table"; + +export const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( + + ); + }, +); +TableHeader.displayName = "TableHeader"; + +export const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => { + return ; + }, +); +TableRow.displayName = "TableRow"; + +export const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes & HideableCellProps +>(({ hideOnSmallScreen, className, ...props }, ref) => { + return + ); + }, +); +TableBody.displayName = "TableBody"; + +export const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes & HideableCellProps +>(({ hideOnSmallScreen, className, ...props }, ref) => { + return
; +}); +TableHead.displayName = "TableHead"; + +export const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
; +}); +TableCell.displayName = "TableCell"; diff --git a/components/dashboard/src/components/podkit/typography/Headings.tsx b/components/dashboard/src/components/podkit/typography/Headings.tsx index a6e288f6bb20c3..97922be5388ea0 100644 --- a/components/dashboard/src/components/podkit/typography/Headings.tsx +++ b/components/dashboard/src/components/podkit/typography/Headings.tsx @@ -22,7 +22,7 @@ export const Heading1: FC = ({ id, color, tracking, className, chi return ( {children} diff --git a/components/dashboard/src/data/configurations/configuration-queries.ts b/components/dashboard/src/data/configurations/configuration-queries.ts index 0b752d618d1ff6..34307cb1482a3e 100644 --- a/components/dashboard/src/data/configurations/configuration-queries.ts +++ b/components/dashboard/src/data/configurations/configuration-queries.ts @@ -33,10 +33,14 @@ export const useListConfigurations = ({ searchTerm = "", page, pageSize }: ListC pagination: { page, pageSize }, }); - return { configurations, pagination }; + return { + configurations, + pagination, + }; }, { enabled: !!org, + keepPreviousData: true, }, ); }; diff --git a/components/dashboard/src/menu/OrganizationSelector.tsx b/components/dashboard/src/menu/OrganizationSelector.tsx index 2bd6bab93044a2..d29638a159d739 100644 --- a/components/dashboard/src/menu/OrganizationSelector.tsx +++ b/components/dashboard/src/menu/OrganizationSelector.tsx @@ -57,8 +57,8 @@ export default function OrganizationSelector() { if (currentOrg.data) { if (repoConfigListAndDetail) { linkEntries.push({ - title: "Configurations", - customContent: Configurations, + title: "Repositories", + customContent: Repositories, active: false, separator: false, link: "/repositories", diff --git a/components/dashboard/src/repositories/list/PaginationControls.tsx b/components/dashboard/src/repositories/list/PaginationControls.tsx new file mode 100644 index 00000000000000..ce5884976a01b6 --- /dev/null +++ b/components/dashboard/src/repositories/list/PaginationControls.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { Button } from "@podkit/buttons/Button"; +import { cn } from "@podkit/lib/cn"; +import { TextMuted } from "@podkit/typography/TextMuted"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { FC, useCallback } from "react"; + +type Props = { + currentPage: number; + totalPages: number; + pageSize: number; + totalRows: number; + currentRows: number; + onPageChanged: (page: number) => void; +}; +export const PaginationControls: FC = ({ + currentPage, + totalPages, + pageSize, + totalRows, + currentRows, + onPageChanged, +}) => { + const prevPage = useCallback(() => { + onPageChanged(currentPage - 1); + }, [currentPage, onPageChanged]); + + const nextPage = useCallback(() => { + onPageChanged(currentPage + 1); + }, [currentPage, onPageChanged]); + + return ( +
+ {/* TODO: Rows per page select */} + + + +
+ ); +}; + +type PaginationCountTextProps = { + currentPage: number; + pageSize: number; + currentRows: number; + totalRows: number; + className?: string; + includePrefix?: boolean; +}; +export const PaginationCountText: FC = ({ + currentPage, + pageSize, + currentRows, + totalRows, + className, + includePrefix = false, +}) => { + const start = (currentPage - 1) * pageSize + 1; + const end = start + currentRows - 1; + + return ( + + {includePrefix ? `Showing ${start} - ${end} of ${totalRows}` : `${start} - ${end} of ${totalRows}`} + + ); +}; diff --git a/components/dashboard/src/repositories/list/RepoListItem.tsx b/components/dashboard/src/repositories/list/RepoListItem.tsx index 6910a6fbee92f7..6580e58c079f63 100644 --- a/components/dashboard/src/repositories/list/RepoListItem.tsx +++ b/components/dashboard/src/repositories/list/RepoListItem.tsx @@ -8,28 +8,58 @@ import { FC } from "react"; import { usePrettyRepoURL } from "../../hooks/use-pretty-repo-url"; import { TextMuted } from "@podkit/typography/TextMuted"; import { Text } from "@podkit/typography/Text"; -import { Link } from "react-router-dom"; -import { Button } from "../../components/Button"; +import { LinkButton } from "@podkit/buttons/LinkButton"; import type { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb"; +import { cn } from "@podkit/lib/cn"; +import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"; +import { TableCell, TableRow } from "@podkit/tables/Table"; type Props = { configuration: Configuration; }; export const RepositoryListItem: FC = ({ configuration }) => { const url = usePrettyRepoURL(configuration.cloneUrl); + const prebuildsEnabled = !!configuration.prebuildSettings?.enabled; + const created = + configuration.creationTime + ?.toDate() + .toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) ?? ""; return ( -
  • -
    - {configuration.name} + + +
    + {configuration.name} + {/* We show the url on a 2nd line for smaller screens since we hide the column */} + {url} +
    +
    + + {url} -
    - -
    - - - -
    -
  • + + + {created} + + +
    + {prebuildsEnabled ? ( + + ) : ( + + )} + + + {prebuildsEnabled ? "Enabled" : "Disabled"} + +
    +
    + + + + View + + + ); }; diff --git a/components/dashboard/src/repositories/list/RepositoryList.tsx b/components/dashboard/src/repositories/list/RepositoryList.tsx index 6eadc7ba29a45b..578c65c6145097 100644 --- a/components/dashboard/src/repositories/list/RepositoryList.tsx +++ b/components/dashboard/src/repositories/list/RepositoryList.tsx @@ -4,24 +4,53 @@ * See License.AGPL.txt in the project root for license information. */ -import { FC, useCallback, useState } from "react"; -import Header from "../../components/Header"; -import { Loader2 } from "lucide-react"; +import { FC, useCallback, useEffect, useState } from "react"; +import { LoaderIcon } from "lucide-react"; import { useHistory } from "react-router-dom"; import { Project } from "@gitpod/gitpod-protocol"; import { CreateProjectModal } from "../../projects/create-project-modal/CreateProjectModal"; -import { Button } from "../../components/Button"; import { RepositoryListItem } from "./RepoListItem"; import { useListConfigurations } from "../../data/configurations/configuration-queries"; import { useStateWithDebounce } from "../../hooks/use-state-with-debounce"; import { TextInput } from "../../components/forms/TextInputField"; +import { TextMuted } from "@podkit/typography/TextMuted"; +import { PageHeading } from "@podkit/layout/PageHeading"; +import { Button } from "@podkit/buttons/Button"; +import { useDocumentTitle } from "../../hooks/use-document-title"; +import { PaginationControls, PaginationCountText } from "./PaginationControls"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@podkit/tables/Table"; const RepositoryListPage: FC = () => { + useDocumentTitle("Imported repositories"); + const history = useHistory(); + + // TODO: Move this state into url search params + const [currentPage, setCurrentPage] = useState(1); const [searchTerm, setSearchTerm, debouncedSearchTerm] = useStateWithDebounce(""); - const { data, isLoading } = useListConfigurations({ searchTerm: debouncedSearchTerm, page: 0, pageSize: 10 }); + + // Reset to page 1 when debounced search term changes (when we perform a new search) + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearchTerm]); + + // Have this set to a low value for now to test pagination while we develop this + // TODO: move this into state and add control for changing it + const pageSize = 5; + + const { data, isFetching, isPreviousData } = useListConfigurations({ + searchTerm: debouncedSearchTerm, + page: currentPage, + pageSize, + }); const [showCreateProjectModal, setShowCreateProjectModal] = useState(false); + // TODO: Adding these to response payload to avoid having to calculate them here + // This will fix issues w/ relying on some server provided state and some client state (like current page) + const rowCount = data?.configurations.length ?? 0; + const totalRows = data?.pagination?.total ?? 0; + const totalPages = Math.ceil(totalRows / pageSize); + const handleProjectCreated = useCallback( (project: Project) => { history.push(`/repositories/${project.id}`); @@ -31,25 +60,88 @@ const RepositoryListPage: FC = () => { return ( <> -
    -
    -
    - -
    + setShowCreateProjectModal(true)}>Import Repository} + /> -
    - + {/* Search/Filter bar */} +
    +
    + {/* TODO: Add search icon on left and decide on pulling Inputs into podkit */} + + {/* TODO: Add prebuild status filter dropdown */} +
    + {/* Account for variation of message when totalRows is greater than smallest page size option (20?) */} +
    + + {rowCount < totalRows ? ( + + ) : ( + <>{totalRows === 1 ? "Showing 1 repo" : `Showing ${totalRows} repos`} + )} + +
    - {isLoading && } +
    + + {/* TODO: Add sorting controls */} + + + Name + Repository + + Created + + + Prebuilds + + {/* Action column, loading status in header */} + + {isFetching && isPreviousData && ( +
    + {/* TODO: Make a LoadingIcon component */} + +
    + )} +
    +
    +
    + + {data?.configurations.map((configuration) => ( + + ))} + +
    -
      - {!isLoading && - data?.configurations.map((configuration) => ( - - ))} -
    + {totalPages > 1 && ( + + )} +
    {showCreateProjectModal && ( diff --git a/components/gitpod-db/src/project-db.spec.db.ts b/components/gitpod-db/src/project-db.spec.db.ts index 8c3be98e03da0d..e9cf3c3db1eea2 100644 --- a/components/gitpod-db/src/project-db.spec.db.ts +++ b/components/gitpod-db/src/project-db.spec.db.ts @@ -51,26 +51,32 @@ class ProjectDBSpec { }); const searchTerm = "rand"; const storedProject = await this.projectDb.storeProject(project); - const foundProject = await this.projectDb.findProjectsBySearchTerm(0, 10, "creationTime", "DESC", searchTerm); + const foundProject = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "creationTime", + orderDir: "DESC", + searchTerm, + }); expect(foundProject.rows[0].id).to.eq(storedProject.id); - const foundProjectByName = await this.projectDb.findProjectsBySearchTerm( - 0, - 10, - "creationTime", - "DESC", - "some-proj", - ); + const foundProjectByName = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "creationTime", + orderDir: "DESC", + searchTerm: "some-proj", + }); expect(foundProjectByName.rows[0].id).to.eq(storedProject.id); - const foundProjectEmptySearch = await this.projectDb.findProjectsBySearchTerm( - 0, - 10, - "creationTime", - "DESC", - " ", - ); + const foundProjectEmptySearch = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "creationTime", + orderDir: "DESC", + searchTerm: " ", + }); expect(foundProjectEmptySearch.rows[0].id).to.eq(storedProject.id); } @@ -121,7 +127,12 @@ class ProjectDBSpec { const storedProject4 = await this.projectDb.storeProject(project4); const storedProject5 = await this.projectDb.storeProject(project5); - const allResults = await this.projectDb.findProjectsBySearchTerm(0, 10, "name", "ASC", ""); + const allResults = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "name", + orderDir: "ASC", + }); expect(allResults.total).equals(5); expect(allResults.rows.length).equal(5); expect(allResults.rows[0].id).to.eq(storedProject1.id); @@ -131,19 +142,85 @@ class ProjectDBSpec { expect(allResults.rows[4].id).to.eq(storedProject5.id); const pageSize = 3; - const page1 = await this.projectDb.findProjectsBySearchTerm(0, pageSize, "name", "ASC", ""); + const page1 = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: pageSize, + orderBy: "name", + orderDir: "ASC", + }); expect(page1.total).equals(5); expect(page1.rows.length).equal(3); expect(page1.rows[0].id).to.eq(storedProject1.id); expect(page1.rows[1].id).to.eq(storedProject2.id); expect(page1.rows[2].id).to.eq(storedProject3.id); - const page2 = await this.projectDb.findProjectsBySearchTerm(pageSize * 1, pageSize, "name", "ASC", ""); + const page2 = await this.projectDb.findProjectsBySearchTerm({ + offset: pageSize * 1, + limit: pageSize, + orderBy: "name", + orderDir: "ASC", + }); expect(page2.total).equals(5); expect(page2.rows.length).equal(2); expect(page2.rows[0].id).to.eq(storedProject4.id); expect(page2.rows[1].id).to.eq(storedProject5.id); } + + @test() + public async findProjectBySearchTermOrganizationId() { + const user = await this.userDb.newUser(); + user.identities.push({ + authProviderId: "GitHub", + authId: "1234", + authName: "newUser", + primaryEmail: "newuser@git.com", + }); + await this.userDb.storeUser(user); + + const project1 = Project.create({ + name: "some-project", + cloneUrl: "some-random-clone-url", + teamId: "team-1", + appInstallationId: "", + }); + const project2 = Project.create({ + name: "some-project-2", + cloneUrl: "some-random-clone-url-2", + teamId: "team-2", + appInstallationId: "", + }); + const storedProject1 = await this.projectDb.storeProject(project1); + const storedProject2 = await this.projectDb.storeProject(project2); + + const team1Results = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "name", + orderDir: "ASC", + organizationId: "team-1", + }); + expect(team1Results.total).equals(1); + expect(team1Results.rows[0].id).to.eq(storedProject1.id); + + const team2Results = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "name", + orderDir: "ASC", + organizationId: "team-2", + }); + expect(team2Results.total).equals(1); + expect(team2Results.rows[0].id).to.eq(storedProject2.id); + + const noResults = await this.projectDb.findProjectsBySearchTerm({ + offset: 0, + limit: 10, + orderBy: "name", + orderDir: "ASC", + organizationId: "does-not-exist", + }); + expect(noResults.total).equals(0); + } } module.exports = new ProjectDBSpec(); diff --git a/components/gitpod-db/src/project-db.ts b/components/gitpod-db/src/project-db.ts index 22323b18d17dd9..d38e4721ada5b3 100644 --- a/components/gitpod-db/src/project-db.ts +++ b/components/gitpod-db/src/project-db.ts @@ -12,13 +12,7 @@ export interface ProjectDB extends TransactionalDB { findProjectById(projectId: string): Promise; findProjectsByCloneUrl(cloneUrl: string): Promise; findProjects(orgID: string): Promise; - findProjectsBySearchTerm( - offset: number, - limit: number, - orderBy: keyof Project, - orderDir: "ASC" | "DESC", - searchTerm: string, - ): Promise<{ total: number; rows: Project[] }>; + findProjectsBySearchTerm(args: FindProjectsBySearchTermArgs): Promise<{ total: number; rows: Project[] }>; storeProject(project: Project): Promise; updateProject(partialProject: PartialProject): Promise; markDeleted(projectId: string): Promise; @@ -37,3 +31,12 @@ export interface ProjectDB extends TransactionalDB { getProjectUsage(projectId: string): Promise; updateProjectUsage(projectId: string, usage: Partial): Promise; } + +export type FindProjectsBySearchTermArgs = { + offset: number; + limit: number; + orderBy: keyof Project; + orderDir: "ASC" | "DESC"; + searchTerm?: string; + organizationId?: string; +}; diff --git a/components/gitpod-db/src/typeorm/project-db-impl.ts b/components/gitpod-db/src/typeorm/project-db-impl.ts index 889d84d664cfb6..16262207102d65 100644 --- a/components/gitpod-db/src/typeorm/project-db-impl.ts +++ b/components/gitpod-db/src/typeorm/project-db-impl.ts @@ -9,7 +9,7 @@ import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryp import { inject, injectable, optional } from "inversify"; import { Brackets, EntityManager, FindConditions, Repository } from "typeorm"; import { v4 as uuidv4 } from "uuid"; -import { ProjectDB } from "../project-db"; +import { ProjectDB, FindProjectsBySearchTermArgs } from "../project-db"; import { DBProject } from "./entity/db-project"; import { DBProjectEnvVar } from "./entity/db-project-env-vars"; import { DBProjectInfo } from "./entity/db-project-info"; @@ -69,13 +69,14 @@ export class ProjectDBImpl extends TransactionalDBImpl implements Pro return repo.find({ where: { teamId: orgId, markedDeleted: false }, order: { name: "ASC" } }); } - public async findProjectsBySearchTerm( - offset: number, - limit: number, - orderBy: keyof Project, - orderDir: "DESC" | "ASC", - searchTerm?: string, - ): Promise<{ total: number; rows: Project[] }> { + public async findProjectsBySearchTerm({ + offset, + limit, + orderBy, + orderDir, + searchTerm, + organizationId, + }: FindProjectsBySearchTermArgs): Promise<{ total: number; rows: Project[] }> { const projectRepo = await this.getRepo(); const normalizedSearchTerm = searchTerm?.trim(); @@ -86,6 +87,10 @@ export class ProjectDBImpl extends TransactionalDBImpl implements Pro .take(limit) .orderBy(orderBy, orderDir); + if (organizationId) { + queryBuilder.andWhere("project.teamId = :organizationId", { organizationId }); + } + if (normalizedSearchTerm) { queryBuilder.andWhere( new Brackets((qb) => { diff --git a/components/gitpod-protocol/src/public-api-converter.spec.ts b/components/gitpod-protocol/src/public-api-converter.spec.ts index fc1a52d1b6335c..5cf57f242391e7 100644 --- a/components/gitpod-protocol/src/public-api-converter.spec.ts +++ b/components/gitpod-protocol/src/public-api-converter.spec.ts @@ -666,6 +666,7 @@ describe("PublicAPIConverter", () => { expect(result.organizationId).to.equal(project.teamId); expect(result.name).to.equal(project.name); expect(result.cloneUrl).to.equal(project.cloneUrl); + expect(result.creationTime).to.deep.equal(Timestamp.fromDate(new Date(project.creationTime))); expect(result.workspaceSettings).to.deep.equal( new WorkspaceSettings({ workspaceClass: project.settings?.workspaceClasses?.regular, diff --git a/components/gitpod-protocol/src/public-api-converter.ts b/components/gitpod-protocol/src/public-api-converter.ts index b205614b8bfa64..5e94f1217475a9 100644 --- a/components/gitpod-protocol/src/public-api-converter.ts +++ b/components/gitpod-protocol/src/public-api-converter.ts @@ -403,6 +403,7 @@ export class PublicAPIConverter { result.organizationId = project.teamId; result.name = project.name; result.cloneUrl = project.cloneUrl; + result.creationTime = Timestamp.fromDate(new Date(project.creationTime)); result.workspaceSettings = this.toWorkspaceSettings(project.settings?.workspaceClasses?.regular); result.prebuildSettings = this.toPrebuildSettings(project.settings?.prebuilds); return result; diff --git a/components/server/src/api/configuration-service-api.ts b/components/server/src/api/configuration-service-api.ts index 6d98b59b271e69..09457e15106612 100644 --- a/components/server/src/api/configuration-service-api.ts +++ b/components/server/src/api/configuration-service-api.ts @@ -78,9 +78,11 @@ export class ConfigurationServiceAPI implements ServiceImpl 1 ? (currentPage - 1) * limit : 0; const { rows, total } = await this.projectService.findProjects(context.user.id, { + organizationId: req.organizationId, searchTerm: req.searchTerm, orderBy: "name", orderDir: "ASC", @@ -90,6 +92,7 @@ export class ConfigurationServiceAPI implements ServiceImpl this.apiConverter.toConfiguration(project)), + // TODO: add additional pagination metadata to response pagination: new PaginationResponse({ total, }), diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts index bd4f52c0b8feb8..227a842548e425 100644 --- a/components/server/src/projects/projects-service.ts +++ b/components/server/src/projects/projects-service.ts @@ -68,15 +68,24 @@ export class ProjectsService { orderBy?: keyof Project; orderDir?: "ASC" | "DESC"; searchTerm?: string; + organizationId?: string; }, ): Promise<{ total: number; rows: Project[] }> { - const projects = await this.projectDB.findProjectsBySearchTerm( - searchOptions.offset || 0, - searchOptions.limit || 1000, - searchOptions.orderBy || "creationTime", - searchOptions.orderDir || "ASC", - searchOptions.searchTerm || "", - ); + if (searchOptions.organizationId) { + await this.auth.checkPermissionOnOrganization(userId, "read_info", searchOptions.organizationId); + } else { + // If no org is provided need to check that user has installation admin scope + } + + const projects = await this.projectDB.findProjectsBySearchTerm({ + offset: searchOptions.offset || 0, + limit: searchOptions.limit || 1000, + orderBy: searchOptions.orderBy || "creationTime", + orderDir: searchOptions.orderDir || "ASC", + searchTerm: searchOptions.searchTerm || "", + organizationId: searchOptions.organizationId, + }); + // TODO: adjust this to not filter entities, but log errors if any are not accessible for current user const rows = await this.filterByReadAccess(userId, projects.rows); const total = projects.total; return {