diff --git a/site/.eslintrc.yaml b/site/.eslintrc.yaml index c467e18627595..6be87c5c80f52 100644 --- a/site/.eslintrc.yaml +++ b/site/.eslintrc.yaml @@ -37,7 +37,6 @@ rules: ["error", "1tbs", { "allowSingleLine": false }] "@typescript-eslint/camelcase": "off" "@typescript-eslint/explicit-function-return-type": "off" - "@typescript-eslint/explicit-module-boundary-types": "error" "@typescript-eslint/method-signature-style": ["error", "property"] "@typescript-eslint/no-floating-promises": error "@typescript-eslint/no-invalid-void-type": error diff --git a/site/package.json b/site/package.json index e0cd2d23f42ef..111802a467a3e 100644 --- a/site/package.json +++ b/site/package.json @@ -37,6 +37,7 @@ "@material-ui/icons": "4.5.1", "@material-ui/lab": "4.0.0-alpha.42", "@monaco-editor/react": "4.4.6", + "@tanstack/react-query": "4.22.4", "@testing-library/react-hooks": "8.0.1", "@types/color-convert": "2.0.0", "@types/react-color": "3.0.6", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a02683bece396..885e701a644b3 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -779,3 +779,10 @@ export const getTemplateVersionLogs = async ( ) return response.data } + +export const updateWorkspaceVersion = async ( + workspace: TypesGen.Workspace, +): Promise => { + const template = await getTemplate(workspace.template_id) + return startWorkspace(workspace.id, template.active_version_id) +} diff --git a/site/src/app.tsx b/site/src/app.tsx index 5dbc2d5e7fcd5..4b47895aa4bff 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -1,7 +1,8 @@ import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { AuthProvider } from "components/AuthProvider/AuthProvider" -import { FC } from "react" +import { FC, PropsWithChildren } from "react" import { HelmetProvider } from "react-helmet-async" import { AppRouter } from "./AppRouter" import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary" @@ -9,18 +10,37 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar" import { dark } from "./theme" import "./theme/globalFonts" -export const App: FC = () => { +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}) + +export const AppProviders: FC = ({ children }) => { return ( - - - - + + + {children} + + + ) } + +export const App: FC = () => { + return ( + + + + ) +} diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx new file mode 100644 index 0000000000000..14fbd36350a66 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -0,0 +1,104 @@ +import Button from "@material-ui/core/Button" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import useMediaQuery from "@material-ui/core/useMediaQuery" +import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" +import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { PageButton } from "./PageButton" +import { buildPagedList } from "./utils" + +export type PaginationWidgetBaseProps = { + count: number + page: number + limit: number + onChange: (page: number) => void +} + +export const PaginationWidgetBase = ({ + count, + page, + limit, + onChange, +}: PaginationWidgetBaseProps): JSX.Element | null => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) + const styles = useStyles() + const numPages = Math.ceil(count / limit) + const isFirstPage = page === 0 + const isLastPage = page === numPages - 1 + + if (numPages < 2) { + return null + } + + return ( +
+ + + + + + + {buildPagedList(numPages, page).map((pageItem) => { + if (pageItem === "left" || pageItem === "right") { + return ( + + ) + } + + return ( + onChange(pageItem)} + /> + ) + })} + + + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + defaultContainerStyles: { + justifyContent: "center", + alignItems: "center", + display: "flex", + flexDirection: "row", + padding: "20px", + }, + + prevLabelStyles: { + marginRight: `${theme.spacing(0.5)}px`, + }, +})) diff --git a/site/src/components/PaginationWidget/utils.ts b/site/src/components/PaginationWidget/utils.ts index a2cd41880ef2c..db25da9b15f8e 100644 --- a/site/src/components/PaginationWidget/utils.ts +++ b/site/src/components/PaginationWidget/utils.ts @@ -30,7 +30,7 @@ const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2 export const buildPagedList = ( numPages: number, activePage: number, -): (string | number)[] => { +): ("left" | "right" | number)[] => { if (numPages > NUM_PAGE_BLOCKS) { let pages = [] const leftBound = activePage - PAGE_NEIGHBORS @@ -44,8 +44,8 @@ export const buildPagedList = ( const singleSpillOffset = PAGES_TO_DISPLAY - pages.length - 1 const hasLeftOverflow = startPage > 2 const hasRightOverflow = endPage < beforeLastPage - const leftOverflowPage = "left" - const rightOverflowPage = "right" + const leftOverflowPage = "left" as const + const rightOverflowPage = "right" as const if (hasLeftOverflow && !hasRightOverflow) { const extraPages = range(startPage - singleSpillOffset, startPage - 1) diff --git a/site/src/components/WorkspacesTable/WorkspacesRow.tsx b/site/src/components/WorkspacesTable/WorkspacesRow.tsx index 366f1f64f1e77..d2fa9960eb85a 100644 --- a/site/src/components/WorkspacesTable/WorkspacesRow.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesRow.tsx @@ -2,24 +2,22 @@ import TableCell from "@material-ui/core/TableCell" import { makeStyles } from "@material-ui/core/styles" import TableRow from "@material-ui/core/TableRow" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" -import { useActor } from "@xstate/react" import { AvatarData } from "components/AvatarData/AvatarData" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import { useClickable } from "hooks/useClickable" import { FC } from "react" import { useNavigate } from "react-router-dom" import { getDisplayWorkspaceTemplateName } from "util/workspace" -import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService" import { LastUsed } from "../LastUsed/LastUsed" -import { OutdatedHelpTooltip } from "../Tooltips" +import { Workspace } from "api/typesGenerated" +import { OutdatedHelpTooltip } from "components/Tooltips/OutdatedHelpTooltip" -export const WorkspacesRow: FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ - workspaceRef, -}) => { +export const WorkspacesRow: FC<{ + workspace: Workspace + onUpdateWorkspace: (workspace: Workspace) => void +}> = ({ workspace, onUpdateWorkspace }) => { const styles = useStyles() const navigate = useNavigate() - const [workspaceState, send] = useActor(workspaceRef) - const { data: workspace } = workspaceState.context const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}` const hasTemplateIcon = workspace.template_icon && workspace.template_icon !== "" @@ -58,7 +56,7 @@ export const WorkspacesRow: FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ {workspace.outdated && ( { - send("UPDATE_VERSION") + onUpdateWorkspace(workspace) }} /> )} diff --git a/site/src/components/WorkspacesTable/WorkspacesTable.tsx b/site/src/components/WorkspacesTable/WorkspacesTable.tsx index b20ef63b7b46f..5ae9ccc8a5784 100644 --- a/site/src/components/WorkspacesTable/WorkspacesTable.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesTable.tsx @@ -4,8 +4,8 @@ import TableCell from "@material-ui/core/TableCell" import TableContainer from "@material-ui/core/TableContainer" import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" +import { Workspace } from "api/typesGenerated" import { FC } from "react" -import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService" import { WorkspacesTableBody } from "./WorkspacesTableBody" const Language = { @@ -18,15 +18,16 @@ const Language = { } export interface WorkspacesTableProps { - isLoading?: boolean - workspaceRefs?: WorkspaceItemMachineRef[] - filter?: string - isNonInitialPage: boolean + workspaces?: Workspace[] + isUsingFilter: boolean + onUpdateWorkspace: (workspace: Workspace) => void } -export const WorkspacesTable: FC< - React.PropsWithChildren -> = ({ isLoading, workspaceRefs, filter, isNonInitialPage }) => { +export const WorkspacesTable: FC = ({ + workspaces, + isUsingFilter, + onUpdateWorkspace, +}) => { return ( @@ -42,10 +43,9 @@ export const WorkspacesTable: FC<
diff --git a/site/src/components/WorkspacesTable/WorkspacesTableBody.tsx b/site/src/components/WorkspacesTable/WorkspacesTableBody.tsx index db41fb3bc078b..ed93e439c4551 100644 --- a/site/src/components/WorkspacesTable/WorkspacesTableBody.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesTableBody.tsx @@ -1,98 +1,79 @@ import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" -import TableCell from "@material-ui/core/TableCell" -import TableRow from "@material-ui/core/TableRow" import AddOutlined from "@material-ui/icons/AddOutlined" +import { Workspace } from "api/typesGenerated" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { TableEmpty } from "components/TableEmpty/TableEmpty" import { FC } from "react" import { useTranslation } from "react-i18next" import { Link as RouterLink } from "react-router-dom" -import { workspaceFilterQuery } from "../../util/filters" -import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService" -import { EmptyState } from "../EmptyState/EmptyState" import { TableLoader } from "../TableLoader/TableLoader" import { WorkspacesRow } from "./WorkspacesRow" interface TableBodyProps { - isLoading?: boolean - workspaceRefs?: WorkspaceItemMachineRef[] - filter?: string - isNonInitialPage: boolean + workspaces?: Workspace[] + isUsingFilter: boolean + onUpdateWorkspace: (workspace: Workspace) => void } export const WorkspacesTableBody: FC< React.PropsWithChildren -> = ({ isLoading, workspaceRefs, filter, isNonInitialPage }) => { +> = ({ workspaces, isUsingFilter, onUpdateWorkspace }) => { const { t } = useTranslation("workspacesPage") const styles = useStyles() + if (!workspaces) { + return + } + + if (workspaces.length === 0) { + return ( + + + + + + + + + + } + image={ +
+ +
+ } + /> +
+
+ ) + } + return ( - - - - - - - - - - - - - - - - } - image={ -
- -
- } - /> -
- - - -
-
-
-
- - {workspaceRefs && - workspaceRefs.map((workspaceRef) => ( - - ))} - -
+ <> + {workspaces.map((workspace) => ( + + ))} + ) } const useStyles = makeStyles((theme) => ({ - emptyTableCell: { - padding: "0 !important", - }, - - empty: { + withImage: { paddingBottom: 0, }, - emptyImage: { maxWidth: "50%", height: theme.spacing(34), diff --git a/site/src/hooks/useFilter.ts b/site/src/hooks/useFilter.ts new file mode 100644 index 0000000000000..823c1a22d69d7 --- /dev/null +++ b/site/src/hooks/useFilter.ts @@ -0,0 +1,21 @@ +import { useSearchParams } from "react-router-dom" + +type UseFilterResult = { + query: string + setFilter: (query: string) => void +} + +export const useFilter = (defaultValue: string): UseFilterResult => { + const [searchParams, setSearchParams] = useSearchParams() + const query = searchParams.get("filter") ?? defaultValue + + const setFilter = (query: string) => { + searchParams.set("filter", query) + setSearchParams(searchParams) + } + + return { + query, + setFilter, + } +} diff --git a/site/src/hooks/usePagination.ts b/site/src/hooks/usePagination.ts new file mode 100644 index 0000000000000..22e2099718613 --- /dev/null +++ b/site/src/hooks/usePagination.ts @@ -0,0 +1,25 @@ +import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils" +import { useSearchParams } from "react-router-dom" + +type UsePaginationResult = { + page: number + limit: number + goToPage: (page: number) => void +} + +export const usePagination = (): UsePaginationResult => { + const [searchParams, setSearchParams] = useSearchParams() + const page = searchParams.get("page") ? Number(searchParams.get("page")) : 0 + const limit = DEFAULT_RECORDS_PER_PAGE + + const goToPage = (page: number) => { + searchParams.set("page", page.toString()) + setSearchParams(searchParams) + } + + return { + page, + limit, + goToPage, + } +} diff --git a/site/src/i18n/en/workspacesPage.json b/site/src/i18n/en/workspacesPage.json index 1bcb1b6f77c1b..cd9a698bf3ad1 100644 --- a/site/src/i18n/en/workspacesPage.json +++ b/site/src/i18n/en/workspacesPage.json @@ -1,7 +1,8 @@ { "emptyCreateWorkspaceMessage": "Create your first workspace", - "emptyCreateWorkspaceDescription": "Start editing your source code and building your software.", + "emptyCreateWorkspaceDescription": "Start editing your source code and building your software", "createFromTemplateButton": "Create from template", "emptyResultsMessage": "No results matched your search", - "emptyPageMessage": "No results on this page" + "emptyPageMessage": "No results on this page", + "updateVersionError": "Error on update workspace version" } diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 9adf8a79db8ad..9a52ac71fcda2 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, screen, waitFor } from "@testing-library/react" import * as API from "../../../api/api" -import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar" import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm" import { renderWithAuth } from "../../../testHelpers/renderHelpers" import * as AuthXService from "../../../xServices/auth/authXService" @@ -10,12 +9,7 @@ import i18next from "i18next" const { t } = i18next const renderPage = () => { - return renderWithAuth( - <> - - - , - ) + return renderWithAuth() } const newData = { diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx index 9114f5d6defc0..9d99bcc9849df 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, screen, within } from "@testing-library/react" import * as API from "../../../api/api" -import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar" import { MockGitSSHKey, renderWithAuth, @@ -20,12 +19,7 @@ describe("SSH keys Page", () => { describe("regenerate SSH key", () => { describe("when it is success", () => { it("shows a success message and updates the ssh key on the page", async () => { - renderWithAuth( - <> - - - , - ) + renderWithAuth() // Wait to the ssh be rendered on the screen await screen.findByText(MockGitSSHKey.public_key) @@ -69,12 +63,7 @@ describe("SSH keys Page", () => { describe("when it fails", () => { it("shows an error message", async () => { - renderWithAuth( - <> - - - , - ) + renderWithAuth() // Wait to the ssh be rendered on the screen await screen.findByText(MockGitSSHKey.public_key) diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx index b61f44500fb59..89b3dac89e428 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, screen, waitFor } from "@testing-library/react" import * as API from "../../../api/api" -import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar" import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm" import { renderWithAuth } from "../../../testHelpers/renderHelpers" import { SecurityPage } from "./SecurityPage" @@ -9,12 +8,7 @@ import i18next from "i18next" const { t } = i18next const renderPage = () => { - return renderWithAuth( - <> - - - , - ) + return renderWithAuth() } const newData = { diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 12051f02ab7c2..4f8c1acb53050 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -6,7 +6,6 @@ import { Language as usersXServiceLanguage } from "xServices/users/usersXService import * as API from "../../api/api" import { Role } from "../../api/typesGenerated" import { Language as ResetPasswordDialogLanguage } from "../../components/Dialogs/ResetPasswordDialog/ResetPasswordDialog" -import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar" import { MockAuditorRole, MockOwnerRole, @@ -21,12 +20,7 @@ import { Language as UsersPageLanguage, UsersPage } from "./UsersPage" const { t } = i18n const renderPage = () => { - return renderWithAuth( - <> - - - , - ) + return renderWithAuth() } const suspendUser = async (setupActionSpies: () => void) => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 6a9e1197ac58d..33e894d03b4c9 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,35 +1,20 @@ -import { useMachine } from "@xstate/react" -import { - getPaginationContext, - nonInitialPage, -} from "components/PaginationWidget/utils" +import { useFilter } from "hooks/useFilter" +import { usePagination } from "hooks/usePagination" import { FC } from "react" import { Helmet } from "react-helmet-async" -import { useSearchParams } from "react-router-dom" import { workspaceFilterQuery } from "util/filters" import { pageTitle } from "util/page" -import { PaginationMachineRef } from "xServices/pagination/paginationXService" -import { workspacesMachine } from "xServices/workspaces/workspacesXService" +import { useWorkspacesData, useWorkspaceUpdate } from "./data" import { WorkspacesPageView } from "./WorkspacesPageView" const WorkspacesPage: FC = () => { - const [searchParams, setSearchParams] = useSearchParams() - const filter = searchParams.get("filter") ?? workspaceFilterQuery.me - const [workspacesState, send] = useMachine(workspacesMachine, { - context: { - filter, - paginationContext: getPaginationContext(searchParams), - }, - actions: { - // Filter updates always cause page updates (to page 1), so only UPDATE_PAGE triggers updateURL - updateURL: (context, event) => - setSearchParams({ page: event.page, filter: context.filter }), - }, + const filter = useFilter(workspaceFilterQuery.me) + const pagination = usePagination() + const { data, error, queryKey } = useWorkspacesData({ + ...pagination, + ...filter, }) - - const { workspaceRefs, count, getWorkspacesError } = workspacesState.context - const paginationRef = workspacesState.context - .paginationRef as PaginationMachineRef + const updateWorkspace = useWorkspaceUpdate(queryKey) return ( <> @@ -38,19 +23,17 @@ const WorkspacesPage: FC = () => { { - send({ - type: "UPDATE_FILTER", - query, - }) + workspaces={data?.workspaces} + error={error} + filter={filter.query} + onFilter={filter.setFilter} + count={data?.count} + page={pagination.page} + limit={pagination.limit} + onPageChange={pagination.goToPage} + onUpdateWorkspace={(workspace) => { + updateWorkspace.mutate(workspace) }} - paginationRef={paginationRef} - isNonInitialPage={nonInitialPage(searchParams)} /> ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 18dbc66405a51..3c874548a27da 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -1,102 +1,66 @@ import { ComponentMeta, Story } from "@storybook/react" -import { createPaginationRef } from "components/PaginationWidget/utils" +import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils" import dayjs from "dayjs" -import { spawn } from "xstate" +import uniqueId from "lodash/uniqueId" import { - ProvisionerJobStatus, - WorkspaceTransition, + Workspace, + WorkspaceStatus, + WorkspaceStatuses, } from "../../api/typesGenerated" import { MockWorkspace } from "../../testHelpers/entities" import { workspaceFilterQuery } from "../../util/filters" -import { - workspaceItemMachine, - WorkspaceItemMachineRef, -} from "../../xServices/workspaces/workspacesXService" import { WorkspacesPageView, WorkspacesPageViewProps, } from "./WorkspacesPageView" -const createWorkspaceItemRef = ( - status: ProvisionerJobStatus, - transition: WorkspaceTransition = "start", +const createWorkspace = ( + status: WorkspaceStatus, outdated = false, lastUsedAt = "0001-01-01", -): WorkspaceItemMachineRef => { - return spawn( - workspaceItemMachine.withContext({ - data: { - ...MockWorkspace, - outdated, - latest_build: { - ...MockWorkspace.latest_build, - transition, - job: { - ...MockWorkspace.latest_build.job, - status: status, - }, - }, - last_used_at: lastUsedAt, - }, - }), - ) +): Workspace => { + return { + ...MockWorkspace, + id: uniqueId("workspace"), + outdated, + latest_build: { + ...MockWorkspace.latest_build, + status, + }, + last_used_at: lastUsedAt, + } } // This is type restricted to prevent future statuses from slipping // through the cracks unchecked! -const workspaces: { [key in ProvisionerJobStatus]: WorkspaceItemMachineRef } = { - canceled: createWorkspaceItemRef("canceled"), - canceling: createWorkspaceItemRef("canceling"), - failed: createWorkspaceItemRef("failed"), - pending: createWorkspaceItemRef("pending"), - running: createWorkspaceItemRef("running"), - succeeded: createWorkspaceItemRef("succeeded"), -} +const workspaces = WorkspaceStatuses.map((status) => createWorkspace(status)) -const additionalWorkspaces: Record = { - runningAndStop: createWorkspaceItemRef("running", "stop"), - succeededAndStop: createWorkspaceItemRef("succeeded", "stop"), - runningAndDelete: createWorkspaceItemRef("running", "delete"), - outdated: createWorkspaceItemRef("running", "delete", true), - active: createWorkspaceItemRef( - "running", - undefined, - true, - dayjs().toString(), - ), - today: createWorkspaceItemRef( +// Additional Workspaces depending on time +const additionalWorkspaces: Record = { + today: createWorkspace( "running", - undefined, true, dayjs().subtract(3, "hour").toString(), ), - old: createWorkspaceItemRef( + old: createWorkspace("running", true, dayjs().subtract(1, "week").toString()), + veryOld: createWorkspace( "running", - undefined, - true, - dayjs().subtract(1, "week").toString(), - ), - veryOld: createWorkspaceItemRef( - "running", - undefined, true, dayjs().subtract(1, "month").subtract(4, "day").toString(), ), } +const allWorkspaces = [ + ...Object.values(workspaces), + ...Object.values(additionalWorkspaces), +] + export default { title: "pages/WorkspacesPageView", component: WorkspacesPageView, argTypes: { - paginationRef: { - defaultValue: createPaginationRef({ page: 1, limit: 25 }), - }, - workspaceRefs: { - options: [ - ...Object.keys(workspaces), - ...Object.keys(additionalWorkspaces), - ], - mapping: { ...workspaces, ...additionalWorkspaces }, + limit: { + defaultValue: DEFAULT_RECORDS_PER_PAGE, }, }, } as ComponentMeta @@ -107,34 +71,20 @@ const Template: Story = (args) => ( export const AllStates = Template.bind({}) AllStates.args = { - workspaceRefs: [ - ...Object.values(workspaces), - ...Object.values(additionalWorkspaces), - ], - count: 14, - isNonInitialPage: false, + workspaces: allWorkspaces, + count: allWorkspaces.length, } export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { - workspaceRefs: [], + workspaces: [], filter: workspaceFilterQuery.me, count: 0, - isNonInitialPage: false, } -export const NoResults = Template.bind({}) -NoResults.args = { - workspaceRefs: [], +export const NoSearchResults = Template.bind({}) +NoSearchResults.args = { + workspaces: [], filter: "searchtearmwithnoresults", count: 0, - isNonInitialPage: false, -} - -export const EmptyPage = Template.bind({}) -EmptyPage.args = { - workspaceRefs: [], - filter: workspaceFilterQuery.me, - count: 0, - isNonInitialPage: true, } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 7532c3aa4d816..a7d25e263b6f9 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,10 +1,10 @@ import Link from "@material-ui/core/Link" +import { Workspace } from "api/typesGenerated" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" -import { PaginationWidget } from "components/PaginationWidget/PaginationWidget" +import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" -import { PaginationMachineRef } from "xServices/pagination/paginationXService" import { Margins } from "../../components/Margins/Margins" import { PageHeader, @@ -16,7 +16,6 @@ import { Stack } from "../../components/Stack/Stack" import { WorkspaceHelpTooltip } from "../../components/Tooltips" import { WorkspacesTable } from "../../components/WorkspacesTable/WorkspacesTable" import { workspaceFilterQuery } from "../../util/filters" -import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService" export const Language = { pageTitle: "Workspaces", @@ -28,27 +27,29 @@ export const Language = { } export interface WorkspacesPageViewProps { - isLoading?: boolean - workspaceRefs?: WorkspaceItemMachineRef[] + error: unknown + workspaces?: Workspace[] count?: number - getWorkspacesError: Error | unknown - filter?: string + page: number + limit: number + filter: string + onPageChange: (page: number) => void onFilter: (query: string) => void - paginationRef: PaginationMachineRef - isNonInitialPage: boolean + onUpdateWorkspace: (workspace: Workspace) => void } export const WorkspacesPageView: FC< React.PropsWithChildren > = ({ - isLoading, - workspaceRefs, - count, - getWorkspacesError, + workspaces, + error, filter, + page, + limit, + count, onFilter, - paginationRef, - isNonInitialPage, + onPageChange, + onUpdateWorkspace, }) => { const presetFilters = [ { query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton }, @@ -79,11 +80,11 @@ export const WorkspacesPageView: FC< - + 0 + workspaces !== undefined && workspaces.length > 0 ? "warning" : "error" } @@ -96,15 +97,19 @@ export const WorkspacesPageView: FC< presetFilters={presetFilters} /> - - - + {count !== undefined && ( + + )} ) } diff --git a/site/src/pages/WorkspacesPage/data.ts b/site/src/pages/WorkspacesPage/data.ts new file mode 100644 index 0000000000000..d46ccf1b8f8d3 --- /dev/null +++ b/site/src/pages/WorkspacesPage/data.ts @@ -0,0 +1,117 @@ +import { + QueryKey, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" +import { getWorkspaces, updateWorkspaceVersion } from "api/api" +import { getErrorMessage } from "api/errors" +import { + Workspace, + WorkspaceBuild, + WorkspacesResponse, +} from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { useTranslation } from "react-i18next" + +type UseWorkspacesDataParams = { + page: number + limit: number + query: string +} + +export const useWorkspacesData = ({ + page, + limit, + query, +}: UseWorkspacesDataParams) => { + const queryKey = ["workspaces", query, page] + const result = useQuery({ + queryKey, + queryFn: () => + getWorkspaces({ + q: query, + limit: limit, + offset: page, + }), + refetchInterval: 5_000, + }) + + return { + ...result, + queryKey, + } +} + +export const useWorkspaceUpdate = (queryKey: QueryKey) => { + const queryClient = useQueryClient() + const { t } = useTranslation("workspacesPage") + + return useMutation({ + mutationFn: updateWorkspaceVersion, + onMutate: async (workspace) => { + await queryClient.cancelQueries({ queryKey }) + queryClient.setQueryData(queryKey, (oldResponse) => { + if (oldResponse) { + return assignPendingStatus(oldResponse, workspace) + } + }) + }, + onSuccess: (workspaceBuild) => { + queryClient.setQueryData(queryKey, (oldResponse) => { + if (oldResponse) { + return assignLatestBuild(oldResponse, workspaceBuild) + } + }) + }, + onError: (error) => { + const message = getErrorMessage(error, t("updateVersionError")) + displayError(message) + }, + }) +} + +const assignLatestBuild = ( + oldResponse: WorkspacesResponse, + build: WorkspaceBuild, +): WorkspacesResponse => { + return { + ...oldResponse, + workspaces: oldResponse.workspaces.map((workspace) => { + if (workspace.id === build.workspace_id) { + return { + ...workspace, + latest_build: build, + } + } + + return workspace + }), + } +} + +const assignPendingStatus = ( + oldResponse: WorkspacesResponse, + workspace: Workspace, +): WorkspacesResponse => { + return { + ...oldResponse, + workspaces: oldResponse.workspaces.map((workspaceItem) => { + if (workspaceItem.id === workspace.id) { + return { + ...workspace, + latest_build: { + ...workspace.latest_build, + status: "pending", + job: { + ...workspace.latest_build.job, + status: "pending", + }, + }, + } as Workspace + } + + return workspace + }), + } +} diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index c29f894230e0d..6190247f26cf9 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -1,16 +1,14 @@ -import ThemeProvider from "@material-ui/styles/ThemeProvider" import { render as wrappedRender, RenderResult, screen, waitForElementToBeRemoved, } from "@testing-library/react" -import { AuthProvider } from "components/AuthProvider/AuthProvider" +import { AppProviders } from "app" import { DashboardLayout } from "components/Dashboard/DashboardLayout" import { createMemoryHistory } from "history" import { i18n } from "i18n" import { FC, ReactElement } from "react" -import { HelmetProvider } from "react-helmet-async" import { I18nextProvider } from "react-i18next" import { MemoryRouter, @@ -19,7 +17,6 @@ import { unstable_HistoryRouter as HistoryRouter, } from "react-router-dom" import { RequireAuth } from "../components/RequireAuth/RequireAuth" -import { dark } from "../theme" import { MockUser } from "./entities" export const history = createMemoryHistory() @@ -28,13 +25,9 @@ export const WrapperComponent: FC> = ({ children, }) => { return ( - - - - {children} - - - + + {children} + ) } @@ -59,24 +52,20 @@ export function renderWithAuth( }: { route?: string; path?: string; routes?: JSX.Element } = {}, ): RenderWithAuthResult { const renderResult = wrappedRender( - - - - - - - }> - }> - - - - {routes} - - - - - - , + + + + + }> + }> + + + + {routes} + + + + , ) return { diff --git a/site/yarn.lock b/site/yarn.lock index 07f5d5fb6d07e..ef8582b8f284f 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -2897,6 +2897,19 @@ regenerator-runtime "^0.13.7" resolve-from "^5.0.0" +"@tanstack/query-core@4.22.4": + version "4.22.4" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.22.4.tgz#aca622d2f8800a147ece5520d956a076ab92f0ea" + integrity sha512-t79CMwlbBnj+yL82tEcmRN93bL4U3pae2ota4t5NN2z3cIeWw74pzdWrKRwOfTvLcd+b30tC+ciDlfYOKFPGUw== + +"@tanstack/react-query@4.22.4": + version "4.22.4" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.22.4.tgz#851581c645f1c9cfcd394448fedd980a39bbc3fe" + integrity sha512-e5j5Z88XUQGeEPMyz5XF1V0mMf6Da+6URXiTpZfUb9nuHs2nlNoA+EoIvnhccE5b9YT6Yg7kARhn2L7u94M/4A== + dependencies: + "@tanstack/query-core" "4.22.4" + use-sync-external-store "^1.2.0" + "@testing-library/dom@^8.5.0": version "8.19.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.0.tgz#bd3f83c217ebac16694329e413d9ad5fdcfd785f" @@ -13804,7 +13817,7 @@ use-isomorphic-layout-effect@^1.0.0: resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== -use-sync-external-store@^1.0.0: +use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==