refactor: Add 'src' folder in 'site' (#445)

This refactoring re-organizes the `site` folder to have a nested `src` folder.

Originally, [we wanted to keep the directory structure shallow](https://github.com/coder/coder/pull/8#issuecomment-1009578910) - but there were two points that motivated this change to introduce the `src` level.

1. We have several non-`src` folders now (`e2e`, `static`, `html_templates`, `.storybook`)
2. Having a `src` folder makes it easier to run XState Typegen

So given those two data points - I believe it makes sense to revisit that and introduce a `src` folder.
This commit is contained in:
Bryan
2022-03-16 04:06:03 +00:00
committed by GitHub
parent 15beb98016
commit d8e530e1ec
103 changed files with 3 additions and 3 deletions
+68
View File
@@ -0,0 +1,68 @@
import { makeStyles } from "@material-ui/core/styles"
import Button from "@material-ui/core/Button"
import Tooltip from "@material-ui/core/Tooltip"
import Check from "@material-ui/icons/Check"
import React, { useState } from "react"
import { FileCopy } from "../Icons"
interface CopyButtonProps {
text: string
className?: string
}
/**
* Copy button used inside the CodeBlock component internally
*/
export const CopyButton: React.FC<CopyButtonProps> = ({ className = "", text }) => {
const styles = useStyles()
const [isCopied, setIsCopied] = useState<boolean>(false)
const copyToClipboard = async (): Promise<void> => {
try {
await window.navigator.clipboard.writeText(text)
setIsCopied(true)
window.setTimeout(() => {
setIsCopied(false)
}, 1000)
} catch (err) {
const wrappedErr = new Error("copyToClipboard: failed to copy text to clipboard")
if (err instanceof Error) {
wrappedErr.stack = err.stack
}
console.error(wrappedErr)
}
}
return (
<Tooltip title="Copy to Clipboard" placement="top">
<div className={`${styles.copyButtonWrapper} ${className}`}>
<Button className={styles.copyButton} onClick={copyToClipboard} size="small">
{isCopied ? <Check className={styles.fileCopyIcon} /> : <FileCopy className={styles.fileCopyIcon} />}
</Button>
</div>
</Tooltip>
)
}
const useStyles = makeStyles((theme) => ({
copyButtonWrapper: {
display: "flex",
marginLeft: theme.spacing(1),
},
copyButton: {
borderRadius: 7,
background: theme.palette.codeBlock.button.main,
color: theme.palette.codeBlock.button.contrastText,
padding: theme.spacing(0.85),
minWidth: 32,
"&:hover": {
background: theme.palette.codeBlock.button.hover,
},
},
fileCopyIcon: {
width: 20,
height: 20,
},
}))
@@ -0,0 +1,26 @@
import { Story } from "@storybook/react"
import React from "react"
import { LoadingButton, LoadingButtonProps } from "./LoadingButton"
export default {
title: "Button/LoadingButton",
component: LoadingButton,
argTypes: {
loading: { control: { type: "boolean" } },
children: { control: "text", defaultValue: "Create workspace" },
},
}
const Template: Story<LoadingButtonProps> = (args) => <LoadingButton {...args} />
export const Loading = Template.bind({})
Loading.args = {
variant: "contained",
loading: true,
}
export const NotLoading = Template.bind({})
NotLoading.args = {
variant: "contained",
loading: false,
}
@@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { LoadingButton } from "./LoadingButton"
describe("LoadingButton", () => {
it("renders", async () => {
// When
render(<LoadingButton>Sign In</LoadingButton>)
// Then
const element = await screen.findByText("Sign In")
expect(element).toBeDefined()
})
it("shows spinner if loading is set to true", async () => {
// When
render(<LoadingButton loading>Sign in</LoadingButton>)
// Then
const spinnerElement = await screen.findByRole("progressbar")
expect(spinnerElement).toBeDefined()
})
})
@@ -0,0 +1,45 @@
import Button, { ButtonProps } from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import * as React from "react"
export interface LoadingButtonProps extends ButtonProps {
/** Whether or not to disable the button and show a spinner */
loading?: boolean
}
/**
* LoadingButton is a small wrapper around Material-UI's button to show a loading spinner
*
* In Material-UI 5+ - this is built-in, but since we're on an earlier version,
* we have to roll our own.
*/
export const LoadingButton: React.FC<LoadingButtonProps> = ({ loading = false, children, ...rest }) => {
const styles = useStyles()
const hidden = loading ? { opacity: 0 } : undefined
return (
<Button {...rest} disabled={rest.disabled || loading}>
<span style={hidden}>{children}</span>
{loading && (
<div className={styles.loader}>
<CircularProgress size={18} className={styles.spinner} />
</div>
)}
</Button>
)
}
const useStyles = makeStyles((theme) => ({
loader: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
height: 18,
width: 18,
},
spinner: {
color: theme.palette.text.disabled,
},
}))
@@ -0,0 +1,58 @@
import { fireEvent, render, screen } from "@testing-library/react"
import React from "react"
import { SplitButton, SplitButtonProps } from "./SplitButton"
namespace Helpers {
export type SplitButtonOptions = "a" | "b" | "c"
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
export const callback = (selectedOption: SplitButtonOptions): void => {}
export const options: SplitButtonProps<SplitButtonOptions>["options"] = [
{
label: "test a",
value: "a",
},
{
label: "test b",
value: "b",
},
{
label: "test c",
value: "c",
},
]
}
describe("SplitButton", () => {
describe("onClick", () => {
it("is called when primary action is clicked", () => {
// Given
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
// When
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
fireEvent.click(screen.getByText("test a"))
// Then
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("a")
})
it("is called when clicking option in pop-up", () => {
// Given
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
// When
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
const buttons = screen.getAllByRole("button")
const dropdownButton = buttons[1]
fireEvent.click(dropdownButton)
fireEvent.click(screen.getByText("test c"))
// Then
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("c")
})
})
})
+125
View File
@@ -0,0 +1,125 @@
import Button, { ButtonProps } from "@material-ui/core/Button"
import ButtonGroup from "@material-ui/core/ButtonGroup"
import ClickAwayListener from "@material-ui/core/ClickAwayListener"
import Grow from "@material-ui/core/Grow"
import MenuItem from "@material-ui/core/MenuItem"
import MenuList from "@material-ui/core/MenuList"
import Paper from "@material-ui/core/Paper"
import Popper from "@material-ui/core/Popper"
import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"
import React, { useRef, useState } from "react"
interface SplitButtonOptions<T> {
/**
* label is shown in the SplitButton UI
*/
label: string
/**
* value is any value for this option
*/
value: T
}
export interface SplitButtonProps<T> extends Pick<ButtonProps, "color" | "disabled" | "startIcon"> {
/**
* onClick is called with the selectedOption
*/
onClick: (selectedOption: T) => void
/**
* options is a list of options
*/
options: SplitButtonOptions<T>[]
/**
* textTransform is applied to the primary button text. Defaults to
* uppercase
*/
textTransform?: React.CSSProperties["textTransform"]
}
/**
* SplitButton is a button with a primary option and a dropdown with secondary
* options.
* @remark The primary option is the 0th index (first option) in the array.
* @see https://mui.com/components/button-group/#split-button
*/
export const SplitButton = <T,>({
color,
disabled,
onClick,
options,
startIcon,
textTransform,
}: SplitButtonProps<T>): ReturnType<React.FC> => {
const [isPopperOpen, setIsPopperOpen] = useState<boolean>(false)
const anchorRef = useRef<HTMLDivElement>(null)
const displayedLabel = options[0].label
const handleClick = () => {
onClick(options[0].value)
}
const handleClose = (e: React.MouseEvent<Document, MouseEvent>) => {
if (anchorRef.current && anchorRef.current.contains(e.target as HTMLElement)) {
return
}
setIsPopperOpen(false)
}
const handleSelectOpt = (e: React.MouseEvent<HTMLLIElement, MouseEvent>, opt: number) => {
onClick(options[opt].value)
setIsPopperOpen(false)
}
const handleTogglePopper = () => {
setIsPopperOpen((prevOpen) => !prevOpen)
}
return (
<>
<ButtonGroup aria-label="split button" color={color} ref={anchorRef} variant="contained">
<Button disabled={disabled} onClick={handleClick} startIcon={startIcon} style={{ textTransform }}>
{displayedLabel}
</Button>
<Button
aria-controls={isPopperOpen ? "split-button-menu" : undefined}
aria-expanded={isPopperOpen ? "true" : undefined}
aria-label="select merge strategy"
aria-haspopup="menu"
disabled={disabled}
size="small"
onClick={handleTogglePopper}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
anchorEl={anchorRef.current}
disablePortal
open={isPopperOpen}
role={undefined}
style={{ zIndex: 1 }}
transition
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu">
{options.map((opt, idx) => (
<MenuItem key={opt.label} onClick={(e) => handleSelectOpt(e, idx)}>
{opt.label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
)
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./SplitButton"
export * from "./LoadingButton"
export * from "./CopyButton"
@@ -0,0 +1,24 @@
import { Story } from "@storybook/react"
import React from "react"
import { CodeBlock, CodeBlockProps } from "./index"
const sampleLines = `Successfully assigned coder/image-jcws7 to cluster-1
Container image "gcr.io/coder-dogfood/master/coder-dev-ubuntu@sha256" already present on machine
Created container user
Started container user
Using user 'coder' with shell '/bin/bash'`.split("\n")
export default {
title: "CodeBlock",
component: CodeBlock,
argTypes: {
lines: { control: "text", defaultValue: sampleLines },
},
}
const Template: Story<CodeBlockProps> = (args: CodeBlockProps) => <CodeBlock {...args} />
export const Example = Template.bind({})
Example.args = {
lines: sampleLines,
}
@@ -0,0 +1,16 @@
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import React from "react"
import { CodeBlock } from "./index"
describe("CodeBlock", () => {
it("renders lines)", async () => {
// When
render(<CodeBlock lines={["line1", "line2"]} />)
// Then
// Both lines should be rendered
await screen.findByText("line1")
await screen.findByText("line2")
})
})
+37
View File
@@ -0,0 +1,37 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
export interface CodeBlockProps {
lines: string[]
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines }) => {
const styles = useStyles()
return (
<div className={styles.root}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
{line}
</div>
))}
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
minHeight: 156,
background: theme.palette.background.default,
color: theme.palette.codeBlock.contrastText,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
wordBreak: "break-all",
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
},
line: {
whiteSpace: "pre-wrap",
},
}))
@@ -0,0 +1,20 @@
import { Story } from "@storybook/react"
import React from "react"
import { CodeExample, CodeExampleProps } from "./CodeExample"
const sampleCode = `echo "Hello, world"`
export default {
title: "CodeExample",
component: CodeExample,
argTypes: {
code: { control: "string", defaultValue: sampleCode },
},
}
const Template: Story<CodeExampleProps> = (args: CodeExampleProps) => <CodeExample {...args} />
export const Example = Template.bind({})
Example.args = {
code: sampleCode,
}
@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import React from "react"
import { CodeExample } from "./CodeExample"
describe("CodeExample", () => {
it("renders code", async () => {
// When
render(<CodeExample code="echo hello" />)
// Then
// Both lines should be rendered
await screen.findByText("echo hello")
})
})
@@ -0,0 +1,38 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { CopyButton } from "../Button"
export interface CodeExampleProps {
code: string
}
/**
* Component to show single-line code examples, with a copy button
*/
export const CodeExample: React.FC<CodeExampleProps> = ({ code }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<code>{code}</code>
<CopyButton text={code} />
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
background: theme.palette.background.default,
color: theme.palette.codeBlock.contrastText,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
},
}))
+1
View File
@@ -0,0 +1 @@
export * from "./CodeExample"
@@ -0,0 +1,14 @@
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import React from "react"
import { EmptyState } from "./index"
describe("EmptyState", () => {
it("renders (smoke test)", async () => {
// When
render(<EmptyState message="Hello, world" />)
// Then
await screen.findByText("Hello, world")
})
})
+74
View File
@@ -0,0 +1,74 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Box from "@material-ui/core/Box"
import Button, { ButtonProps } from "@material-ui/core/Button"
import Typography from "@material-ui/core/Typography"
export interface EmptyStateProps {
/** Text Message to display, placed inside Typography component */
message: React.ReactNode
/** Longer optional description to display below the message */
description?: React.ReactNode
button?: ButtonProps
}
/**
* Component to place on screens or in lists that have no content. Optionally
* provide a button that would allow the user to return from where they were,
* or to add an item that they currently have none of.
*
* EmptyState's props extend the [Material UI Box component](https://material-ui.com/components/box/)
* that you can directly pass props through to to customize the shape and layout of it.
*/
export const EmptyState: React.FC<EmptyStateProps> = (props) => {
const { message, description, button, ...boxProps } = props
const styles = useStyles()
const descClassName = `${styles.description}`
const buttonClassName = `${styles.button} ${button && button.className ? button.className : ""}`
return (
<Box className={styles.root} {...boxProps}>
<Typography variant="h5" color="textSecondary" className={styles.header}>
{message}
</Typography>
{description && (
<Typography variant="body2" color="textSecondary" className={descClassName}>
{description}
</Typography>
)}
{button && <Button variant="contained" color="primary" {...button} className={buttonClassName} />}
</Box>
)
}
const useStyles = makeStyles(
(theme) => ({
root: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
minHeight: 120,
padding: theme.spacing(3),
},
header: {
fontWeight: 400,
},
description: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1),
},
button: {
marginTop: theme.spacing(2),
},
icon: {
fontSize: theme.typography.h2.fontSize,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
opacity: 0.5,
},
}),
{ name: "EmptyState" },
)
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { ErrorSummary } from "./index"
describe("ErrorSummary", () => {
it("renders", async () => {
// When
const error = new Error("test error message")
render(<ErrorSummary error={error} />)
// Then
const element = await screen.findByText("test error message", { exact: false })
expect(element).toBeDefined()
})
})
@@ -0,0 +1,15 @@
import React from "react"
export interface ErrorSummaryProps {
error: Error | undefined
}
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
// TODO: More interesting error page
if (typeof error === "undefined") {
return <div>{"Unknown error"}</div>
}
return <div>{error.toString()}</div>
}
@@ -0,0 +1,16 @@
import { Story } from "@storybook/react"
import React from "react"
import { FormCloseButton, FormCloseButtonProps } from "./FormCloseButton"
export default {
title: "Form/FormCloseButton",
component: FormCloseButton,
argTypes: {
onClose: { action: "onClose" },
},
}
const Template: Story<FormCloseButtonProps> = (args) => <FormCloseButton {...args} />
export const Example = Template.bind({})
Example.args = {}
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react"
import React from "react"
import { FormCloseButton } from "./FormCloseButton"
describe("FormCloseButton", () => {
it("renders", async () => {
// When
render(
<FormCloseButton
onClose={() => {
return
}}
/>,
)
// Then
await screen.findByText("ESC")
})
it("calls onClose when clicked", async () => {
// Given
const onClose = jest.fn()
// When
render(<FormCloseButton onClose={onClose} />)
// Then
const element = await screen.findByText("ESC")
// When
fireEvent.click(element)
// Then
expect(onClose).toBeCalledTimes(1)
})
it("calls onClose when escape is pressed", async () => {
// Given
const onClose = jest.fn()
// When
render(<FormCloseButton onClose={onClose} />)
// Then
const element = await screen.findByText("ESC")
// When
fireEvent.keyDown(element, { key: "Escape", code: "Esc", charCode: 27 })
// Then
expect(onClose).toBeCalledTimes(1)
})
it("doesn't call onClose if another key is pressed", async () => {
// Given
const onClose = jest.fn()
// When
render(<FormCloseButton onClose={onClose} />)
// Then
const element = await screen.findByText("ESC")
// When
fireEvent.keyDown(element, { key: "Enter", code: "Enter", charCode: 13 })
// Then
expect(onClose).toBeCalledTimes(0)
})
})
@@ -0,0 +1,55 @@
import IconButton from "@material-ui/core/IconButton"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React, { useEffect } from "react"
import { CloseIcon } from "../Icons/Close"
export interface FormCloseButtonProps {
onClose: () => void
}
export const FormCloseButton: React.FC<FormCloseButtonProps> = ({ onClose }) => {
const styles = useStyles()
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose()
}
}
document.body.addEventListener("keydown", handleKeyPress, false)
return () => {
document.body.removeEventListener("keydown", handleKeyPress, false)
}
}, [onClose])
return (
<IconButton className={styles.closeButton} onClick={onClose} size="medium">
<CloseIcon />
<Typography variant="caption" className={styles.label}>
ESC
</Typography>
</IconButton>
)
}
const useStyles = makeStyles((theme) => ({
closeButton: {
position: "fixed",
top: theme.spacing(3),
right: theme.spacing(6),
opacity: 0.5,
color: theme.palette.text.primary,
"&:hover": {
opacity: 1,
},
},
label: {
position: "absolute",
left: "50%",
top: "100%",
transform: "translate(-50%, 50%)",
},
}))
@@ -0,0 +1,47 @@
import Box from "@material-ui/core/Box"
import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { FormTextField, FormTextFieldProps } from "./FormTextField"
export interface DropdownItem {
value: string
name: string
description?: string
}
export interface FormDropdownFieldProps<T> extends FormTextFieldProps<T> {
items: DropdownItem[]
}
export const FormDropdownField = <T,>({ items, ...props }: FormDropdownFieldProps<T>): React.ReactElement => {
const styles = useStyles()
return (
<FormTextField select {...props}>
{items.map((item: DropdownItem) => (
<MenuItem key={item.value} value={item.value}>
<Box alignItems="center" display="flex">
<Box ml={1}>
<Typography>{item.name}</Typography>
</Box>
{item.description && (
<Box ml={1}>
<Typography className={styles.hintText} variant="caption">
{item.description}
</Typography>
</Box>
)}
</Box>
</MenuItem>
))}
</FormTextField>
)
}
const useStyles = makeStyles({
hintText: {
opacity: 0.75,
},
})
+60
View File
@@ -0,0 +1,60 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
export interface FormSectionProps {
title: string
description?: string
}
export const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "row",
// Borrowed from PaperForm styles
maxWidth: "852px",
width: "100%",
borderBottom: `1px solid ${theme.palette.divider}`,
},
descriptionContainer: {
maxWidth: "200px",
flex: "0 0 200px",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "flex-start",
marginTop: theme.spacing(5),
marginBottom: theme.spacing(2),
},
descriptionText: {
fontSize: "0.9em",
lineHeight: "1em",
color: theme.palette.text.secondary,
marginTop: theme.spacing(1),
},
contents: {
flex: 1,
marginTop: theme.spacing(4),
marginBottom: theme.spacing(4),
},
}))
export const FormSection: React.FC<FormSectionProps> = ({ title, description, children }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.descriptionContainer}>
<Typography variant="h5" color="textPrimary">
{title}
</Typography>
{description && (
<Typography className={styles.descriptionText} variant="body2" color="textSecondary">
{description}
</Typography>
)}
</div>
<div className={styles.contents}>{children}</div>
</div>
)
}
@@ -0,0 +1,75 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { useFormik } from "formik"
import React from "react"
import * as yup from "yup"
import { FormTextField, FormTextFieldProps } from "./FormTextField"
namespace Helpers {
export interface FormValues {
name: string
}
export const requiredValidationMsg = "required"
export const Component: React.FC<Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">> = (props) => {
const form = useFormik<FormValues>({
initialValues: {
name: "",
},
onSubmit: (values, helpers) => {
return helpers.setSubmitting(false)
},
validationSchema: yup.object({
name: yup.string().required(requiredValidationMsg),
}),
})
return <FormTextField<FormValues> {...props} form={form} formFieldName="name" />
}
}
describe("FormTextField", () => {
describe("helperText", () => {
it("uses helperText prop when there are no errors", () => {
// Given
const props = {
helperText: "testing",
}
// When
const { queryByText } = render(<Helpers.Component {...props} />)
// Then
expect(queryByText(props.helperText)).toBeDefined()
})
it("uses validation message when there are errors", () => {
// Given
const props = {}
// When
const { container } = render(<Helpers.Component {...props} />)
const el = container.firstChild
// Then
expect(el).toBeDefined()
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull()
// When
act(() => {
fireEvent.focus(el as Element)
})
// Then
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull()
// When
act(() => {
fireEvent.blur(el as Element)
})
// Then
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeDefined()
})
})
})
+164
View File
@@ -0,0 +1,164 @@
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
import React from "react"
import { PasswordField } from "./PasswordField"
import { FormikContextType } from "formik"
/**
* FormFieldProps are required props for creating form fields using a factory.
*/
export interface FormFieldProps<T> {
/**
* form is a reference to a form or subform and is used to compute common
* states such as error and helper text
*/
form: FormikContextType<T>
/**
* formFieldName is a field name associated with the form schema.
*/
formFieldName: keyof T
}
/**
* FormTextFieldProps extends form-related MUI TextFieldProps with Formik
* props. The passed in form is used to compute error states and configure
* change handlers. `formFieldName` represents the key of a Formik value
* that's associated to this component.
*/
export interface FormTextFieldProps<T>
extends Pick<
TextFieldProps,
| "autoComplete"
| "autoFocus"
| "children"
| "className"
| "disabled"
| "fullWidth"
| "helperText"
| "id"
| "InputLabelProps"
| "InputProps"
| "inputProps"
| "label"
| "margin"
| "multiline"
| "onChange"
| "placeholder"
| "required"
| "rows"
| "select"
| "SelectProps"
| "style"
| "type"
>,
FormFieldProps<T> {
/**
* eventTransform is an optional transformer on the event data before it is
* processed by formik.
*
* @example
* <FormTextField
* eventTransformer={(str) => {
* return str.replace(" ", "-")
* }}
* />
*/
eventTransform?: (value: string) => unknown
/**
* isPassword uses a PasswordField component when `true`; otherwise a
* TextField component is used.
*/
isPassword?: boolean
/**
* displayValueOverride allows displaying a different value in the field
* without changing the actual underlying value.
*/
displayValueOverride?: string
variant?: "outlined" | "filled" | "standard"
}
/**
* Factory function for creating a formik TextField
*
* @example
* interface FormValues {
* username: string
* }
*
* // Use the factory to create a FormTextField associated to this form
* const FormTextField = formTextFieldFactory<FormValues>()
*
* const MyComponent: React.FC = () => {
* const form = useFormik<FormValues>()
*
* return (
* <FormTextField
* form={form}
* formFieldName="username"
* fullWidth
* helperText="A unique name"
* label="Username"
* placeholder="Lorem Ipsum"
* required
* />
* )
* }
*/
export const FormTextField = <T,>({
children,
disabled,
displayValueOverride,
eventTransform,
form,
formFieldName,
helperText,
isPassword = false,
InputProps,
onChange,
type,
variant = "outlined",
...rest
}: FormTextFieldProps<T>): React.ReactElement => {
const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName])
// Conversion to a string primitive is necessary as formFieldName is an in
// indexable type such as a string, number or enum.
const fieldId = String(formFieldName)
const Component = isPassword ? PasswordField : TextField
const inputType = isPassword ? undefined : type
return (
<Component
{...rest}
variant={variant}
disabled={disabled || form.isSubmitting}
error={isError}
helperText={isError ? form.errors[formFieldName] : helperText}
id={fieldId}
InputProps={isPassword ? undefined : InputProps}
name={fieldId}
onBlur={form.handleBlur}
onChange={(e) => {
if (typeof onChange !== "undefined") {
onChange(e)
}
const event = e
if (typeof eventTransform !== "undefined") {
// TODO(Grey): Asserting the type as a string here is not quite
// right in that when an input is of type="number", the value will
// be a number. Type asserting is better than conversion for this
// reason, but perhaps there's a better way to do this without any
// assertions.
event.target.value = eventTransform(e.target.value) as string
}
form.handleChange(event)
}}
type={inputType}
value={displayValueOverride || form.values[formFieldName]}
>
{children}
</Component>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
export interface FormTitleProps {
title: string
detail?: React.ReactNode
}
const useStyles = makeStyles((theme) => ({
title: {
textAlign: "center",
marginTop: theme.spacing(5),
marginBottom: theme.spacing(5),
"& h3": {
marginBottom: theme.spacing(1),
},
},
}))
export const FormTitle: React.FC<FormTitleProps> = ({ title, detail }) => {
const styles = useStyles()
return (
<div className={styles.title}>
<Typography variant="h3">{title}</Typography>
{detail && <Typography variant="caption">{detail}</Typography>}
</div>
)
}
@@ -0,0 +1,14 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { PasswordField } from "./PasswordField"
describe("PasswordField", () => {
it("renders", async () => {
// When
render(<PasswordField helperText="Enter password" />)
// Then
const element = await screen.findByText("Enter password")
expect(element).toBeDefined()
})
})
@@ -0,0 +1,40 @@
import IconButton from "@material-ui/core/IconButton"
import InputAdornment from "@material-ui/core/InputAdornment"
import { makeStyles } from "@material-ui/core/styles"
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined"
import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined"
import React, { useCallback, useState } from "react"
type PasswordFieldProps = Omit<TextFieldProps, "InputProps" | "type">
export const PasswordField: React.FC<PasswordFieldProps> = ({ variant = "outlined", ...rest }) => {
const styles = useStyles()
const [showPassword, setShowPassword] = useState<boolean>(false)
const handleVisibilityChange = useCallback(() => setShowPassword((showPassword) => !showPassword), [])
const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined
return (
<TextField
{...rest}
type={showPassword ? "text" : "password"}
variant={variant}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton aria-label="toggle password visibility" onClick={handleVisibilityChange} size="small">
<VisibilityIcon className={styles.visibilityIcon} />
</IconButton>
</InputAdornment>
),
}}
/>
)
}
const useStyles = makeStyles({
visibilityIcon: {
fontSize: 20,
},
})
+5
View File
@@ -0,0 +1,5 @@
export * from "./FormCloseButton"
export * from "./FormSection"
export * from "./FormDropdownField"
export * from "./FormTextField"
export * from "./FormTitle"
@@ -0,0 +1,37 @@
import Button from "@material-ui/core/Button"
import { lighten, makeStyles } from "@material-ui/core/styles"
import React from "react"
export interface HeaderButtonProps {
readonly text: string
readonly disabled?: boolean
readonly onClick?: (event: MouseEvent) => void
}
export const HeaderButton: React.FC<HeaderButtonProps> = (props) => {
const styles = useStyles()
return (
<Button
className={styles.pageButton}
variant="contained"
onClick={(event: React.MouseEvent): void => {
if (props.onClick) {
props.onClick(event.nativeEvent)
}
}}
disabled={props.disabled}
component="button"
>
{props.text}
</Button>
)
}
const useStyles = makeStyles((theme) => ({
pageButton: {
whiteSpace: "nowrap",
backgroundColor: lighten(theme.palette.hero.main, 0.1),
color: "#B5BFD2",
},
}))
+28
View File
@@ -0,0 +1,28 @@
import { screen } from "@testing-library/react"
import { render } from "./../../test_helpers"
import React from "react"
import { Header } from "./index"
describe("Header", () => {
it("renders title and subtitle", async () => {
// When
render(<Header title="Title Test" subTitle="Subtitle Test" />)
// Then
const titleElement = await screen.findByText("Title Test")
expect(titleElement).toBeDefined()
const subTitleElement = await screen.findByText("Subtitle Test")
expect(subTitleElement).toBeDefined()
})
it("renders button if specified", async () => {
// When
render(<Header title="Title" action={{ text: "Button Test" }} />)
// Then
const buttonElement = await screen.findByRole("button")
expect(buttonElement).toBeDefined()
expect(buttonElement.textContent).toEqual("Button Test")
})
})
+116
View File
@@ -0,0 +1,116 @@
import Box from "@material-ui/core/Box"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { HeaderButton } from "./HeaderButton"
export interface HeaderAction {
readonly text: string
readonly onClick?: (event: MouseEvent) => void
}
export interface HeaderProps {
description?: string
title: string
subTitle?: string
action?: HeaderAction
}
export const Header: React.FC<HeaderProps> = ({ description, title, subTitle, action }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.top}>
<div className={styles.topInner}>
<Box display="flex" flexDirection="column" minWidth={0}>
<div>
<Box display="flex" alignItems="center">
<Typography variant="h3" className={styles.title}>
<Box component="span" maxWidth="100%" overflow="hidden" textOverflow="ellipsis">
{title}
</Box>
</Typography>
{subTitle && (
<div className={styles.subtitle}>
<Typography style={{ fontSize: 16 }}>{subTitle}</Typography>
</div>
)}
</Box>
{description && (
<Typography variant="caption" className={styles.description}>
{description}
</Typography>
)}
</div>
</Box>
{action && (
<>
<div className={styles.actions}>
<HeaderButton key={action.text} {...action} />
</div>
</>
)}
</div>
</div>
</div>
)
}
const secondaryText = "#B5BFD2"
const useStyles = makeStyles((theme) => ({
root: {},
top: {
position: "relative",
display: "flex",
alignItems: "center",
height: 150,
background: theme.palette.hero.main,
boxShadow: theme.shadows[3],
},
topInner: {
display: "flex",
alignItems: "center",
maxWidth: "1380px",
margin: "0 auto",
flex: 1,
height: 68,
minWidth: 0,
},
title: {
display: "flex",
alignItems: "center",
fontWeight: "bold",
whiteSpace: "nowrap",
minWidth: 0,
color: theme.palette.primary.contrastText,
},
description: {
display: "block",
marginTop: theme.spacing(1) / 2,
marginBottom: -26,
color: secondaryText,
},
subtitle: {
position: "relative",
top: 2,
display: "flex",
alignItems: "center",
borderLeft: `1px solid ${theme.palette.divider}`,
height: 28,
marginLeft: 16,
paddingLeft: 16,
color: secondaryText,
},
actions: {
paddingLeft: "50px",
paddingRight: 0,
flex: 1,
display: "flex",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
},
}))
+8
View File
@@ -0,0 +1,8 @@
import SvgIcon from "@material-ui/core/SvgIcon"
import React from "react"
export const CloseIcon: typeof SvgIcon = (props) => (
<SvgIcon {...props} viewBox="0 0 31 31">
<path d="M29.5 1.5l-28 28M29.5 29.5l-28-28" stroke="currentcolor" strokeMiterlimit="10" strokeLinecap="square" />
</SvgIcon>
)
+17
View File
@@ -0,0 +1,17 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
import React from "react"
/**
* CoderIcon represents the cloud with brackets Coder brand icon. It does not
* contain additional aspects, like the word 'Coder'.
*/
export const CoderIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 68 49">
<path d="M66.3575 21.3584C65.0024 21.3584 64.099 20.5638 64.099 18.9328V9.5647C64.099 3.58419 61.6353 0.280273 55.2705 0.280273H52.314V6.59536H53.2174C55.7222 6.59536 56.913 7.97547 56.913 10.443V18.7237C56.913 22.3203 57.9807 23.7841 60.3212 24.5369C57.9807 25.2479 56.913 26.7534 56.913 30.3501C56.913 32.3994 56.913 34.4486 56.913 36.4979C56.913 38.2126 56.913 39.8855 56.4613 41.6002C56.0097 43.1894 55.2705 44.695 54.244 45.9914C53.6691 46.7442 53.0121 47.3716 52.2729 47.9571V48.7935H55.2295C61.5942 48.7935 64.058 45.4896 64.058 39.5091V30.141C64.058 28.4681 64.9203 27.7153 66.3164 27.7153H68V21.4003H66.3575V21.3584Z" />
<path d="M46.2367 9.81532H37.1208C36.9155 9.81532 36.7512 9.64804 36.7512 9.43893V8.72796C36.7512 8.51885 36.9155 8.35156 37.1208 8.35156H46.2778C46.4831 8.35156 46.6473 8.51885 46.6473 8.72796V9.43893C46.6473 9.64804 46.442 9.81532 46.2367 9.81532Z" />
<path d="M47.7971 18.8485H41.145C40.9396 18.8485 40.7754 18.6812 40.7754 18.4721V17.7612C40.7754 17.5521 40.9396 17.3848 41.145 17.3848H47.7971C48.0024 17.3848 48.1667 17.5521 48.1667 17.7612V18.4721C48.1667 18.6394 48.0024 18.8485 47.7971 18.8485Z" />
<path d="M50.4251 14.3319H37.1208C36.9155 14.3319 36.7512 14.1646 36.7512 13.9555V13.2446C36.7512 13.0355 36.9155 12.8682 37.1208 12.8682H50.384C50.5894 12.8682 50.7536 13.0355 50.7536 13.2446V13.9555C50.7536 14.1228 50.6304 14.3319 50.4251 14.3319Z" />
<path d="M26.5677 11.8649C27.4711 11.8649 28.3744 11.9485 29.2368 12.1577V10.443C29.2368 8.0173 30.4686 6.59536 32.9324 6.59536H33.8358V0.280273H30.8793C24.5145 0.280273 22.0508 3.58419 22.0508 9.5647V12.6595C23.488 12.1577 25.0073 11.8649 26.5677 11.8649Z" />
<path d="M53.2174 34.6165C52.5603 29.3051 48.5362 24.872 43.3623 23.8683C41.9251 23.5755 40.4879 23.5337 39.0918 23.7847C39.0507 23.7847 39.0507 23.7428 39.0096 23.7428C36.7512 18.9333 31.9058 15.7549 26.6497 15.7549C21.3937 15.7549 16.5894 18.8497 14.2898 23.6592C14.2488 23.6592 14.2488 23.701 14.2077 23.701C12.7295 23.5337 11.2512 23.6174 9.77294 23.9938C4.68116 25.2484 0.821255 29.5979 0.123188 34.8674C0.0410628 35.4111 0 35.9548 0 36.4566C0 38.0459 1.06763 39.5096 2.62802 39.7187C4.55797 40.0115 6.24154 38.5059 6.20048 36.5821C6.20048 36.2894 6.20048 35.9548 6.24154 35.662C6.57004 32.9854 8.58212 30.7271 11.2101 30.0997C12.0314 29.8906 12.8526 29.8488 13.6328 29.9743C16.1377 30.3088 18.6014 29.0124 19.6691 26.754C20.4493 25.0811 21.6811 23.6174 23.3237 22.8228C25.1304 21.9445 27.1836 21.819 29.0724 22.4882C31.0435 23.1992 32.5217 24.7047 33.4251 26.5867C34.3695 28.4269 34.8212 29.7233 36.8333 29.9743C37.6546 30.0997 39.9541 30.0579 40.8164 30.0161C42.5 30.0161 44.1835 30.6016 45.3744 31.8144C46.1546 32.6509 46.7294 33.6964 46.9758 34.8674C47.3454 36.7494 46.8937 38.6314 45.785 40.0533C45.0048 41.057 43.9372 41.8098 42.7463 42.1444C42.1715 42.3117 41.5966 42.3535 41.0217 42.3535C40.6932 42.3535 40.2415 42.3535 39.7077 42.3535C38.0652 42.3535 34.5749 42.3535 31.9468 42.3535C30.1401 42.3535 28.7029 40.8897 28.7029 39.0496V32.86V26.7958C28.7029 26.294 28.2922 25.8757 27.7995 25.8757H26.5265C24.0217 25.9176 22.0096 28.7614 22.0096 31.7726C22.0096 34.7838 22.0096 42.7717 22.0096 42.7717C22.0096 46.0338 24.5966 48.6686 27.7995 48.6686C27.7995 48.6686 42.0483 48.6268 42.2536 48.6268C45.5386 48.2922 48.5773 46.5775 50.6304 43.9427C52.6835 41.3916 53.628 38.0459 53.2174 34.6165Z" />
</SvgIcon>
)
+11
View File
@@ -0,0 +1,11 @@
import SvgIcon from "@material-ui/core/SvgIcon"
import React from "react"
export const FileCopy: typeof SvgIcon = (props) => (
<SvgIcon {...props} viewBox="0 0 20 20">
<path
d="M12.7412 2.2807H4.32014C3.5447 2.2807 2.91663 2.90877 2.91663 3.68421V13.5088H4.32014V3.68421H12.7412V2.2807ZM14.8465 5.08772H7.12716C6.35172 5.08772 5.72365 5.71579 5.72365 6.49123V16.3158C5.72365 17.0912 6.35172 17.7193 7.12716 17.7193H14.8465C15.6219 17.7193 16.25 17.0912 16.25 16.3158V6.49123C16.25 5.71579 15.6219 5.08772 14.8465 5.08772ZM14.8465 16.3158H7.12716V6.49123H14.8465V16.3158Z"
fill="currentColor"
/>
</SvgIcon>
)
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
export const Logo = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg aria-labelledby="title" viewBox="0 0 341 75" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<title id="title" lang="en">
Coder logo
</title>
<path d="M176.753 32.7584H165.554C165.35 31.3095 164.932 30.0226 164.301 28.8976C163.671 27.7555 162.861 26.7839 161.872 25.9828C160.884 25.1817 159.742 24.568 158.446 24.1419C157.168 23.7158 155.779 23.5027 154.279 23.5027C151.568 23.5027 149.208 24.176 147.196 25.5226C145.185 26.8521 143.625 28.7953 142.517 31.3521C141.409 33.8919 140.855 36.9771 140.855 40.6078C140.855 44.3408 141.409 47.4771 142.517 50.0169C143.642 52.5567 145.21 54.4743 147.222 55.7697C149.233 57.0652 151.56 57.7129 154.202 57.7129C155.685 57.7129 157.057 57.5169 158.318 57.1249C159.597 56.7328 160.73 56.1618 161.719 55.4118C162.708 54.6447 163.526 53.7158 164.173 52.6249C164.838 51.534 165.298 50.2896 165.554 48.8919L176.753 48.943C176.463 51.3464 175.739 53.6646 174.58 55.8976C173.438 58.1135 171.895 60.0993 169.952 61.855C168.026 63.5936 165.725 64.9743 163.048 65.997C160.389 67.0027 157.381 67.5055 154.023 67.5055C149.352 67.5055 145.176 66.4487 141.494 64.3351C137.83 62.2214 134.932 59.1618 132.801 55.1561C130.688 51.1504 129.631 46.301 129.631 40.6078C129.631 34.8976 130.705 30.0396 132.852 26.0339C135 22.0283 137.915 18.9771 141.597 16.8805C145.279 14.7669 149.421 13.7101 154.023 13.7101C157.057 13.7101 159.869 14.1362 162.46 14.9885C165.068 15.8408 167.378 17.0851 169.389 18.7214C171.401 20.3408 173.037 22.3266 174.298 24.6788C175.577 27.0311 176.395 29.7243 176.753 32.7584Z" />
<path d="M202.032 67.5567C198.06 67.5567 194.625 66.7129 191.728 65.0254C188.847 63.3209 186.623 60.9516 185.054 57.9175C183.486 54.8663 182.702 51.3294 182.702 47.3067C182.702 43.2499 183.486 39.7044 185.054 36.6703C186.623 33.6192 188.847 31.2499 191.728 29.5624C194.625 27.8578 198.06 27.0055 202.032 27.0055C206.003 27.0055 209.429 27.8578 212.31 29.5624C215.208 31.2499 217.441 33.6192 219.009 36.6703C220.577 39.7044 221.361 43.2499 221.361 47.3067C221.361 51.3294 220.577 54.8663 219.009 57.9175C217.441 60.9516 215.208 63.3209 212.31 65.0254C209.429 66.7129 206.003 67.5567 202.032 67.5567ZM202.083 59.1192C203.89 59.1192 205.398 58.6078 206.608 57.5851C207.819 56.5453 208.731 55.1305 209.344 53.3408C209.975 51.551 210.29 49.5141 210.29 47.23C210.29 44.9459 209.975 42.9089 209.344 41.1192C208.731 39.3294 207.819 37.9146 206.608 36.8749C205.398 35.8351 203.89 35.3152 202.083 35.3152C200.259 35.3152 198.725 35.8351 197.481 36.8749C196.253 37.9146 195.324 39.3294 194.694 41.1192C194.08 42.9089 193.773 44.9459 193.773 47.23C193.773 49.5141 194.08 51.551 194.694 53.3408C195.324 55.1305 196.253 56.5453 197.481 57.5851C198.725 58.6078 200.259 59.1192 202.083 59.1192Z" />
<path d="M242.715 67.4288C239.732 67.4288 237.031 66.6618 234.61 65.1277C232.207 63.5766 230.298 61.301 228.883 58.301C227.485 55.284 226.786 51.5851 226.786 47.2044C226.786 42.7044 227.511 38.9629 228.96 35.98C230.408 32.98 232.335 30.7385 234.738 29.2555C237.158 27.7555 239.809 27.0055 242.69 27.0055C244.889 27.0055 246.721 27.3805 248.187 28.1305C249.67 28.8635 250.863 29.7839 251.766 30.8919C252.687 31.9828 253.386 33.0567 253.863 34.1135H254.195V14.426H265.062V66.7896H254.323V60.4999H253.863C253.352 61.5908 252.627 62.6732 251.69 63.747C250.769 64.8038 249.568 65.6817 248.085 66.3805C246.619 67.0794 244.829 67.4288 242.715 67.4288ZM246.167 58.7612C247.923 58.7612 249.406 58.2839 250.616 57.3294C251.843 56.3578 252.781 55.0027 253.428 53.2641C254.093 51.5254 254.425 49.4885 254.425 47.1533C254.425 44.818 254.102 42.7896 253.454 41.068C252.806 39.3464 251.869 38.0169 250.641 37.0794C249.414 36.1419 247.923 35.6732 246.167 35.6732C244.377 35.6732 242.869 36.1589 241.641 37.1305C240.414 38.1021 239.485 39.4487 238.854 41.1703C238.224 42.8919 237.908 44.8862 237.908 47.1533C237.908 49.4374 238.224 51.4572 238.854 53.2129C239.502 54.9516 240.431 56.3152 241.641 57.3038C242.869 58.2754 244.377 58.7612 246.167 58.7612Z" />
<path d="M291.715 67.5567C287.675 67.5567 284.198 66.7385 281.283 65.1021C278.385 63.4487 276.152 61.1135 274.584 58.0964C273.016 55.0624 272.232 51.4743 272.232 47.3322C272.232 43.2925 273.016 39.747 274.584 36.6959C276.152 33.6447 278.36 31.2669 281.206 29.5624C284.07 27.8578 287.428 27.0055 291.28 27.0055C293.871 27.0055 296.283 27.4232 298.516 28.2584C300.766 29.0766 302.726 30.3124 304.397 31.9658C306.084 33.6192 307.397 35.6987 308.334 38.2044C309.272 40.693 309.74 43.6078 309.74 46.9487V49.9402H276.578V43.1902H299.488C299.488 41.622 299.147 40.2328 298.465 39.0226C297.783 37.8124 296.837 36.8663 295.627 36.1845C294.434 35.4857 293.044 35.1362 291.459 35.1362C289.806 35.1362 288.34 35.5197 287.061 36.2868C285.8 37.0368 284.811 38.051 284.095 39.3294C283.38 40.5908 283.013 41.997 282.996 43.5482V49.9658C282.996 51.9089 283.354 53.5879 284.07 55.0027C284.803 56.4175 285.834 57.5084 287.164 58.2754C288.493 59.0425 290.07 59.426 291.894 59.426C293.104 59.426 294.212 59.2555 295.218 58.9146C296.223 58.5737 297.084 58.0624 297.8 57.3805C298.516 56.6987 299.061 55.8635 299.436 54.8749L309.51 55.5396C308.999 57.9601 307.951 60.0737 306.365 61.8805C304.797 63.6703 302.769 65.068 300.28 66.0737C297.809 67.0624 294.953 67.5567 291.715 67.5567Z" />
<path d="M316.648 66.7896V27.5169H327.208V34.3692H327.617C328.333 31.9317 329.535 30.0908 331.222 28.8464C332.91 27.5851 334.853 26.9544 337.052 26.9544C337.597 26.9544 338.185 26.9885 338.816 27.0567C339.447 27.1249 340.001 27.2186 340.478 27.3379V37.0027C339.967 36.8493 339.259 36.7129 338.356 36.5936C337.452 36.4743 336.626 36.4146 335.876 36.4146C334.273 36.4146 332.842 36.7641 331.58 37.4629C330.336 38.1447 329.347 39.0993 328.614 40.3266C327.898 41.5538 327.54 42.9686 327.54 44.5709V66.7896H316.648Z" />
<path d="M102.464 32.5472C100.371 32.5472 98.9767 31.3202 98.9767 28.8017V14.3362C98.9767 5.10163 95.1722 0 85.3443 0H80.779V9.75122H82.1741C86.0417 9.75122 87.8805 11.8823 87.8805 15.6924V28.4788C87.8805 34.0324 89.529 36.2927 93.1432 37.4551C89.529 38.553 87.8805 40.8777 87.8805 46.4313C87.8805 49.5957 87.8805 52.7599 87.8805 55.9244C87.8805 58.572 87.8805 61.1551 87.1831 63.8027C86.4856 66.2568 85.3443 68.5815 83.7592 70.5833C82.8715 71.7459 81.857 72.7145 80.7158 73.6187V74.9101H85.2808C95.1088 74.9101 98.9132 69.8085 98.9132 60.5738V46.1084C98.9132 43.5253 100.245 42.3629 102.401 42.3629H105V32.6116H102.464V32.5472Z" />
<path d="M71.3947 14.7257H57.3186C57.0015 14.7257 56.748 14.4674 56.748 14.1445V13.0467C56.748 12.7238 57.0015 12.4655 57.3186 12.4655H71.4581C71.775 12.4655 72.0287 12.7238 72.0287 13.0467V14.1445C72.0287 14.4674 71.7116 14.7257 71.3947 14.7257Z" />
<path d="M73.8044 28.6738H63.5327C63.2156 28.6738 62.9619 28.4154 62.9619 28.0925V26.9948C62.9619 26.672 63.2156 26.4135 63.5327 26.4135H73.8044C74.1215 26.4135 74.375 26.672 74.375 26.9948V28.0925C74.375 28.3509 74.1215 28.6738 73.8044 28.6738Z" />
<path d="M77.862 21.6998H57.3186C57.0015 21.6998 56.748 21.4415 56.748 21.1186V20.0208C56.748 19.6979 57.0015 19.4396 57.3186 19.4396H77.7985C78.1157 19.4396 78.3694 19.6979 78.3694 20.0208V21.1186C78.3694 21.3769 78.1791 21.6998 77.862 21.6998Z" />
<path d="M41.0235 17.888C42.4183 17.888 43.8134 18.0172 45.1448 18.3401V15.6924C45.1448 11.9469 47.047 9.75122 50.8514 9.75122H52.2462V0H47.681C37.853 0 34.0488 5.10163 34.0488 14.3362V19.115C36.2679 18.3401 38.6141 17.888 41.0235 17.888Z" />
<path d="M82.1739 53.0168C81.1594 44.8154 74.9456 37.9701 66.9564 36.4202C64.7373 35.9683 62.518 35.9036 60.3623 36.2911C60.2989 36.2911 60.2989 36.2264 60.2355 36.2264C56.7482 28.8001 49.2664 23.8922 41.1504 23.8922C33.0344 23.8922 25.616 28.671 22.0652 36.0974C22.0018 36.0974 22.0018 36.162 21.9384 36.162C19.6558 35.9036 17.3732 36.0327 15.0906 36.614C7.22825 38.5512 1.26813 45.2674 0.190222 53.4041C0.0633989 54.2436 0 55.0831 0 55.8581C0 58.3119 1.64855 60.5722 4.05796 60.8951C7.03803 61.3473 9.63766 59.0223 9.57426 56.0519C9.57426 55.5997 9.57426 55.0831 9.63766 54.6311C10.1449 50.4981 13.2518 47.011 17.3098 46.0422C18.5779 45.7193 19.846 45.6549 21.0507 45.8487C24.9185 46.3653 28.7228 44.3632 30.3713 40.8761C31.5761 38.293 33.4783 36.0327 36.0145 34.8057C38.8042 33.4495 41.9746 33.256 44.8914 34.2893C47.9347 35.387 50.2172 37.7117 51.6123 40.6177C53.0706 43.4593 53.768 45.4611 56.875 45.8487C58.143 46.0422 61.6937 45.9777 63.0254 45.9131C65.625 45.9131 68.2246 46.8172 70.0634 48.69C71.268 49.9814 72.1557 51.596 72.5362 53.4041C73.1068 56.3101 72.4094 59.2161 70.6974 61.4117C69.4926 62.9616 67.8441 64.124 66.0053 64.6406C65.1176 64.899 64.2299 64.9635 63.3422 64.9635C62.8351 64.9635 62.1377 64.9635 61.3134 64.9635C58.7772 64.9635 53.3877 64.9635 49.3296 64.9635C46.5399 64.9635 44.3206 62.7034 44.3206 59.8618V50.3043V40.9405C44.3206 40.1657 43.6866 39.52 42.9257 39.52H40.9601C37.0922 39.5844 33.9855 43.9759 33.9855 48.6253C33.9855 53.275 33.9855 65.6094 33.9855 65.6094C33.9855 70.6464 37.9799 74.7148 42.9257 74.7148C42.9257 74.7148 64.9276 74.6501 65.2445 74.6501C70.3169 74.1335 75.009 71.4859 78.1792 67.4175C81.3496 63.4782 82.8079 58.3119 82.1739 53.0168Z" />
</svg>
)
+31
View File
@@ -0,0 +1,31 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
import React from "react"
export const LogoutIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.92523 18.5071H11.2169C11.8878 18.5063 12.4314 17.9626 12.4322 17.2918V15.4689H11.2169V17.2918H3.92523V2.70844H11.2169V4.53136H12.4322V2.70844V2.70845C12.4314 2.03759 11.8878 1.49394 11.2169 1.49316H3.92524C3.25438 1.49393 2.71073 2.03758 2.70996 2.70844V17.2918V17.2918C2.71073 17.9626 3.25438 18.5063 3.92523 18.5071Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.6751 17.292C12.6742 18.0968 12.022 18.7491 11.2171 18.75H3.92513V18.507L11.2168 18.5072C11.8877 18.5064 12.4313 17.9625 12.4321 17.2917V15.4688H11.2168V17.2917H3.92513V2.70834H11.2168V4.53125H12.4321V2.70848C12.4313 2.03762 11.8877 1.49383 11.2168 1.49306H3.92513V1.25H11.2168C12.0217 1.25093 12.6742 1.90319 12.6751 2.70806V4.77431H10.9737V2.95139H4.16818V17.0486H10.9737V15.2257H12.6751V17.292ZM2.70985 2.70833C2.71062 2.03747 3.25427 1.49383 3.92513 1.49306V1.25C3.12025 1.25092 2.46772 1.90318 2.4668 2.70805V17.2917C2.46772 18.0965 3.12025 18.7491 3.92513 18.75V18.507C3.25427 18.5062 2.71062 17.9624 2.70985 17.2915V2.70833Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.7879 12.7867L14.9669 10.6077H6.35547V9.39244H14.9669L12.7879 7.21345L13.6471 6.35425L17.293 10.0001L13.6471 13.6459L12.7879 12.7867Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4446 12.7867L14.3805 10.8508H6.11279V9.14937H14.3805L12.4446 7.21343L13.6475 6.0105L17.6371 10.0001L13.6475 13.9896L12.4446 12.7867ZM14.9673 9.39243H6.35585V10.6077H14.9673L12.7883 12.7867L13.6475 13.6459L17.2934 10.0001L13.6475 6.35423L12.7883 7.21343L14.9673 9.39243Z"
fill="currentColor"
/>
</SvgIcon>
)
@@ -0,0 +1,12 @@
import SvgIcon from "@material-ui/core/SvgIcon"
import React from "react"
export const WorkspacesIcon: typeof SvgIcon = (props) => (
<SvgIcon {...props} viewBox="0 0 16 16">
<path d="M6 14H2V2H12V5.5L14 7V1C14 0.734784 13.8946 0.48043 13.7071 0.292893C13.5196 0.105357 13.2652 0 13 0L1 0C0.734784 0 0.48043 0.105357 0.292893 0.292893C0.105357 0.48043 0 0.734784 0 1L0 15C0 15.2652 0.105357 15.5196 0.292893 15.7071C0.48043 15.8946 0.734784 16 1 16H6V14Z" />
<path d="M12 8L8 11V16H11V13H13.035V16H16V11L12 8Z" />
<path d="M10 4H4V5H10V4Z" />
<path d="M10 7H4V8H10V7Z" />
<path d="M7 10H4V11H7V10Z" />
</SvgIcon>
)
+22
View File
@@ -0,0 +1,22 @@
import React from "react"
import SvgIcon from "@material-ui/core/SvgIcon"
import { render } from "./../../test_helpers"
import * as Icons from "./index"
const getAllIcons = (): [string, typeof SvgIcon][] => {
let k: keyof typeof Icons
const ret: [string, typeof SvgIcon][] = []
for (k in Icons) {
ret.push([k, Icons[k]])
}
return ret
}
describe("Icons", () => {
const allIcons = getAllIcons()
it.each(allIcons)(`rendering icon %p`, (_name, Icon) => {
render(<Icon />)
})
})
+5
View File
@@ -0,0 +1,5 @@
export { CoderIcon } from "./CoderIcon"
export * from "./FileCopy"
export { Logo } from "./Logo"
export * from "./Logout"
export { WorkspacesIcon } from "./WorkspacesIcon"
@@ -0,0 +1,14 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { FullScreenLoader } from "./FullScreenLoader"
describe("FullScreenLoader", () => {
it("renders", async () => {
// When
render(<FullScreenLoader />)
// Then
const element = await screen.findByRole("progressbar")
expect(element).toBeDefined()
})
})
@@ -0,0 +1,26 @@
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
export const useStyles = makeStyles(() => ({
root: {
position: "absolute",
top: "0",
left: "0",
right: "0",
bottom: "0",
display: "flex",
justifyContent: "center",
alignItems: "center",
},
}))
export const FullScreenLoader: React.FC = () => {
const styles = useStyles()
return (
<div className={styles.root}>
<CircularProgress />
</div>
)
}
@@ -0,0 +1,31 @@
import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { fade, makeStyles } from "@material-ui/core/styles"
import React from "react"
type BorderedMenuVariant = "manage-dropdown" | "user-dropdown"
type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant
}
export const BorderedMenu: React.FC<BorderedMenuProps> = ({ children, variant, ...rest }) => {
const styles = useStyles()
return (
<Popover classes={{ root: styles.root, paper: styles.paperRoot }} data-variant={variant} {...rest}>
{children}
</Popover>
)
}
const useStyles = makeStyles((theme) => ({
root: {
paddingBottom: theme.spacing(1),
},
paperRoot: {
width: "292px",
border: `2px solid ${theme.palette.primary.main}`,
borderRadius: 7,
boxShadow: `4px 4px 0px ${fade(theme.palette.primary.main, 0.2)}`,
},
}))
+121
View File
@@ -0,0 +1,121 @@
import Badge from "@material-ui/core/Badge"
import Divider from "@material-ui/core/Divider"
import ListItemIcon from "@material-ui/core/ListItemIcon"
import ListItemText from "@material-ui/core/ListItemText"
import MenuItem from "@material-ui/core/MenuItem"
import { fade, makeStyles } from "@material-ui/core/styles"
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import React, { useState } from "react"
import { LogoutIcon } from "../Icons"
import { BorderedMenu } from "./BorderedMenu"
import { UserProfileCard } from "../User/UserProfileCard"
import { User } from "../../contexts/UserContext"
import { UserAvatar } from "../User"
export interface UserDropdownProps {
user: User
onSignOut: () => void
}
export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: UserDropdownProps) => {
const styles = useStyles()
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>()
const handleDropdownClick = (ev: React.MouseEvent<HTMLLIElement>): void => {
setAnchorEl(ev.currentTarget)
}
const onPopoverClose = () => {
setAnchorEl(undefined)
}
return (
<>
<div>
<MenuItem onClick={handleDropdownClick}>
<div className={styles.inner}>
<Badge overlap="circle">
<UserAvatar user={user} />
</Badge>
{anchorEl ? (
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
) : (
<KeyboardArrowDown className={styles.arrowIcon} />
)}
</div>
</MenuItem>
</div>
<BorderedMenu
anchorEl={anchorEl}
getContentAnchorEl={null}
open={!!anchorEl}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
marginThreshold={0}
variant="user-dropdown"
onClose={onPopoverClose}
>
<div className={styles.userInfo}>
<UserProfileCard user={user} />
<Divider className={styles.divider} />
<MenuItem className={styles.menuItem} onClick={onSignOut}>
<ListItemIcon className={styles.icon}>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Sign Out" />
</MenuItem>
</div>
</BorderedMenu>
</>
)
}
export const useStyles = makeStyles((theme) => ({
divider: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
inner: {
display: "flex",
alignItems: "center",
minWidth: 0,
maxWidth: 300,
},
userInfo: {
marginBottom: theme.spacing(1),
},
arrowIcon: {
color: fade(theme.palette.primary.contrastText, 0.7),
marginLeft: theme.spacing(1),
width: 16,
height: 16,
},
arrowIconUp: {
color: theme.palette.primary.contrastText,
},
menuItem: {
height: 44,
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
"&:hover": {
backgroundColor: fade(theme.palette.primary.light, 0.1),
transition: "background-color 0.3s ease",
},
},
icon: {
color: theme.palette.text.secondary,
},
}))
+34
View File
@@ -0,0 +1,34 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render, MockUser } from "../../test_helpers"
import { Navbar } from "./index"
describe("Navbar", () => {
const noop = () => {
return
}
it("renders content", async () => {
// When
render(<Navbar onSignOut={noop} />)
// Then
await screen.findAllByText("Coder", { exact: false })
})
it("renders profile picture for user", async () => {
// Given
const mockUser = {
...MockUser,
username: "bryan",
}
// When
render(<Navbar user={mockUser} onSignOut={noop} />)
// Then
// There should be a 'B' avatar!
const element = await screen.findByText("B")
expect(element).toBeDefined()
})
})
+70
View File
@@ -0,0 +1,70 @@
import React from "react"
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import { Link } from "react-router-dom"
import { User } from "../../contexts/UserContext"
import { Logo } from "../Icons"
import { UserDropdown } from "./UserDropdown"
export interface NavbarProps {
user?: User
onSignOut: () => void
}
export const Navbar: React.FC<NavbarProps> = ({ user, onSignOut }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.fixed}>
<Link to="/">
<Button className={styles.logo} variant="text">
<Logo fill="white" opacity={1} />
</Button>
</Link>
</div>
<div className={styles.fullWidth} />
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
position: "relative",
display: "flex",
flex: "0",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
height: "56px",
background: theme.palette.navbar.main,
marginTop: 0,
transition: "margin 150ms ease",
"@media (display-mode: standalone)": {
borderTop: `1px solid ${theme.palette.divider}`,
},
borderBottom: `1px solid #383838`,
},
fixed: {
flex: "0",
},
fullWidth: {
flex: "1",
},
logo: {
flex: "0",
height: "56px",
paddingLeft: theme.spacing(4),
paddingRight: theme.spacing(2),
borderRadius: 0,
"& svg": {
display: "block",
width: 125,
},
},
title: {
flex: "1",
textAlign: "center",
},
}))
+15
View File
@@ -0,0 +1,15 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import { Footer } from "./Footer"
describe("Footer", () => {
it("renders content", async () => {
// When
render(<Footer />)
// Then
await screen.findByText("Copyright", { exact: false })
})
})
+39
View File
@@ -0,0 +1,39 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
export const Footer: React.FC = ({ children }) => {
const styles = useFooterStyles()
return (
<div className={styles.root}>
{children}
<div className={styles.copyRight}>
<Typography color="textSecondary" variant="caption">
{`Copyright \u00a9 ${new Date().getFullYear()} Coder Technologies, Inc. All rights reserved.`}
</Typography>
</div>
<div className={styles.version}>
<Typography color="textSecondary" variant="caption">
v2 0.0.0-prototype
</Typography>
</div>
</div>
)
}
const useFooterStyles = makeStyles((theme) => ({
root: {
textAlign: "center",
marginBottom: theme.spacing(5),
flex: "0",
},
copyRight: {
backgroundColor: theme.palette.background.default,
margin: theme.spacing(0.25),
},
version: {
backgroundColor: theme.palette.background.default,
margin: theme.spacing(0.25),
},
}))
+1
View File
@@ -0,0 +1 @@
export * from "./Footer"
@@ -0,0 +1,16 @@
import { Story } from "@storybook/react"
import React from "react"
import { CliAuthToken, CliAuthTokenProps } from "./CliAuthToken"
export default {
title: "SignIn/CliAuthToken",
component: CliAuthToken,
argTypes: {
sessionToken: { control: "text", defaultValue: "some-session-token" },
},
}
const Template: Story<CliAuthTokenProps> = (args) => <CliAuthToken {...args} />
export const Example = Template.bind({})
Example.args = {}
@@ -0,0 +1,16 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import { CliAuthToken } from "./CliAuthToken"
describe("CliAuthToken", () => {
it("renders content", async () => {
// When
render(<CliAuthToken sessionToken="test-token" />)
// Then
await screen.findByText("Session Token")
await screen.findByText("test-token")
})
})
@@ -0,0 +1,29 @@
import Paper from "@material-ui/core/Paper"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { CodeExample } from "../CodeExample"
export interface CliAuthTokenProps {
sessionToken: string
}
export const CliAuthToken: React.FC<CliAuthTokenProps> = ({ sessionToken }) => {
const styles = useStyles()
return (
<Paper className={styles.container}>
<Typography className={styles.title}>Session Token</Typography>
<CodeExample code={sessionToken} />
</Paper>
)
}
const useStyles = makeStyles((theme) => ({
title: {
marginBottom: theme.spacing(2),
},
container: {
maxWidth: "680px",
padding: theme.spacing(2),
},
}))
@@ -0,0 +1,16 @@
import { Story } from "@storybook/react"
import React from "react"
import { SignInForm, SignInProps } from "./SignInForm"
export default {
title: "SignIn/SignInForm",
component: SignInForm,
argTypes: {
loginHandler: { action: "Login" },
},
}
const Template: Story<SignInProps> = (args) => <SignInForm {...args} />
export const Example = Template.bind({})
Example.args = {}
@@ -0,0 +1,82 @@
import React from "react"
import { act, fireEvent, screen, waitFor } from "@testing-library/react"
import { history, render } from "../../test_helpers"
import { SignInForm } from "./SignInForm"
describe("SignInForm", () => {
beforeEach(() => {
history.replace("/")
})
it("renders content", async () => {
// When
render(<SignInForm />)
// Then
await screen.findByText("Sign In", { exact: false })
})
it("shows an error message if SignIn fails", async () => {
// Given
const loginHandler = (_email: string, _password: string) => Promise.reject("Unacceptable credentials")
// When
// Render the component
const { container } = render(<SignInForm loginHandler={loginHandler} />)
const inputs = container.querySelectorAll("input")
// Set username / password
fireEvent.change(inputs[0], { target: { value: "test@coder.com" } })
fireEvent.change(inputs[1], { target: { value: "password" } })
// Click sign-in
const elem = await screen.findByText("Sign In")
act(() => elem.click())
// Then
// Should see an error message
const errorMessage = await screen.findByText("The username or password is incorrect.")
expect(errorMessage).toBeDefined()
})
it("redirects when login is complete", async () => {
// Given
const loginHandler = (_email: string, _password: string) => Promise.resolve()
// When
// Render the component
const { container } = render(<SignInForm loginHandler={loginHandler} />)
// Set user / password
const inputs = container.querySelectorAll("input")
fireEvent.change(inputs[0], { target: { value: "test@coder.com" } })
fireEvent.change(inputs[1], { target: { value: "password" } })
// Click sign-in
const elem = await screen.findByText("Sign In")
act(() => elem.click())
// Then
// Should redirect because login was successful
await waitFor(() => expect(history.location.pathname).toEqual("/"))
})
it("respects ?redirect query parameter when complete", async () => {
// Given
const loginHandler = (_email: string, _password: string) => Promise.resolve()
// Set a path to redirect to after login is successful
history.replace("/login?redirect=%2Fsome%2Fother%2Fpath")
// When
// Render the component
const { container } = render(<SignInForm loginHandler={loginHandler} />)
// Set user / password
const inputs = container.querySelectorAll("input")
fireEvent.change(inputs[0], { target: { value: "test@coder.com" } })
fireEvent.change(inputs[1], { target: { value: "password" } })
// Click sign-in
const elem = await screen.findByText("Sign In")
act(() => elem.click())
// Then
// Should redirect to /some/other/path because ?redirect was specified and login was successful
await waitFor(() => expect(history.location.pathname).toEqual("/some/other/path"))
})
})
+131
View File
@@ -0,0 +1,131 @@
import { makeStyles } from "@material-ui/core/styles"
import { FormikContextType, useFormik } from "formik"
import { Location } from "history"
import { useNavigate, useLocation } from "react-router-dom"
import React from "react"
import { useSWRConfig } from "swr"
import * as Yup from "yup"
import { Welcome } from "./Welcome"
import { FormTextField } from "../Form"
import * as API from "./../../api"
import { LoadingButton } from "./../Button"
/**
* BuiltInAuthFormValues describes a form using built-in (email/password)
* authentication. This form may not always be present depending on external
* auth providers available and administrative configurations
*/
interface BuiltInAuthFormValues {
email: string
password: string
}
const validationSchema = Yup.object({
email: Yup.string().required("Email is required."),
password: Yup.string(),
})
const useStyles = makeStyles((theme) => ({
loginBtnWrapper: {
marginTop: theme.spacing(6),
borderTop: `1px solid ${theme.palette.action.disabled}`,
paddingTop: theme.spacing(3),
},
loginTextField: {
marginTop: theme.spacing(2),
},
submitBtn: {
marginTop: theme.spacing(2),
},
}))
export interface SignInProps {
loginHandler?: (email: string, password: string) => Promise<void>
}
export const SignInForm: React.FC<SignInProps> = ({
loginHandler = (email: string, password: string) => API.login(email, password),
}) => {
const navigate = useNavigate()
const location = useLocation()
const styles = useStyles()
const { mutate } = useSWRConfig()
const form: FormikContextType<BuiltInAuthFormValues> = useFormik<BuiltInAuthFormValues>({
initialValues: {
email: "",
password: "",
},
validationSchema,
onSubmit: async ({ email, password }, helpers) => {
try {
await loginHandler(email, password)
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
const redirect = getRedirectFromLocation(location)
await navigate(redirect)
} catch (err) {
helpers.setFieldError("password", "The username or password is incorrect.")
}
},
})
return (
<>
<Welcome />
<form onSubmit={form.handleSubmit}>
<div>
<FormTextField
autoComplete="email"
autoFocus
className={styles.loginTextField}
eventTransform={(email: string) => email.trim()}
form={form}
formFieldName="email"
fullWidth
inputProps={{
id: "signin-form-inpt-email",
}}
placeholder="Email"
variant="outlined"
/>
<FormTextField
autoComplete="current-password"
className={styles.loginTextField}
form={form}
formFieldName="password"
fullWidth
inputProps={{
id: "signin-form-inpt-password",
}}
isPassword
placeholder="Password"
variant="outlined"
/>
</div>
<div className={styles.submitBtn}>
<LoadingButton
color="primary"
loading={form.isSubmitting}
fullWidth
id="signin-form-submit"
type="submit"
variant="contained"
>
Sign In
</LoadingButton>
</div>
</form>
</>
)
}
const getRedirectFromLocation = (location: Location) => {
const defaultRedirect = "/"
const searchParams = new URLSearchParams(location.search)
const redirect = searchParams.get("redirect")
return redirect ? redirect : defaultRedirect
}
+42
View File
@@ -0,0 +1,42 @@
import { makeStyles } from "@material-ui/core/styles"
import { CoderIcon } from "../Icons"
import React from "react"
import Typography from "@material-ui/core/Typography"
export const Welcome: React.FC = () => {
const styles = useStyles()
return (
<div>
<div className={styles.logoBox}>
<CoderIcon className={styles.logo} />
</div>
<Typography className={styles.title} variant="h1">
<>
Welcome to
<br />
Coder
</>
</Typography>
</div>
)
}
const useStyles = makeStyles((theme) => ({
logoBox: {
display: "flex",
justifyContent: "center",
},
logo: {
width: 80,
height: 56,
color: theme.palette.text.primary,
},
title: {
fontSize: 24,
letterSpacing: -0.3,
marginBottom: theme.spacing(3),
marginTop: theme.spacing(6),
textAlign: "center",
},
}))
+2
View File
@@ -0,0 +1,2 @@
export * from "./CliAuthToken"
export * from "./SignInForm"
+82
View File
@@ -0,0 +1,82 @@
import { screen } from "@testing-library/react"
import { render } from "./../../test_helpers"
import React from "react"
import { Table, Column } from "./Table"
interface TestData {
name: string
description: string
}
const columns: Column<TestData>[] = [
{
name: "Name",
key: "name",
},
{
name: "Description",
key: "description",
// For description, we'll test out the custom renderer path
renderer: (field) => <span>{"!!" + field + "!!"}</span>,
},
]
const data: TestData[] = [{ name: "AName", description: "ADescription" }]
const emptyData: TestData[] = []
describe("Table", () => {
it("renders empty state if empty", async () => {
// Given
const emptyState = <div>Empty Table!</div>
const tableProps = {
title: "TitleTest",
data: emptyData,
columns,
emptyState,
}
// When
render(<Table {...tableProps} />)
// Then
// Since there are no items, our empty state should've rendered
const emptyTextElement = await screen.findByText("Empty Table!")
expect(emptyTextElement).toBeDefined()
})
it("renders title", async () => {
// Given
const tableProps = {
title: "TitleTest",
data: emptyData,
columns,
}
// When
render(<Table {...tableProps} />)
// Then
const titleElement = await screen.findByText("TitleTest")
expect(titleElement).toBeDefined()
})
it("renders data fields with default renderer if none provided", async () => {
// Given
const tableProps = {
title: "TitleTest",
data,
columns,
}
// When
render(<Table {...tableProps} />)
// Then
// Check that the 'name' was rendered, with the default renderer
const nameElement = await screen.findByText("AName")
expect(nameElement).toBeDefined()
// ...and the description used our custom rendered
const descriptionElement = await screen.findByText("!!ADescription!!")
expect(descriptionElement).toBeDefined()
})
})
+88
View File
@@ -0,0 +1,88 @@
import React from "react"
import Box from "@material-ui/core/Box"
import MuiTable from "@material-ui/core/Table"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import TableCell from "@material-ui/core/TableCell"
import { TableTitle } from "./TableTitle"
import { TableHeaders } from "./TableHeaders"
import TableBody from "@material-ui/core/TableBody"
export interface Column<T> {
/**
* The field of type T that this column is associated with
*/
key: keyof T
/**
* Friendly name of the field, shown in headers
*/
name: string
/**
* Custom render for the field inside the table
*/
renderer?: (field: T[keyof T], data: T) => React.ReactElement
}
export interface TableProps<T> {
/**
* Title of the table
*/
title?: string
/**
* A list of columns, including the name and the key
*/
columns: Column<T>[]
/**
* The actual data to show in the table
*/
data: T[]
/**
* Optional empty state UI when the data is empty
*/
emptyState?: React.ReactElement
}
export const Table = <T,>({ columns, data, emptyState, title }: TableProps<T>): React.ReactElement => {
const columnNames = columns.map(({ name }) => name)
const body = renderTableBody(data, columns, emptyState)
return (
<MuiTable>
<TableHead>
{title && <TableTitle title={title} />}
<TableHeaders columns={columnNames} />
</TableHead>
{body}
</MuiTable>
)
}
/**
* Helper function to render the table data, falling back to an empty state if available
*/
const renderTableBody = <T,>(data: T[], columns: Column<T>[], emptyState?: React.ReactElement) => {
if (data.length > 0) {
const rows = data.map((item: T, index) => {
const cells = columns.map((column) => {
if (column.renderer) {
return <TableCell key={String(column.key)}>{column.renderer(item[column.key], item)}</TableCell>
} else {
return <TableCell key={String(column.key)}>{String(item[column.key]).toString()}</TableCell>
}
})
return <TableRow key={index}>{cells}</TableRow>
})
return <TableBody>{rows}</TableBody>
} else {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>{emptyState}</Box>
</TableCell>
</TableRow>
</TableBody>
)
}
}
@@ -0,0 +1,35 @@
import React from "react"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import { makeStyles } from "@material-ui/core/styles"
export interface TableHeadersProps {
columns: string[]
}
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns }) => {
const styles = useStyles()
return (
<TableRow className={styles.root}>
{columns.map((c, idx) => (
<TableCell key={idx} size="small">
{c}
</TableCell>
))}
</TableRow>
)
}
export const useStyles = makeStyles((theme) => ({
root: {
fontSize: 12,
fontWeight: 500,
lineHeight: "16px",
letterSpacing: 1.5,
textTransform: "uppercase",
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
color: theme.palette.text.secondary,
backgroundColor: theme.palette.background.default,
},
}))
+65
View File
@@ -0,0 +1,65 @@
import Box from "@material-ui/core/Box"
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import Typography from "@material-ui/core/Typography"
import * as React from "react"
export interface TableTitleProps {
/** A title to display */
readonly title?: React.ReactNode
/** Arbitrary node to display to the right of the title. */
readonly details?: React.ReactNode
}
/**
* Component that encapsulates all of the pieces that sit on the top of a table.
*/
export const TableTitle: React.FC<TableTitleProps> = ({ title, details }) => {
const styles = useStyles()
return (
<TableRow>
<TableCell colSpan={9999} className={styles.cell}>
<Box className={`${styles.container} ${details ? "-details" : ""}`}>
{title && (
<Typography variant="h6" className={styles.title}>
{title}
</Typography>
)}
{details && <div className={styles.details}>{details}</div>}
</Box>
</TableCell>
</TableRow>
)
}
const useStyles = makeStyles((theme) => ({
cell: {
background: "none",
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
container: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
title: {
fontSize: theme.typography.h5.fontSize,
fontWeight: 500,
color: theme.palette.text.primary,
textTransform: "none",
letterSpacing: "normal",
},
details: {
alignItems: "center",
display: "flex",
justifyContent: "flex-end",
letterSpacing: "normal",
margin: `0 ${theme.spacing(2)}px`,
[theme.breakpoints.down("sm")]: {
margin: `${theme.spacing(1)}px 0 0 0`,
},
},
}))
+1
View File
@@ -0,0 +1 @@
export * from "./Table"
+25
View File
@@ -0,0 +1,25 @@
import Avatar from "@material-ui/core/Avatar"
import React from "react"
import { User } from "../../contexts/UserContext"
export interface UserAvatarProps {
user: User
className?: string
}
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, className }) => {
return <Avatar className={className}>{firstLetter(user.username)}</Avatar>
}
/**
* `firstLetter` extracts the first character and returns it, uppercased
*
* If the string is empty or null, returns an empty string
*/
export const firstLetter = (str: string): string => {
if (str && str.length > 0) {
return str[0].toLocaleUpperCase()
}
return ""
}
@@ -0,0 +1,58 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { User } from "../../contexts/UserContext"
import { UserAvatar } from "./UserAvatar"
interface UserProfileCardProps {
user: User
}
export const UserProfileCard: React.FC<UserProfileCardProps> = ({ user }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.avatarContainer}>
<UserAvatar className={styles.avatar} user={user} />
</div>
<Typography className={styles.userName}>{user.username}</Typography>
<Typography className={styles.userEmail}>{user.email}</Typography>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
paddingTop: theme.spacing(3),
textAlign: "center",
},
avatarContainer: {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
avatar: {
width: 48,
height: 48,
borderRadius: "50%",
marginBottom: theme.spacing(1),
transition: `transform .2s`,
"&:hover": {
transform: `scale(1.1)`,
},
},
userName: {
fontSize: 16,
marginBottom: theme.spacing(0.5),
},
userEmail: {
fontSize: 14,
letterSpacing: 0.2,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1.5),
},
}))
+2
View File
@@ -0,0 +1,2 @@
export * from "./UserAvatar"
export * from "./UserProfileCard"
@@ -0,0 +1,19 @@
import { Story } from "@storybook/react"
import React from "react"
import { Workspace, WorkspaceProps } from "./Workspace"
import { MockOrganization, MockProject, MockWorkspace } from "../../test_helpers"
export default {
title: "Workspace",
component: Workspace,
argTypes: {},
}
const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />
export const Example = Template.bind({})
Example.args = {
organization: MockOrganization,
project: MockProject,
workspace: MockWorkspace,
}
@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react"
import React from "react"
import { Workspace } from "./Workspace"
import { MockOrganization, MockProject, MockWorkspace, render } from "../../test_helpers"
describe("Workspace", () => {
it("renders", async () => {
// When
render(<Workspace organization={MockOrganization} project={MockProject} workspace={MockWorkspace} />)
// Then
const element = await screen.findByText(MockWorkspace.name)
expect(element).toBeDefined()
})
})
+135
View File
@@ -0,0 +1,135 @@
import Box from "@material-ui/core/Box"
import Paper from "@material-ui/core/Paper"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import CloudCircleIcon from "@material-ui/icons/CloudCircle"
import { Link } from "react-router-dom"
import React from "react"
import * as Constants from "./constants"
import * as API from "../../api"
import { WorkspaceSection } from "./WorkspaceSection"
export interface WorkspaceProps {
organization: API.Organization
workspace: API.Workspace
project: API.Project
}
/**
* Workspace is the top-level component for viewing an individual workspace
*/
export const Workspace: React.FC<WorkspaceProps> = ({ organization, project, workspace }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.vertical}>
<WorkspaceHeader organization={organization} project={project} workspace={workspace} />
<div className={styles.horizontal}>
<div className={styles.sidebarContainer}>
<WorkspaceSection title="Applications">
<Placeholder />
</WorkspaceSection>
<WorkspaceSection title="Dev URLs">
<Placeholder />
</WorkspaceSection>
<WorkspaceSection title="Resources">
<Placeholder />
</WorkspaceSection>
</div>
<div className={styles.timelineContainer}>
<WorkspaceSection title="Timeline">
<div
className={styles.vertical}
style={{ justifyContent: "center", alignItems: "center", height: "300px" }}
>
<Placeholder />
</div>
</WorkspaceSection>
</div>
</div>
</div>
</div>
)
}
/**
* Component for the header at the top of the workspace page
*/
export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ organization, project, workspace }) => {
const styles = useStyles()
const projectLink = `/projects/${organization.name}/${project.name}`
return (
<Paper elevation={0} className={styles.section}>
<div className={styles.horizontal}>
<WorkspaceHeroIcon />
<div className={styles.vertical}>
<Typography variant="h4">{workspace.name}</Typography>
<Typography variant="body2" color="textSecondary">
<Link to={projectLink}>{project.name}</Link>
</Typography>
</div>
</div>
</Paper>
)
}
/**
* Component to render the 'Hero Icon' in the header of a workspace
*/
export const WorkspaceHeroIcon: React.FC = () => {
return (
<Box mr="1em">
<CloudCircleIcon width={Constants.TitleIconSize} height={Constants.TitleIconSize} />
</Box>
)
}
/**
* Temporary placeholder component until we have the sections implemented
* Can be removed once the Workspace page has all the necessary sections
*/
const Placeholder: React.FC = () => {
return (
<div style={{ textAlign: "center", opacity: "0.5" }}>
<Typography variant="caption">Not yet implemented</Typography>
</div>
)
}
export const useStyles = makeStyles((theme) => {
return {
root: {
display: "flex",
flexDirection: "column",
},
horizontal: {
display: "flex",
flexDirection: "row",
},
vertical: {
display: "flex",
flexDirection: "column",
},
section: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: Constants.CardRadius,
padding: Constants.CardPadding,
margin: theme.spacing(1),
},
sidebarContainer: {
display: "flex",
flexDirection: "column",
flex: "0 0 350px",
},
timelineContainer: {
flex: 1,
},
icon: {
width: Constants.TitleIconSize,
height: Constants.TitleIconSize,
},
}
})
@@ -0,0 +1,48 @@
import Paper from "@material-ui/core/Paper"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { CardPadding, CardRadius } from "./constants"
export interface WorkspaceSectionProps {
title: string
}
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children }) => {
const styles = useStyles()
return (
<Paper elevation={0} className={styles.root}>
<div className={styles.headerContainer}>
<div className={styles.header}>
<Typography variant="h6">{title}</Typography>
</div>
</div>
<div className={styles.contents}>{children}</div>
</Paper>
)
}
const useStyles = makeStyles((theme) => ({
root: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: CardRadius,
margin: theme.spacing(1),
},
headerContainer: {
borderBottom: `1px solid ${theme.palette.divider}`,
},
contents: {
margin: theme.spacing(2),
},
header: {
alignItems: "center",
display: "flex",
flexDirection: "row",
marginBottom: theme.spacing(1),
marginTop: theme.spacing(1),
paddingLeft: CardPadding + theme.spacing(1),
paddingRight: CardPadding / 2,
},
}))
@@ -0,0 +1,3 @@
export const TitleIconSize = 48
export const CardRadius = 8
export const CardPadding = 20
+1
View File
@@ -0,0 +1 @@
export * from "./Workspace"
+3
View File
@@ -0,0 +1,3 @@
export * from "./Button"
export * from "./EmptyState"
export * from "./Page"