chore(site): Use react-query and refactor the workspaces page to use it (#5838)

This commit is contained in:
Bruno Quaresma 2023-01-24 16:22:42 -03:00 committed by GitHub
parent bef9e72078
commit 36384aa3c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 499 additions and 314 deletions

View File

@ -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

View File

@ -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",

View File

@ -779,3 +779,10 @@ export const getTemplateVersionLogs = async (
)
return response.data
}
export const updateWorkspaceVersion = async (
workspace: TypesGen.Workspace,
): Promise<TypesGen.WorkspaceBuild> => {
const template = await getTemplate(workspace.template_id)
return startWorkspace(workspace.id, template.active_version_id)
}

View File

@ -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<PropsWithChildren> = ({ children }) => {
return (
<HelmetProvider>
<ThemeProvider theme={dark}>
<CssBaseline />
<ErrorBoundary>
<AuthProvider>
<AppRouter />
<GlobalSnackbar />
</AuthProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
<GlobalSnackbar />
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</ThemeProvider>
</HelmetProvider>
)
}
export const App: FC = () => {
return (
<AppProviders>
<AppRouter />
</AppProviders>
)
}

View File

@ -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 (
<div className={styles.defaultContainerStyles}>
<Button
className={styles.prevLabelStyles}
aria-label="Previous page"
disabled={isFirstPage}
onClick={() => {
if (!isFirstPage) {
onChange(page - 1)
}
}}
>
<KeyboardArrowLeft />
</Button>
<ChooseOne>
<Cond condition={isMobile}>
<PageButton activePage={page} page={page} numPages={numPages} />
</Cond>
<Cond>
{buildPagedList(numPages, page).map((pageItem) => {
if (pageItem === "left" || pageItem === "right") {
return (
<PageButton
key={pageItem}
activePage={page}
placeholder="..."
disabled
/>
)
}
return (
<PageButton
key={pageItem}
page={pageItem}
activePage={page}
numPages={numPages}
onPageClick={() => onChange(pageItem)}
/>
)
})}
</Cond>
</ChooseOne>
<Button
aria-label="Next page"
disabled={isLastPage}
onClick={() => {
if (!isLastPage) {
onChange(page + 1)
}
}}
>
<KeyboardArrowRight />
</Button>
</div>
)
}
const useStyles = makeStyles((theme) => ({
defaultContainerStyles: {
justifyContent: "center",
alignItems: "center",
display: "flex",
flexDirection: "row",
padding: "20px",
},
prevLabelStyles: {
marginRight: `${theme.spacing(0.5)}px`,
},
}))

View File

@ -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)

View File

@ -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 && (
<OutdatedHelpTooltip
onUpdateVersion={() => {
send("UPDATE_VERSION")
onUpdateWorkspace(workspace)
}}
/>
)}

View File

@ -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<WorkspacesTableProps>
> = ({ isLoading, workspaceRefs, filter, isNonInitialPage }) => {
export const WorkspacesTable: FC<WorkspacesTableProps> = ({
workspaces,
isUsingFilter,
onUpdateWorkspace,
}) => {
return (
<TableContainer>
<Table>
@ -42,10 +43,9 @@ export const WorkspacesTable: FC<
</TableHead>
<TableBody>
<WorkspacesTableBody
isLoading={isLoading}
workspaceRefs={workspaceRefs}
filter={filter}
isNonInitialPage={isNonInitialPage}
workspaces={workspaces}
isUsingFilter={isUsingFilter}
onUpdateWorkspace={onUpdateWorkspace}
/>
</TableBody>
</Table>

View File

@ -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<TableBodyProps>
> = ({ isLoading, workspaceRefs, filter, isNonInitialPage }) => {
> = ({ workspaces, isUsingFilter, onUpdateWorkspace }) => {
const { t } = useTranslation("workspacesPage")
const styles = useStyles()
if (!workspaces) {
return <TableLoader />
}
if (workspaces.length === 0) {
return (
<ChooseOne>
<Cond condition={isUsingFilter}>
<TableEmpty message={t("emptyResultsMessage")} />
</Cond>
<Cond>
<TableEmpty
className={styles.withImage}
message={t("emptyCreateWorkspaceMessage")}
description={t("emptyCreateWorkspaceDescription")}
cta={
<Link underline="none" component={RouterLink} to="/templates">
<Button startIcon={<AddOutlined />}>
{t("createFromTemplateButton")}
</Button>
</Link>
}
image={
<div className={styles.emptyImage}>
<img src="/featured/workspaces.webp" alt="" />
</div>
}
/>
</Cond>
</ChooseOne>
)
}
return (
<ChooseOne>
<Cond condition={Boolean(isLoading)}>
<TableLoader />
</Cond>
<Cond condition={!workspaceRefs || workspaceRefs.length === 0}>
<TableRow>
<TableCell colSpan={999} className={styles.emptyTableCell}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<EmptyState message={t("emptyPageMessage")} />
</Cond>
<Cond
condition={
filter === workspaceFilterQuery.me ||
filter === workspaceFilterQuery.all
}
>
<EmptyState
className={styles.empty}
message={t("emptyCreateWorkspaceMessage")}
description={t("emptyCreateWorkspaceDescription")}
cta={
<Link
underline="none"
component={RouterLink}
to="/templates"
>
<Button startIcon={<AddOutlined />}>
{t("createFromTemplateButton")}
</Button>
</Link>
}
image={
<div className={styles.emptyImage}>
<img src="/featured/workspaces.webp" alt="" />
</div>
}
/>
</Cond>
<Cond>
<EmptyState message={t("emptyResultsMessage")} />
</Cond>
</ChooseOne>
</TableCell>
</TableRow>
</Cond>
<Cond>
{workspaceRefs &&
workspaceRefs.map((workspaceRef) => (
<WorkspacesRow workspaceRef={workspaceRef} key={workspaceRef.id} />
))}
</Cond>
</ChooseOne>
<>
{workspaces.map((workspace) => (
<WorkspacesRow
workspace={workspace}
key={workspace.id}
onUpdateWorkspace={onUpdateWorkspace}
/>
))}
</>
)
}
const useStyles = makeStyles((theme) => ({
emptyTableCell: {
padding: "0 !important",
},
empty: {
withImage: {
paddingBottom: 0,
},
emptyImage: {
maxWidth: "50%",
height: theme.spacing(34),

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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"
}

View File

@ -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(
<>
<AccountPage />
<GlobalSnackbar />
</>,
)
return renderWithAuth(<AccountPage />)
}
const newData = {

View File

@ -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(
<>
<SSHKeysPage />
<GlobalSnackbar />
</>,
)
renderWithAuth(<SSHKeysPage />)
// 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(
<>
<SSHKeysPage />
<GlobalSnackbar />
</>,
)
renderWithAuth(<SSHKeysPage />)
// Wait to the ssh be rendered on the screen
await screen.findByText(MockGitSSHKey.public_key)

View File

@ -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(
<>
<SecurityPage />
<GlobalSnackbar />
</>,
)
return renderWithAuth(<SecurityPage />)
}
const newData = {

View File

@ -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(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)
return renderWithAuth(<UsersPage />)
}
const suspendUser = async (setupActionSpies: () => void) => {

View File

@ -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 = () => {
</Helmet>
<WorkspacesPageView
filter={workspacesState.context.filter}
isLoading={!workspaceRefs}
workspaceRefs={workspaceRefs}
count={count}
getWorkspacesError={getWorkspacesError}
onFilter={(query) => {
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)}
/>
</>
)

View File

@ -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<string, WorkspaceItemMachineRef> = {
runningAndStop: createWorkspaceItemRef("running", "stop"),
succeededAndStop: createWorkspaceItemRef("succeeded", "stop"),
runningAndDelete: createWorkspaceItemRef("running", "delete"),
outdated: createWorkspaceItemRef("running", "delete", true),
active: createWorkspaceItemRef(
// Additional Workspaces depending on time
const additionalWorkspaces: Record<string, Workspace> = {
today: createWorkspace(
"running",
undefined,
true,
dayjs().toString(),
),
today: createWorkspaceItemRef(
"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<typeof WorkspacesPageView>
@ -107,34 +71,20 @@ const Template: Story<WorkspacesPageViewProps> = (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,
}

View File

@ -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<WorkspacesPageViewProps>
> = ({
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<
</PageHeader>
<Stack>
<Maybe condition={getWorkspacesError !== undefined}>
<Maybe condition={Boolean(error)}>
<AlertBanner
error={getWorkspacesError}
error={error}
severity={
workspaceRefs !== undefined && workspaceRefs.length > 0
workspaces !== undefined && workspaces.length > 0
? "warning"
: "error"
}
@ -96,15 +97,19 @@ export const WorkspacesPageView: FC<
presetFilters={presetFilters}
/>
</Stack>
<WorkspacesTable
isLoading={isLoading}
workspaceRefs={workspaceRefs}
filter={filter}
isNonInitialPage={isNonInitialPage}
workspaces={workspaces}
isUsingFilter={filter !== workspaceFilterQuery.me}
onUpdateWorkspace={onUpdateWorkspace}
/>
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
{count !== undefined && (
<PaginationWidgetBase
count={count}
limit={limit}
onChange={onPageChange}
page={page}
/>
)}
</Margins>
)
}

View File

@ -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<WorkspacesResponse>(queryKey, (oldResponse) => {
if (oldResponse) {
return assignPendingStatus(oldResponse, workspace)
}
})
},
onSuccess: (workspaceBuild) => {
queryClient.setQueryData<WorkspacesResponse>(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
}),
}
}

View File

@ -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<React.PropsWithChildren<unknown>> = ({
children,
}) => {
return (
<HelmetProvider>
<ThemeProvider theme={dark}>
<HistoryRouter history={history}>
<AuthProvider>{children}</AuthProvider>
</HistoryRouter>
</ThemeProvider>
</HelmetProvider>
<AppProviders>
<HistoryRouter history={history}>{children}</HistoryRouter>
</AppProviders>
)
}
@ -59,24 +52,20 @@ export function renderWithAuth(
}: { route?: string; path?: string; routes?: JSX.Element } = {},
): RenderWithAuthResult {
const renderResult = wrappedRender(
<HelmetProvider>
<I18nextProvider i18n={i18n}>
<ThemeProvider theme={dark}>
<AuthProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route path={path ?? route} element={ui} />
</Route>
</Route>
{routes}
</Routes>
</MemoryRouter>
</AuthProvider>
</ThemeProvider>
</I18nextProvider>
</HelmetProvider>,
<I18nextProvider i18n={i18n}>
<AppProviders>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route path={path ?? route} element={ui} />
</Route>
</Route>
{routes}
</Routes>
</MemoryRouter>
</AppProviders>
</I18nextProvider>,
)
return {

View File

@ -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==