feat: Add helpful tooltips for the key features (#2097)

This commit is contained in:
Bruno Quaresma
2022-06-07 08:43:46 -05:00
committed by GitHub
parent 6d966963da
commit eedd293ad5
16 changed files with 726 additions and 395 deletions

View File

@ -0,0 +1,35 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipProps,
HelpTooltipText,
HelpTooltipTitle,
} from "./HelpTooltip"
export default {
title: "components/HelpTooltip",
component: HelpTooltip,
} as ComponentMeta<typeof HelpTooltip>
const Template: Story<HelpTooltipProps> = (args) => (
<HelpTooltip {...args}>
<HelpTooltipTitle>What is template?</HelpTooltipTitle>
<HelpTooltipText>
With templates you can create a common configuration for your workspaces using Terraform. So, you and your team
can use the same environment to deliver great software.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://github.com/coder/coder/">Creating a template</HelpTooltipLink>
<HelpTooltipLink href="https://github.com/coder/coder/">Updating a template</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
export const Close = Template.bind({})
export const Open = Template.bind({})
Open.args = {
open: true,
}

View File

@ -0,0 +1,159 @@
import Link from "@material-ui/core/Link"
import Popover from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import HelpIcon from "@material-ui/icons/HelpOutline"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
import { useState } from "react"
import { Stack } from "../Stack/Stack"
type Size = "small" | "medium"
export interface HelpTooltipProps {
// Useful to test on storybook
open?: boolean
size?: Size
}
export const HelpTooltip: React.FC<HelpTooltipProps> = ({ children, open, size = "medium" }) => {
const styles = useStyles({ size })
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
open = open ?? Boolean(anchorEl)
const id = open ? "help-popover" : undefined
return (
<>
<button aria-describedby={id} className={styles.button} onClick={(event) => setAnchorEl(event.currentTarget)}>
<HelpIcon className={styles.icon} />
</button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={open}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null)
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
{children}
</Popover>
</>
)
}
export const HelpTooltipTitle: React.FC = ({ children }) => {
const styles = useStyles()
return <h4 className={styles.title}>{children}</h4>
}
export const HelpTooltipText: React.FC = ({ children }) => {
const styles = useStyles()
return <p className={styles.text}>{children}</p>
}
export const HelpTooltipLink: React.FC<{ href: string }> = ({ children, href }) => {
const styles = useStyles()
return (
<Link href={href} target="_blank" rel="noreferrer" className={styles.link}>
<OpenInNewIcon className={styles.linkIcon} />
{children}
</Link>
)
}
export const HelpTooltipLinksGroup: React.FC = ({ children }) => {
const styles = useStyles()
return (
<Stack spacing={1} className={styles.linksGroup}>
{children}
</Stack>
)
}
const getButtonSpacingFromSize = (size?: Size): number => {
switch (size) {
case "small":
return 2.75
case "medium":
default:
return 3
}
}
const getIconSpacingFromSize = (size?: Size): number => {
switch (size) {
case "small":
return 1.75
case "medium":
default:
return 2
}
}
const useStyles = makeStyles((theme) => ({
button: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: ({ size }: { size?: Size }) => theme.spacing(getButtonSpacingFromSize(size)),
height: ({ size }: { size?: Size }) => theme.spacing(getButtonSpacingFromSize(size)),
padding: 0,
border: 0,
background: "transparent",
color: theme.palette.text.secondary,
cursor: "pointer",
"&:hover": {
color: theme.palette.text.primary,
},
},
icon: {
width: ({ size }: { size?: Size }) => theme.spacing(getIconSpacingFromSize(size)),
height: ({ size }: { size?: Size }) => theme.spacing(getIconSpacingFromSize(size)),
},
popoverPaper: {
marginTop: theme.spacing(0.5),
width: theme.spacing(38),
padding: theme.spacing(2.5),
color: theme.palette.text.secondary,
},
title: {
marginTop: 0,
marginBottom: theme.spacing(1),
color: theme.palette.text.primary,
},
text: {
marginTop: theme.spacing(0.5),
marginBottom: theme.spacing(0.5),
},
link: {
display: "flex",
alignItems: "center",
},
linkIcon: {
color: "inherit",
width: 14,
height: 14,
marginRight: theme.spacing(1),
},
linksGroup: {
marginTop: theme.spacing(2),
},
}))

View File

@ -0,0 +1,15 @@
import { ComponentMeta, Story } from "@storybook/react"
import { PageHeader, PageHeaderTitle } from "./PageHeader"
export default {
title: "components/PageHeader",
component: PageHeader,
} as ComponentMeta<typeof PageHeader>
const Template: Story = () => (
<PageHeader>
<PageHeaderTitle>Templates</PageHeaderTitle>
</PageHeader>
)
export const Example = Template.bind({})

View File

@ -0,0 +1,62 @@
import { makeStyles } from "@material-ui/core/styles"
import { Stack } from "../Stack/Stack"
export interface PageHeaderProps {
actions?: JSX.Element
}
export const PageHeader: React.FC<PageHeaderProps> = ({ children, actions }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<hgroup>{children}</hgroup>
<Stack direction="row" className={styles.actions}>
{actions}
</Stack>
</div>
)
}
export const PageHeaderTitle: React.FC = ({ children }) => {
const styles = useStyles()
return <h1 className={styles.title}>{children}</h1>
}
export const PageHeaderSubtitle: React.FC = ({ children }) => {
const styles = useStyles()
return <h2 className={styles.subtitle}>{children}</h2>
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
alignItems: "center",
paddingTop: theme.spacing(6),
paddingBottom: theme.spacing(5),
},
title: {
fontSize: theme.spacing(4),
fontWeight: 400,
margin: 0,
display: "flex",
alignItems: "center",
lineHeight: "140%",
},
subtitle: {
fontSize: theme.spacing(2.5),
color: theme.palette.text.secondary,
fontWeight: 400,
display: "block",
margin: 0,
marginTop: theme.spacing(1),
},
actions: {
marginLeft: "auto",
},
}))

View File

@ -9,6 +9,13 @@ import { FC } from "react"
import { Workspace, WorkspaceResource } from "../../api/typesGenerated"
import { getDisplayAgentStatus } from "../../util/workspace"
import { AppLink } from "../AppLink/AppLink"
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "../HelpTooltip/HelpTooltip"
import { Stack } from "../Stack/Stack"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TerminalLink } from "../TerminalLink/TerminalLink"
@ -21,6 +28,35 @@ const Language = {
agentLabel: "Agent",
statusLabel: "Status",
accessLabel: "Access",
resourceTooltipTitle: "What is a resource?",
resourceTooltipText: "A resource is an infrastructure object that is create when the workspace is provisioned.",
resourceTooltipLink: "Persistent and ephemeral resources",
agentTooltipTitle: "What is an agent?",
agentTooltipText:
"The Coder agent runs inside your resource and gives you direct access to the shell via the UI or CLI.",
}
const ResourcesHelpTooltip: React.FC = () => {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.resourceTooltipTitle}</HelpTooltipTitle>
<HelpTooltipText>{Language.resourceTooltipText}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://github.com/coder/coder/blob/main/docs/templates.md#persistent-and-ephemeral-resources">
{Language.resourceTooltipLink}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
}
const AgentHelpTooltip: React.FC = () => {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.agentTooltipTitle}</HelpTooltipTitle>
<HelpTooltipText>{Language.agentTooltipTitle}</HelpTooltipText>
</HelpTooltip>
)
}
interface ResourcesProps {
@ -41,8 +77,18 @@ export const Resources: FC<ResourcesProps> = ({ resources, getResourcesError, wo
<Table className={styles.table}>
<TableHead>
<TableHeaderRow>
<TableCell>{Language.resourceLabel}</TableCell>
<TableCell className={styles.agentColumn}>{Language.agentLabel}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} alignItems="center">
{Language.resourceLabel}
<ResourcesHelpTooltip />
</Stack>
</TableCell>
<TableCell className={styles.agentColumn}>
<Stack direction="row" spacing={0.5} alignItems="center">
{Language.agentLabel}
<AgentHelpTooltip />
</Stack>
</TableCell>
<TableCell>{Language.accessLabel}</TableCell>
<TableCell>{Language.statusLabel}</TableCell>
</TableHeaderRow>

View File

@ -1,4 +1,5 @@
import { makeStyles } from "@material-ui/core/styles"
import { CSSProperties } from "@material-ui/core/styles/withStyles"
import { FC } from "react"
import { combineClasses } from "../../util/combineClasses"
@ -7,6 +8,7 @@ type Direction = "column" | "row"
interface StyleProps {
direction: Direction
spacing: number
alignItems?: CSSProperties["alignItems"]
}
const useStyles = makeStyles((theme) => ({
@ -14,6 +16,7 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
flexDirection: ({ direction }: StyleProps) => direction,
gap: ({ spacing }: StyleProps) => theme.spacing(spacing),
alignItems: ({ alignItems }: StyleProps) => alignItems,
},
}))
@ -21,10 +24,11 @@ export interface StackProps {
className?: string
direction?: Direction
spacing?: number
alignItems?: CSSProperties["alignItems"]
}
export const Stack: FC<StackProps> = ({ children, className, direction = "column", spacing = 2 }) => {
const styles = useStyles({ spacing, direction })
export const Stack: FC<StackProps> = ({ children, className, direction = "column", spacing = 2, alignItems }) => {
const styles = useStyles({ spacing, direction, alignItems })
return <div className={combineClasses([styles.stack, className])}>{children}</div>
}

View File

@ -1,9 +1,10 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { BuildsTable } from "../BuildsTable/BuildsTable"
import { Margins } from "../Margins/Margins"
import { PageHeader, PageHeaderSubtitle, PageHeaderTitle } from "../PageHeader/PageHeader"
import { Resources } from "../Resources/Resources"
import { Stack } from "../Stack/Stack"
import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions"
@ -46,33 +47,22 @@ export const Workspace: FC<WorkspaceProps> = ({
const styles = useStyles()
return (
<div className={styles.root}>
<Stack direction="row" spacing={3}>
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
<div className={styles.header}>
<div>
<Typography variant="h4" className={styles.title}>
{workspace.name}
</Typography>
<Typography color="textSecondary" className={styles.subtitle}>
{workspace.owner_name}
</Typography>
</div>
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
/>
</div>
</Stack>
<Stack direction="column" className={styles.secondColumnSpacer} spacing={3}></Stack>
</Stack>
<Margins>
<PageHeader
actions={
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
/>
}
>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
</PageHeader>
<Stack direction="row" spacing={3}>
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
@ -95,16 +85,12 @@ export const Workspace: FC<WorkspaceProps> = ({
<WorkspaceSchedule workspace={workspace} />
</Stack>
</Stack>
</div>
</Margins>
)
}
export const useStyles = makeStyles((theme) => {
return {
root: {
display: "flex",
flexDirection: "column",
},
firstColumnSpacer: {
flex: 2,
},

View File

@ -1,7 +1,6 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import frontMatter from "front-matter"
import { FC } from "react"
@ -9,11 +8,11 @@ import ReactMarkdown from "react-markdown"
import { Link as RouterLink } from "react-router-dom"
import { Template, TemplateVersion, WorkspaceResource } from "../../api/typesGenerated"
import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderSubtitle, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { TemplateResourcesTable } from "../../components/TemplateResourcesTable/TemplateResourcesTable"
import { TemplateStats } from "../../components/TemplateStats/TemplateStats"
import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
const Language = {
createButton: "Create workspace",
@ -38,23 +37,19 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({ template, activeTe
return (
<Margins>
<div className={styles.header}>
<div>
<Typography variant="h4" className={styles.title}>
{template.name}
</Typography>
<Typography color="textSecondary" className={styles.subtitle}>
{template.description === "" ? Language.noDescription : template.description}
</Typography>
</div>
<div className={styles.headerActions}>
<PageHeader
actions={
<Link underline="none" component={RouterLink} to={`/workspaces/new?template=${template.name}`}>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</div>
</div>
}
>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
<PageHeaderSubtitle>
{" "}
{template.description === "" ? Language.noDescription : template.description}
</PageHeaderSubtitle>
</PageHeader>
<Stack spacing={3}>
<TemplateStats template={template} activeVersion={activeTemplateVersion} />
@ -83,38 +78,6 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({ template, activeTe
export const useStyles = makeStyles((theme) => {
return {
root: {
display: "flex",
flexDirection: "column",
},
header: {
paddingTop: theme.spacing(5),
paddingBottom: theme.spacing(5),
fontFamily: MONOSPACE_FONT_FAMILY,
display: "flex",
alignItems: "center",
},
headerActions: {
marginLeft: "auto",
},
title: {
fontWeight: 600,
fontFamily: "inherit",
},
subtitle: {
fontFamily: "inherit",
marginTop: theme.spacing(0.5),
},
layout: {
alignItems: "flex-start",
},
main: {
width: "100%",
},
sidebar: {
width: theme.spacing(32),
flexShrink: 0,
},
readmeContents: {
margin: 0,
},

View File

@ -12,7 +12,15 @@ import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "../../components/HelpTooltip/HelpTooltip"
import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
@ -36,6 +44,23 @@ export const Language = {
or use a built-in template using the following Coder CLI command:
</>
),
templateTooltipTitle: "What is template?",
templateTooltipText: "With templates you can create a common configuration for your workspaces using Terraform.",
templateTooltipLink: "Manage templates",
}
const TemplateHelpTooltip: React.FC = () => {
return (
<HelpTooltip>
<HelpTooltipTitle>{Language.templateTooltipTitle}</HelpTooltipTitle>
<HelpTooltipText>{Language.templateTooltipText}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://github.com/coder/coder/blob/main/docs/templates.md#manage-templates">
{Language.templateTooltipLink}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
}
export interface TemplatesPageViewProps {
@ -47,56 +72,60 @@ export interface TemplatesPageViewProps {
export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
const styles = useStyles()
return (
<Stack spacing={4} className={styles.root}>
<Margins>
<Table>
<TableHead>
<Margins>
<PageHeader>
<PageHeaderTitle>
<Stack spacing={1} direction="row" alignItems="center">
Templates
<TemplateHelpTooltip />
</Stack>
</PageHeaderTitle>
</PageHeader>
<Table>
<TableHead>
<TableRow>
<TableCell>{Language.nameLabel}</TableCell>
<TableCell>{Language.usedByLabel}</TableCell>
<TableCell>{Language.lastUpdatedLabel}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.loading && <TableLoader />}
{!props.loading && !props.templates?.length && (
<TableRow>
<TableCell>{Language.nameLabel}</TableCell>
<TableCell>{Language.usedByLabel}</TableCell>
<TableCell>{Language.lastUpdatedLabel}</TableCell>
<TableCell colSpan={999}>
<EmptyState
message={Language.emptyMessage}
description={props.canCreateTemplate ? Language.emptyDescription : Language.emptyViewNoPerms}
descriptionClassName={styles.emptyDescription}
cta={<CodeExample code="coder template init" />}
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.loading && <TableLoader />}
{!props.loading && !props.templates?.length && (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={Language.emptyMessage}
description={props.canCreateTemplate ? Language.emptyDescription : Language.emptyViewNoPerms}
descriptionClassName={styles.emptyDescription}
cta={<CodeExample code="coder template init" />}
/>
</TableCell>
</TableRow>
)}
{props.templates?.map((template) => (
<TableRow key={template.id}>
<TableCell>
<AvatarData
title={template.name}
subtitle={template.description}
link={`/templates/${template.name}`}
/>
</TableCell>
)}
{props.templates?.map((template) => (
<TableRow key={template.id}>
<TableCell>
<AvatarData
title={template.name}
subtitle={template.description}
link={`/templates/${template.name}`}
/>
</TableCell>
<TableCell>{Language.developerCount(template.workspace_owner_count)}</TableCell>
<TableCell>{Language.developerCount(template.workspace_owner_count)}</TableCell>
<TableCell data-chromatic="ignore">{dayjs().to(dayjs(template.updated_at))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Margins>
</Stack>
<TableCell data-chromatic="ignore">{dayjs().to(dayjs(template.updated_at))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
root: {
marginTop: theme.spacing(10),
},
emptyDescription: {
maxWidth: theme.spacing(62),
},

View File

@ -1,11 +1,10 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { UsersTable } from "../../components/UsersTable/UsersTable"
export const Language = {
@ -40,48 +39,34 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
canCreateUser,
isLoading,
}) => {
const styles = useStyles()
return (
<Stack spacing={4}>
<Margins>
<div className={styles.actions}>
<div>
{canCreateUser && (
<Button onClick={openUserCreationDialog} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
)}
</div>
</div>
{error ? (
<ErrorSummary error={error} />
) : (
<UsersTable
users={users}
roles={roles}
onSuspendUser={onSuspendUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
isLoading={isLoading}
/>
)}
</Margins>
</Stack>
<Margins>
<PageHeader
actions={
canCreateUser ? (
<Button onClick={openUserCreationDialog} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
) : undefined
}
>
<PageHeaderTitle>Users</PageHeaderTitle>
</PageHeader>
{error ? (
<ErrorSummary error={error} />
) : (
<UsersTable
users={users}
roles={roles}
onSuspendUser={onSuspendUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
isLoading={isLoading}
/>
)}
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
actions: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
display: "flex",
height: theme.spacing(6),
"& > *": {
marginLeft: "auto",
},
},
}))

View File

@ -1,21 +1,10 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { Helmet } from "react-helmet"
import { useParams } from "react-router-dom"
import { ProvisionerJobLog } from "../../api/typesGenerated"
import { Loader } from "../../components/Loader/Loader"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats"
import { pageTitle } from "../../util/page"
import { workspaceBuildMachine } from "../../xServices/workspaceBuild/workspaceBuildXService"
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
return [...logs].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
import { WorkspaceBuildPageView } from "./WorkspaceBuildPageView"
const useBuildId = () => {
const { buildId } = useParams()
@ -32,29 +21,14 @@ export const WorkspaceBuildPage: FC = () => {
const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } })
const { logs, build } = buildState.context
const isWaitingForLogs = !buildState.matches("logs.loaded")
const styles = useStyles()
return (
<Margins>
<>
<Helmet>
<title>{build ? pageTitle(`Build #${build.build_number} · ${build.workspace_name}`) : ""}</title>
</Helmet>
<Stack>
<Typography variant="h4" className={styles.title}>
Logs
</Typography>
{build && <WorkspaceBuildStats build={build} />}
{!logs && <Loader />}
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} isWaitingForLogs={isWaitingForLogs} />}
</Stack>
</Margins>
<WorkspaceBuildPageView logs={logs} build={build} isWaitingForLogs={isWaitingForLogs} />
</>
)
}
const useStyles = makeStyles((theme) => ({
title: {
paddingTop: theme.spacing(5),
paddingBottom: theme.spacing(2),
},
}))

View File

@ -0,0 +1,17 @@
import { ComponentMeta, Story } from "@storybook/react"
import { MockWorkspaceBuild, MockWorkspaceBuildLogs } from "../../testHelpers/entities"
import { WorkspaceBuildPageView, WorkspaceBuildPageViewProps } from "./WorkspaceBuildPageView"
export default {
title: "pages/WorkspaceBuildPageView",
component: WorkspaceBuildPageView,
} as ComponentMeta<typeof WorkspaceBuildPageView>
const Template: Story<WorkspaceBuildPageViewProps> = (args) => <WorkspaceBuildPageView {...args} />
export const Example = Template.bind({})
Example.args = {
build: MockWorkspaceBuild,
logs: MockWorkspaceBuildLogs,
isWaitingForLogs: false,
}

View File

@ -0,0 +1,34 @@
import { FC } from "react"
import { ProvisionerJobLog, WorkspaceBuild } from "../../api/typesGenerated"
import { Loader } from "../../components/Loader/Loader"
import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats"
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
return [...logs].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
export interface WorkspaceBuildPageViewProps {
logs: ProvisionerJobLog[] | undefined
build: WorkspaceBuild | undefined
isWaitingForLogs: boolean
}
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({ logs, build, isWaitingForLogs }) => {
return (
<Margins>
<PageHeader>
<PageHeaderTitle>Logs</PageHeaderTitle>
</PageHeader>
<Stack>
{build && <WorkspaceBuildStats build={build} />}
{!logs && <Loader />}
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} isWaitingForLogs={isWaitingForLogs} />}
</Stack>
</Margins>
)
}

View File

@ -5,8 +5,6 @@ import { useNavigate, useParams } from "react-router-dom"
import { DeleteWorkspaceDialog } from "../../components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { Workspace } from "../../components/Workspace/Workspace"
import { firstOrItem } from "../../util/array"
import { pageTitle } from "../../util/page"
@ -38,40 +36,37 @@ export const WorkspacePage: React.FC = () => {
return <FullScreenLoader />
} else {
return (
<Margins>
<>
<Helmet>
<title>{pageTitle(`${workspace.owner_name}/${workspace.name}`)}</title>
</Helmet>
<Stack spacing={4}>
<>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
},
}}
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
handleDelete={() => workspaceSend("ASK_DELETE")}
handleUpdate={() => workspaceSend("UPDATE")}
handleCancel={() => workspaceSend("CANCEL")}
resources={resources}
getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined}
builds={builds}
/>
<DeleteWorkspaceDialog
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
handleCancel={() => workspaceSend("CANCEL_DELETE")}
handleConfirm={() => {
workspaceSend("DELETE")
navigate("/workspaces")
}}
/>
</>
</Stack>
</Margins>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
},
}}
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
handleDelete={() => workspaceSend("ASK_DELETE")}
handleUpdate={() => workspaceSend("UPDATE")}
handleCancel={() => workspaceSend("CANCEL")}
resources={resources}
getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined}
builds={builds}
/>
<DeleteWorkspaceDialog
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
handleCancel={() => workspaceSend("CANCEL_DELETE")}
handleConfirm={() => {
workspaceSend("DELETE")
navigate("/workspaces")
}}
/>
</>
)
}
}

View File

@ -1,174 +1,32 @@
import Button from "@material-ui/core/Button"
import Fade from "@material-ui/core/Fade"
import InputAdornment from "@material-ui/core/InputAdornment"
import Link from "@material-ui/core/Link"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import SearchIcon from "@material-ui/icons/Search"
import { useMachine } from "@xstate/react"
import { FormikErrors, useFormik } from "formik"
import { FC, useState } from "react"
import { FC } from "react"
import { Helmet } from "react-helmet"
import { Link as RouterLink } from "react-router-dom"
import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { pageTitle } from "../../util/page"
import { workspacesMachine } from "../../xServices/workspaces/workspacesXService"
import { WorkspacesPageView } from "./WorkspacesPageView"
interface FilterFormValues {
query: string
}
const Language = {
filterName: "Filters",
createWorkspaceButton: "Create workspace",
yourWorkspacesButton: "Your workspaces",
allWorkspacesButton: "All workspaces",
}
export type FilterFormErrors = FormikErrors<FilterFormValues>
const WorkspacesPage: FC = () => {
const styles = useStyles()
const [workspacesState, send] = useMachine(workspacesMachine)
const form = useFormik<FilterFormValues>({
initialValues: {
query: workspacesState.context.filter || "",
},
onSubmit: (values) => {
send({
type: "SET_FILTER",
query: values.query,
})
},
})
const getFieldHelpers = getFormHelpers<FilterFormValues>(form)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const setYourWorkspaces = () => {
void form.setFieldValue("query", "owner:me")
void form.submitForm()
handleClose()
}
const setAllWorkspaces = () => {
void form.setFieldValue("query", "")
void form.submitForm()
handleClose()
}
return (
<Margins>
<>
<Helmet>
<title>{pageTitle("Workspaces")}</title>
</Helmet>
<Stack direction="row" className={styles.workspacesHeaderContainer}>
<Stack direction="column" className={styles.filterColumn}>
<Stack direction="row" spacing={0} className={styles.filterContainer}>
<Button
aria-controls="filter-menu"
aria-haspopup="true"
onClick={handleClick}
className={styles.buttonRoot}
>
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
<TextField
{...getFieldHelpers("query")}
className={styles.textFieldRoot}
onChange={onChangeTrimmed(form)}
fullWidth
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
</form>
<Menu
id="filter-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
TransitionComponent={Fade}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem onClick={setYourWorkspaces}>{Language.yourWorkspacesButton}</MenuItem>
<MenuItem onClick={setAllWorkspaces}>{Language.allWorkspacesButton}</MenuItem>
</Menu>
</Stack>
</Stack>
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />} style={{ height: "44px" }}>
{Language.createWorkspaceButton}
</Button>
</Link>
</Stack>
<WorkspacesPageView loading={workspacesState.hasTag("loading")} workspaces={workspacesState.context.workspaces} />
</Margins>
<WorkspacesPageView
filter={workspacesState.context.filter}
loading={workspacesState.hasTag("loading")}
workspaces={workspacesState.context.workspaces}
onFilter={(query) => {
send({
type: "SET_FILTER",
query,
})
}}
/>
</>
)
}
const useStyles = makeStyles((theme) => ({
workspacesHeaderContainer: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
justifyContent: "space-between",
},
filterColumn: {
width: "60%",
cursor: "text",
},
filterContainer: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: "6px",
},
filterForm: {
width: "100%",
},
buttonRoot: {
border: "none",
borderRight: `1px solid ${theme.palette.divider}`,
borderRadius: "6px 0px 0px 6px",
},
textFieldRoot: {
margin: "0px",
"& fieldset": {
border: "none",
},
},
}))
export default WorkspacesPage

View File

@ -1,22 +1,40 @@
import Button from "@material-ui/core/Button"
import Fade from "@material-ui/core/Fade"
import InputAdornment from "@material-ui/core/InputAdornment"
import Link from "@material-ui/core/Link"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles, Theme } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import TextField from "@material-ui/core/TextField"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import SearchIcon from "@material-ui/icons/Search"
import useTheme from "@material-ui/styles/useTheme"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react"
import { FormikErrors, useFormik } from "formik"
import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "../../components/HelpTooltip/HelpTooltip"
import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { getDisplayStatus } from "../../util/workspace"
dayjs.extend(relativeTime)
@ -25,19 +43,150 @@ export const Language = {
createButton: "Create workspace",
emptyMessage: "Create your first workspace",
emptyDescription: "Start editing your source code and building your software",
filterName: "Filters",
createWorkspaceButton: "Create workspace",
yourWorkspacesButton: "Your workspaces",
allWorkspacesButton: "All workspaces",
workspaceTooltipTitle: "What is workspace?",
workspaceTooltipText:
"It is your workstation. It is a workspace that will provide you the necessary compute and access to your development environment.",
workspaceTooltipLink1: "Create workspaces",
workspaceTooltipLink2: "Connect with SSH",
workspaceTooltipLink3: "Editors and IDEs",
}
const WorkspaceHelpTooltip: React.FC = () => {
return (
<HelpTooltip>
<HelpTooltipTitle>{Language.workspaceTooltipTitle}</HelpTooltipTitle>
<HelpTooltipText>{Language.workspaceTooltipTitle}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://github.com/coder/coder/blob/main/docs/workspaces.md#create-workspaces">
{Language.workspaceTooltipLink1}
</HelpTooltipLink>
<HelpTooltipLink href="https://github.com/coder/coder/blob/main/docs/workspaces.md#connect-with-ssh">
{Language.workspaceTooltipLink2}
</HelpTooltipLink>
<HelpTooltipLink href="https://github.com/coder/coder/blob/main/docs/workspaces.md#editors-and-ides">
{Language.workspaceTooltipLink3}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
}
interface FilterFormValues {
query: string
}
export type FilterFormErrors = FormikErrors<FilterFormValues>
export interface WorkspacesPageViewProps {
loading?: boolean
workspaces?: TypesGen.Workspace[]
filter?: string
onFilter: (query: string) => void
}
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, workspaces }) => {
useStyles()
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, workspaces, filter, onFilter }) => {
const styles = useStyles()
const theme: Theme = useTheme()
const form = useFormik<FilterFormValues>({
initialValues: {
query: filter ?? "",
},
onSubmit: ({ query }) => {
onFilter(query)
},
})
const getFieldHelpers = getFormHelpers<FilterFormValues>(form)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const setYourWorkspaces = () => {
void form.setFieldValue("query", "owner:me")
void form.submitForm()
handleClose()
}
const setAllWorkspaces = () => {
void form.setFieldValue("query", "")
void form.submitForm()
handleClose()
}
return (
<Stack spacing={4}>
<Margins>
<PageHeader
actions={
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />} style={{ height: "44px" }}>
{Language.createWorkspaceButton}
</Button>
</Link>
}
>
<PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center">
<span>Workspaces</span>
<WorkspaceHelpTooltip />
</Stack>
</PageHeaderTitle>
</PageHeader>
<Stack direction="row" spacing={0} className={styles.filterContainer}>
<Button aria-controls="filter-menu" aria-haspopup="true" onClick={handleClick} className={styles.buttonRoot}>
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
<TextField
{...getFieldHelpers("query")}
className={styles.textFieldRoot}
onChange={onChangeTrimmed(form)}
fullWidth
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
</form>
<Menu
id="filter-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
TransitionComponent={Fade}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem onClick={setYourWorkspaces}>{Language.yourWorkspacesButton}</MenuItem>
<MenuItem onClick={setAllWorkspaces}>{Language.allWorkspacesButton}</MenuItem>
</Menu>
</Stack>
<Table>
<TableHead>
<TableRow>
@ -98,7 +247,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, works
})}
</TableBody>
</Table>
</Stack>
</Margins>
)
}
@ -110,6 +259,7 @@ const useStyles = makeStyles((theme) => ({
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
"& span": {
maxWidth: 600,
textAlign: "center",
@ -117,4 +267,23 @@ const useStyles = makeStyles((theme) => ({
lineHeight: `${theme.spacing(3)}px`,
},
},
filterContainer: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
marginBottom: theme.spacing(2),
},
filterForm: {
width: "100%",
},
buttonRoot: {
border: "none",
borderRight: `1px solid ${theme.palette.divider}`,
borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
},
textFieldRoot: {
margin: "0px",
"& fieldset": {
border: "none",
},
},
}))