chore: format code with semicolons when using prettier (#9555)

This commit is contained in:
Kayla Washburn 2023-09-06 12:59:26 -06:00 committed by GitHub
parent bef38b8413
commit 988c9af015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
664 changed files with 17537 additions and 17407 deletions

View File

@ -1,9 +1,8 @@
# This config file is used in conjunction with `.editorconfig` to specify
# formatting for prettier-supported files. See `.editorconfig` and
# `site/.editorconfig`for whitespace formatting options.
# `site/.editorconfig` for whitespace formatting options.
printWidth: 80
proseWrap: always
semi: false
trailingComma: all
useTabs: false
tabWidth: 2

View File

@ -128,9 +128,9 @@ export const getAgentListeningPorts = async (
): Promise<TypesGen.ListeningPortsResponse> => {
const response = await axios.get(
`/api/v2/workspaceagents/${agentID}/listening-ports`,
)
return response.data
}
);
return response.data;
};
```
Sometimes, a FE operation can have multiple API calls so it is ok to wrap it as
@ -140,9 +140,9 @@ a single function.
export const updateWorkspaceVersion = async (
workspace: TypesGen.Workspace,
): Promise<TypesGen.WorkspaceBuild> => {
const template = await getTemplate(workspace.template_id)
return startWorkspace(workspace.id, template.active_version_id)
}
const template = await getTemplate(workspace.template_id);
return startWorkspace(workspace.id, template.active_version_id);
};
```
If you need more granular errors or control, you may should consider keep them
@ -242,14 +242,14 @@ instead of using `screen.getByRole("button")` directly we could do
slow.
```tsx
user.click(screen.getByRole("button"))
user.click(screen.getByRole("button"));
```
✅ Better. We can limit the number of elements we are querying.
```tsx
const form = screen.getByTestId("form")
user.click(within(form).getByRole("button"))
const form = screen.getByTestId("form");
user.click(within(form).getByRole("button"));
```
#### `jest.spyOn` with the API is not working

View File

@ -2,6 +2,6 @@
const nextConfig = {
reactStrictMode: true,
trailingSlash: true,
}
};
module.exports = nextConfig
module.exports = nextConfig;

View File

@ -24,57 +24,57 @@ import {
Tr,
UnorderedList,
useDisclosure,
} from "@chakra-ui/react"
import fm from "front-matter"
import { readFileSync } from "fs"
import _ from "lodash"
import { GetStaticPaths, GetStaticProps, NextPage } from "next"
import Head from "next/head"
import NextLink from "next/link"
import { useRouter } from "next/router"
import path from "path"
import { MdMenu } from "react-icons/md"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import remarkGfm from "remark-gfm"
} from "@chakra-ui/react";
import fm from "front-matter";
import { readFileSync } from "fs";
import _ from "lodash";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import Head from "next/head";
import NextLink from "next/link";
import { useRouter } from "next/router";
import path from "path";
import { MdMenu } from "react-icons/md";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
type FilePath = string
type UrlPath = string
type FilePath = string;
type UrlPath = string;
type Route = {
path: FilePath
title: string
description?: string
children?: Route[]
}
type Manifest = { versions: string[]; routes: Route[] }
type NavItem = { title: string; path: UrlPath; children?: NavItem[] }
type Nav = NavItem[]
path: FilePath;
title: string;
description?: string;
children?: Route[];
};
type Manifest = { versions: string[]; routes: Route[] };
type NavItem = { title: string; path: UrlPath; children?: NavItem[] };
type Nav = NavItem[];
const readContentFile = (filePath: string) => {
const baseDir = process.cwd()
const docsPath = path.join(baseDir, "..", "docs")
return readFileSync(path.join(docsPath, filePath), { encoding: "utf-8" })
}
const baseDir = process.cwd();
const docsPath = path.join(baseDir, "..", "docs");
return readFileSync(path.join(docsPath, filePath), { encoding: "utf-8" });
};
const removeTrailingSlash = (path: string) => path.replace(/\/+$/, "")
const removeTrailingSlash = (path: string) => path.replace(/\/+$/, "");
const removeMkdExtension = (path: string) => path.replace(/\.md/g, "")
const removeMkdExtension = (path: string) => path.replace(/\.md/g, "");
const removeIndexFilename = (path: string) => {
if (path.endsWith("index")) {
path = path.replace("index", "")
path = path.replace("index", "");
}
return path
}
return path;
};
const removeREADMEName = (path: string) => {
if (path.startsWith("README")) {
path = path.replace("README", "")
path = path.replace("README", "");
}
return path
}
return path;
};
// transformLinkUri converts the links in the markdown file to
// href html links. All index page routes are the directory name, and all
@ -87,142 +87,142 @@ const removeREADMEName = (path: string) => {
// file.md -> ../file-next-to-file = ../file-next-to-file
const transformLinkUriSource = (sourceFile: string) => {
return (href = "") => {
const isExternal = href.startsWith("http") || href.startsWith("https")
const isExternal = href.startsWith("http") || href.startsWith("https");
if (!isExternal) {
// Remove .md form the path
href = removeMkdExtension(href)
href = removeMkdExtension(href);
// Add the extra '..' if not an index file.
sourceFile = removeMkdExtension(sourceFile)
sourceFile = removeMkdExtension(sourceFile);
if (!sourceFile.endsWith("index")) {
href = "../" + href
href = "../" + href;
}
// Remove the index path
href = removeIndexFilename(href)
href = removeREADMEName(href)
href = removeIndexFilename(href);
href = removeREADMEName(href);
}
return href
}
}
return href;
};
};
const transformFilePathToUrlPath = (filePath: string) => {
// Remove markdown extension
let urlPath = removeMkdExtension(filePath)
let urlPath = removeMkdExtension(filePath);
// Remove relative path
if (urlPath.startsWith("./")) {
urlPath = urlPath.replace("./", "")
urlPath = urlPath.replace("./", "");
}
// Remove index from the root file
urlPath = removeIndexFilename(urlPath)
urlPath = removeREADMEName(urlPath)
urlPath = removeIndexFilename(urlPath);
urlPath = removeREADMEName(urlPath);
// Remove trailing slash
if (urlPath.endsWith("/")) {
urlPath = removeTrailingSlash(urlPath)
urlPath = removeTrailingSlash(urlPath);
}
return urlPath
}
return urlPath;
};
const mapRoutes = (manifest: Manifest): Record<UrlPath, Route> => {
const paths: Record<UrlPath, Route> = {}
const paths: Record<UrlPath, Route> = {};
const addPaths = (routes: Route[]) => {
for (const route of routes) {
paths[transformFilePathToUrlPath(route.path)] = route
paths[transformFilePathToUrlPath(route.path)] = route;
if (route.children) {
addPaths(route.children)
addPaths(route.children);
}
}
}
};
addPaths(manifest.routes)
addPaths(manifest.routes);
return paths
}
return paths;
};
let manifest: Manifest | undefined
let manifest: Manifest | undefined;
const getManifest = () => {
if (manifest) {
return manifest
return manifest;
}
const manifestContent = readContentFile("manifest.json")
manifest = JSON.parse(manifestContent) as Manifest
return manifest
}
const manifestContent = readContentFile("manifest.json");
manifest = JSON.parse(manifestContent) as Manifest;
return manifest;
};
let navigation: Nav | undefined
let navigation: Nav | undefined;
const getNavigation = (manifest: Manifest): Nav => {
if (navigation) {
return navigation
return navigation;
}
const getNavItem = (route: Route, parentPath?: UrlPath): NavItem => {
const path = parentPath
? `${parentPath}/${transformFilePathToUrlPath(route.path)}`
: transformFilePathToUrlPath(route.path)
: transformFilePathToUrlPath(route.path);
const navItem: NavItem = {
title: route.title,
path,
}
};
if (route.children) {
navItem.children = []
navItem.children = [];
for (const childRoute of route.children) {
navItem.children.push(getNavItem(childRoute))
navItem.children.push(getNavItem(childRoute));
}
}
return navItem
}
return navItem;
};
navigation = []
navigation = [];
for (const route of manifest.routes) {
navigation.push(getNavItem(route))
navigation.push(getNavItem(route));
}
return navigation
}
return navigation;
};
const removeHtmlComments = (string: string) => {
return string.replace(/<!--[\s\S]*?-->/g, "")
}
return string.replace(/<!--[\s\S]*?-->/g, "");
};
export const getStaticPaths: GetStaticPaths = () => {
const manifest = getManifest()
const routes = mapRoutes(manifest)
const manifest = getManifest();
const routes = mapRoutes(manifest);
const paths = Object.keys(routes).map((urlPath) => ({
params: { slug: urlPath.split("/") },
}))
}));
return {
paths,
fallback: false,
}
}
};
};
export const getStaticProps: GetStaticProps = (context) => {
// When it is home page, the slug is undefined because there is no url path
// so we make it an empty string to work good with the mapRoutes
const { slug = [""] } = context.params as { slug: string[] }
const manifest = getManifest()
const routes = mapRoutes(manifest)
const urlPath = slug.join("/")
const route = routes[urlPath]
const { body } = fm(readContentFile(route.path))
const { slug = [""] } = context.params as { slug: string[] };
const manifest = getManifest();
const routes = mapRoutes(manifest);
const urlPath = slug.join("/");
const route = routes[urlPath];
const { body } = fm(readContentFile(route.path));
// Serialize MDX to support custom components
const content = removeHtmlComments(body)
const navigation = getNavigation(manifest)
const version = manifest.versions[0]
const content = removeHtmlComments(body);
const navigation = getNavigation(manifest);
const version = manifest.versions[0];
return {
props: {
@ -231,25 +231,26 @@ export const getStaticProps: GetStaticProps = (context) => {
route,
version,
},
}
}
};
};
const SidebarNavItem: React.FC<{ item: NavItem; nav: Nav }> = ({
item,
nav,
}) => {
const router = useRouter()
let isActive = router.asPath.startsWith(`/${item.path}`)
const router = useRouter();
let isActive = router.asPath.startsWith(`/${item.path}`);
// Special case to handle the home path
if (item.path === "") {
isActive = router.asPath === "/"
isActive = router.asPath === "/";
// Special case to handle the home path children
const homeNav = nav.find((navItem) => navItem.path === "") as NavItem
const homeNavPaths = homeNav.children?.map((item) => `/${item.path}/`) ?? []
const homeNav = nav.find((navItem) => navItem.path === "") as NavItem;
const homeNavPaths =
homeNav.children?.map((item) => `/${item.path}/`) ?? [];
if (homeNavPaths.includes(router.asPath)) {
isActive = true
isActive = true;
}
}
@ -280,8 +281,8 @@ const SidebarNavItem: React.FC<{ item: NavItem; nav: Nav }> = ({
</Grid>
)}
</Box>
)
}
);
};
const SidebarNav: React.FC<{ nav: Nav; version: string } & GridProps> = ({
nav,
@ -312,14 +313,14 @@ const SidebarNav: React.FC<{ nav: Nav; version: string } & GridProps> = ({
<SidebarNavItem key={navItem.path} item={navItem} nav={nav} />
))}
</Grid>
)
}
);
};
const MobileNavbar: React.FC<{ nav: Nav; version: string }> = ({
nav,
version,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
@ -347,26 +348,26 @@ const MobileNavbar: React.FC<{ nav: Nav; version: string }> = ({
</DrawerContent>
</Drawer>
</>
)
}
);
};
const slugifyTitle = (title: string) => {
return _.kebabCase(title.toLowerCase())
}
return _.kebabCase(title.toLowerCase());
};
const getImageUrl = (src: string | undefined) => {
if (src === undefined) {
return ""
return "";
}
const assetPath = src.split("images/")[1]
return `/images/${assetPath}`
}
const assetPath = src.split("images/")[1];
return `/images/${assetPath}`;
};
const DocsPage: NextPage<{
content: string
navigation: Nav
route: Route
version: string
content: string;
navigation: Nav;
route: Route;
version: string;
}> = ({ content, navigation, route, version }) => {
return (
<>
@ -486,7 +487,7 @@ const DocsPage: NextPage<{
),
a: ({ children, href = "" }) => {
const isExternal =
href.startsWith("http") || href.startsWith("https")
href.startsWith("http") || href.startsWith("https");
return (
<Link
@ -497,7 +498,7 @@ const DocsPage: NextPage<{
>
{children}
</Link>
)
);
},
code: ({ node, ...props }) => (
<Code {...props} bgColor="gray.100" />
@ -538,7 +539,7 @@ const DocsPage: NextPage<{
</Box>
</Box>
</>
)
}
);
};
export default DocsPage
export default DocsPage;

View File

@ -1,6 +1,6 @@
import { ChakraProvider, extendTheme } from "@chakra-ui/react"
import type { AppProps } from "next/app"
import Head from "next/head"
import { ChakraProvider, extendTheme } from "@chakra-ui/react";
import type { AppProps } from "next/app";
import Head from "next/head";
const theme = extendTheme({
styles: {
@ -10,7 +10,7 @@ const theme = extendTheme({
},
},
},
})
});
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
return (
@ -23,7 +23,7 @@ const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
<Component {...pageProps} />
</ChakraProvider>
</>
)
}
);
};
export default MyApp
export default MyApp;

View File

@ -2,10 +2,9 @@
# This config file is used in conjunction with `.editorconfig` to specify
# formatting for prettier-supported files. See `.editorconfig` and
# `site/.editorconfig`for whitespace formatting options.
# `site/.editorconfig` for whitespace formatting options.
printWidth: 80
proseWrap: always
semi: false
trailingComma: all
useTabs: false
tabWidth: 2

View File

@ -1,5 +1,5 @@
import turbosnap from "vite-plugin-turbosnap"
import { mergeConfig } from "vite"
import turbosnap from "vite-plugin-turbosnap";
import { mergeConfig } from "vite";
module.exports = {
stories: ["../src/**/*.stories.tsx"],
@ -15,7 +15,7 @@ module.exports = {
options: {},
},
async viteFinal(config, { configType }) {
config.plugins = config.plugins || []
config.plugins = config.plugins || [];
// return the customized config
if (configType === "PRODUCTION") {
// ignore @ts-ignore because it's not in the vite types yet
@ -23,8 +23,8 @@ module.exports = {
turbosnap({
rootDir: config.root || "",
}),
)
);
}
return config
return config;
},
}
};

View File

@ -1,11 +1,11 @@
import CssBaseline from "@mui/material/CssBaseline"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import { withRouter } from "storybook-addon-react-router-v6"
import { HelmetProvider } from "react-helmet-async"
import { dark } from "../src/theme"
import "../src/theme/globalFonts"
import "../src/i18n"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import CssBaseline from "@mui/material/CssBaseline";
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
import { withRouter } from "storybook-addon-react-router-v6";
import { HelmetProvider } from "react-helmet-async";
import { dark } from "../src/theme";
import "../src/theme/globalFonts";
import "../src/i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export const decorators = [
(Story) => (
@ -22,16 +22,16 @@ export const decorators = [
<HelmetProvider>
<Story />
</HelmetProvider>
)
);
},
(Story) => {
return (
<QueryClientProvider client={new QueryClient()}>
<Story />
</QueryClientProvider>
)
);
},
]
];
export const parameters = {
actions: {
@ -44,4 +44,4 @@ export const parameters = {
date: /Date$/,
},
},
}
};

View File

@ -1,12 +1,12 @@
// Default port from the server
export const defaultPort = 3000
export const prometheusPort = 2114
export const pprofPort = 6061
export const defaultPort = 3000;
export const prometheusPort = 2114;
export const pprofPort = 6061;
// Credentials for the first user
export const username = "admin"
export const password = "SomeSecurePassword!"
export const email = "admin@coder.com"
export const username = "admin";
export const password = "SomeSecurePassword!";
export const email = "admin@coder.com";
export const gitAuth = {
deviceProvider: "device",
@ -22,4 +22,4 @@ export const gitAuth = {
codePath: "/code",
validatePath: "/validate",
installationsPath: "/installations",
}
};

View File

@ -1,18 +1,18 @@
import { test, expect } from "@playwright/test"
import * as constants from "./constants"
import { STORAGE_STATE } from "./playwright.config"
import { Language } from "../src/pages/CreateUserPage/CreateUserForm"
import { test, expect } from "@playwright/test";
import * as constants from "./constants";
import { STORAGE_STATE } from "./playwright.config";
import { Language } from "../src/pages/CreateUserPage/CreateUserForm";
test("create first user", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" })
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByLabel(Language.usernameLabel).fill(constants.username)
await page.getByLabel(Language.emailLabel).fill(constants.email)
await page.getByLabel(Language.passwordLabel).fill(constants.password)
await page.getByTestId("trial").click()
await page.getByTestId("create").click()
await page.getByLabel(Language.usernameLabel).fill(constants.username);
await page.getByLabel(Language.emailLabel).fill(constants.email);
await page.getByLabel(Language.passwordLabel).fill(constants.password);
await page.getByTestId("trial").click();
await page.getByTestId("create").click();
await expect(page).toHaveURL("/workspaces")
await expect(page).toHaveURL("/workspaces");
await page.context().storageState({ path: STORAGE_STATE })
})
await page.context().storageState({ path: STORAGE_STATE });
});

View File

@ -1,9 +1,9 @@
import { expect, Page } from "@playwright/test"
import { ChildProcess, exec, spawn } from "child_process"
import { randomUUID } from "crypto"
import path from "path"
import express from "express"
import { TarWriter } from "utils/tar"
import { expect, Page } from "@playwright/test";
import { ChildProcess, exec, spawn } from "child_process";
import { randomUUID } from "crypto";
import path from "path";
import express from "express";
import { TarWriter } from "utils/tar";
import {
Agent,
App,
@ -14,13 +14,13 @@ import {
ApplyComplete,
Resource,
RichParameter,
} from "./provisionerGenerated"
import { prometheusPort, pprofPort } from "./constants"
import { port } from "./playwright.config"
import * as ssh from "ssh2"
import { Duplex } from "stream"
import { WorkspaceBuildParameter } from "api/typesGenerated"
import axios from "axios"
} from "./provisionerGenerated";
import { prometheusPort, pprofPort } from "./constants";
import { port } from "./playwright.config";
import * as ssh from "ssh2";
import { Duplex } from "stream";
import { WorkspaceBuildParameter } from "api/typesGenerated";
import axios from "axios";
// createWorkspace creates a workspace for a template.
// It does not wait for it to be running, but it does navigate to the page.
@ -32,25 +32,25 @@ export const createWorkspace = async (
): Promise<string> => {
await page.goto("/templates/" + templateName + "/workspace", {
waitUntil: "domcontentloaded",
})
await expect(page).toHaveURL("/templates/" + templateName + "/workspace")
});
await expect(page).toHaveURL("/templates/" + templateName + "/workspace");
const name = randomName()
await page.getByLabel("name").fill(name)
const name = randomName();
await page.getByLabel("name").fill(name);
await fillParameters(page, richParameters, buildParameters)
await page.getByTestId("form-submit").click()
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await expect(page).toHaveURL("/@admin/" + name)
await expect(page).toHaveURL("/@admin/" + name);
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
)
return name
}
);
return name;
};
export const verifyParameters = async (
page: Page,
@ -60,56 +60,56 @@ export const verifyParameters = async (
) => {
await page.goto("/@admin/" + workspaceName + "/settings/parameters", {
waitUntil: "domcontentloaded",
})
});
await expect(page).toHaveURL(
"/@admin/" + workspaceName + "/settings/parameters",
)
);
for (const buildParameter of expectedBuildParameters) {
const richParameter = richParameters.find(
(richParam) => richParam.name === buildParameter.name,
)
);
if (!richParameter) {
throw new Error(
"build parameter is expected to be present in rich parameter schema",
)
);
}
const parameterLabel = await page.waitForSelector(
"[data-testid='parameter-field-" + richParameter.name + "']",
{ state: "visible" },
)
);
const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled"
const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled";
if (richParameter.type === "bool") {
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked" +
muiDisabled +
" input",
)
const value = await parameterField.inputValue()
expect(value).toEqual(buildParameter.value)
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
} else if (richParameter.options.length > 0) {
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked" +
muiDisabled +
" input",
)
const value = await parameterField.inputValue()
expect(value).toEqual(buildParameter.value)
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
} else if (richParameter.type === "list(string)") {
throw new Error("not implemented yet") // FIXME
throw new Error("not implemented yet"); // FIXME
} else {
// text or number
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-text'] input" + muiDisabled,
)
const value = await parameterField.inputValue()
expect(value).toEqual(buildParameter.value)
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
}
}
}
};
// createTemplate navigates to the /templates/new page and uploads a template
// with the resources provided in the responses argument.
@ -120,24 +120,24 @@ export const createTemplate = async (
// Required to have templates submit their provisioner type as echo!
await page.addInitScript({
content: "window.playwright = true",
})
});
await page.goto("/templates/new", { waitUntil: "domcontentloaded" })
await expect(page).toHaveURL("/templates/new")
await page.goto("/templates/new", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL("/templates/new");
await page.getByTestId("file-upload").setInputFiles({
buffer: await createTemplateVersionTar(responses),
mimeType: "application/x-tar",
name: "template.tar",
})
const name = randomName()
await page.getByLabel("Name *").fill(name)
await page.getByTestId("form-submit").click()
});
const name = randomName();
await page.getByLabel("Name *").fill(name);
await page.getByTestId("form-submit").click();
await expect(page).toHaveURL("/templates/" + name, {
timeout: 30000,
})
return name
}
});
return name;
};
// sshIntoWorkspace spawns a Coder SSH process and a client connected to it.
export const sshIntoWorkspace = async (
@ -147,9 +147,9 @@ export const sshIntoWorkspace = async (
binaryArgs: string[] = [],
): Promise<ssh.Client> => {
if (binaryPath === "go") {
binaryArgs = ["run", coderMainPath()]
binaryArgs = ["run", coderMainPath()];
}
const sessionToken = await findSessionToken(page)
const sessionToken = await findSessionToken(page);
return new Promise<ssh.Client>((resolve, reject) => {
const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], {
env: {
@ -157,49 +157,49 @@ export const sshIntoWorkspace = async (
CODER_SESSION_TOKEN: sessionToken,
CODER_URL: "http://localhost:3000",
},
})
cp.on("error", (err) => reject(err))
});
cp.on("error", (err) => reject(err));
const proxyStream = new Duplex({
read: (size) => {
return cp.stdout.read(Math.min(size, cp.stdout.readableLength))
return cp.stdout.read(Math.min(size, cp.stdout.readableLength));
},
write: cp.stdin.write.bind(cp.stdin),
})
});
// eslint-disable-next-line no-console -- Helpful for debugging
cp.stderr.on("data", (data) => console.log(data.toString()))
cp.stderr.on("data", (data) => console.log(data.toString()));
cp.stdout.on("readable", (...args) => {
proxyStream.emit("readable", ...args)
proxyStream.emit("readable", ...args);
if (cp.stdout.readableLength > 0) {
proxyStream.emit("data", cp.stdout.read())
proxyStream.emit("data", cp.stdout.read());
}
})
const client = new ssh.Client()
});
const client = new ssh.Client();
client.connect({
sock: proxyStream,
username: "coder",
})
client.on("error", (err) => reject(err))
});
client.on("error", (err) => reject(err));
client.on("ready", () => {
resolve(client)
})
})
}
resolve(client);
});
});
};
export const stopWorkspace = async (page: Page, workspaceName: string) => {
await page.goto("/@admin/" + workspaceName, {
waitUntil: "domcontentloaded",
})
await expect(page).toHaveURL("/@admin/" + workspaceName)
});
await expect(page).toHaveURL("/@admin/" + workspaceName);
await page.getByTestId("workspace-stop-button").click()
await page.getByTestId("workspace-stop-button").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Stopped",
{
state: "visible",
},
)
}
);
};
export const buildWorkspaceWithParameters = async (
page: Page,
@ -210,15 +210,15 @@ export const buildWorkspaceWithParameters = async (
) => {
await page.goto("/@admin/" + workspaceName, {
waitUntil: "domcontentloaded",
})
await expect(page).toHaveURL("/@admin/" + workspaceName)
});
await expect(page).toHaveURL("/@admin/" + workspaceName);
await page.getByTestId("build-parameters-button").click()
await page.getByTestId("build-parameters-button").click();
await fillParameters(page, richParameters, buildParameters)
await page.getByTestId("build-parameters-submit").click()
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("build-parameters-submit").click();
if (confirm) {
await page.getByTestId("confirm-button").click()
await page.getByTestId("confirm-button").click();
}
await page.waitForSelector(
@ -226,8 +226,8 @@ export const buildWorkspaceWithParameters = async (
{
state: "visible",
},
)
}
);
};
// startAgent runs the coder agent with the provided token.
// It awaits the agent to be ready before returning.
@ -235,8 +235,8 @@ export const startAgent = async (
page: Page,
token: string,
): Promise<ChildProcess> => {
return startAgentWithCommand(page, token, "go", "run", coderMainPath())
}
return startAgentWithCommand(page, token, "go", "run", coderMainPath());
};
// downloadCoderVersion downloads the version provided into a temporary dir and
// caches it so subsequent calls are fast.
@ -244,23 +244,23 @@ export const downloadCoderVersion = async (
version: string,
): Promise<string> => {
if (version.startsWith("v")) {
version = version.slice(1)
version = version.slice(1);
}
const binaryName = "coder-e2e-" + version
const tempDir = "/tmp/coder-e2e-cache"
const binaryName = "coder-e2e-" + version;
const tempDir = "/tmp/coder-e2e-cache";
// The install script adds `./bin` automatically to the path :shrug:
const binaryPath = path.join(tempDir, "bin", binaryName)
const binaryPath = path.join(tempDir, "bin", binaryName);
const exists = await new Promise<boolean>((resolve) => {
const cp = spawn(binaryPath, ["version"])
const cp = spawn(binaryPath, ["version"]);
cp.on("close", (code) => {
resolve(code === 0)
})
cp.on("error", () => resolve(false))
})
resolve(code === 0);
});
cp.on("error", () => resolve(false));
});
if (exists) {
return binaryPath
return binaryPath;
}
// Runs our public install script using our options to
@ -294,19 +294,19 @@ export const downloadCoderVersion = async (
XDG_CACHE_HOME: "/tmp/coder-e2e-cache",
},
},
)
);
// eslint-disable-next-line no-console -- Needed for debugging
cp.stderr.on("data", (data) => console.log(data.toString()))
cp.stderr.on("data", (data) => console.log(data.toString()));
cp.on("close", (code) => {
if (code === 0) {
resolve()
resolve();
} else {
reject(new Error("curl failed with code " + code))
reject(new Error("curl failed with code " + code));
}
})
})
return binaryPath
}
});
});
return binaryPath;
};
export const startAgentWithCommand = async (
page: Page,
@ -322,23 +322,23 @@ export const startAgentWithCommand = async (
CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + pprofPort,
CODER_AGENT_PROMETHEUS_ADDRESS: "127.0.0.1:" + prometheusPort,
},
})
});
cp.stdout.on("data", (data: Buffer) => {
// eslint-disable-next-line no-console -- Log agent activity
console.log(
`[agent] [stdout] [onData] ${data.toString().replace(/\n$/g, "")}`,
)
})
);
});
cp.stderr.on("data", (data: Buffer) => {
// eslint-disable-next-line no-console -- Log agent activity
console.log(
`[agent] [stderr] [onData] ${data.toString().replace(/\n$/g, "")}`,
)
})
);
});
await page.getByTestId("agent-status-ready").waitFor({ state: "visible" })
return cp
}
await page.getByTestId("agent-status-ready").waitFor({ state: "visible" });
return cp;
};
export const stopAgent = async (cp: ChildProcess, goRun: boolean = true) => {
// When the web server is started with `go run`, it spawns a child process with coder server.
@ -346,31 +346,31 @@ export const stopAgent = async (cp: ChildProcess, goRun: boolean = true) => {
// The command `kill` is used to terminate a web server started as a standalone binary.
exec(goRun ? `pkill -P ${cp.pid}` : `kill ${cp.pid}`, (error) => {
if (error) {
throw new Error(`exec error: ${JSON.stringify(error)}`)
throw new Error(`exec error: ${JSON.stringify(error)}`);
}
})
await waitUntilUrlIsNotResponding("http://localhost:" + prometheusPort)
}
});
await waitUntilUrlIsNotResponding("http://localhost:" + prometheusPort);
};
const waitUntilUrlIsNotResponding = async (url: string) => {
const maxRetries = 30
const retryIntervalMs = 1000
let retries = 0
const maxRetries = 30;
const retryIntervalMs = 1000;
let retries = 0;
while (retries < maxRetries) {
try {
await axios.get(url)
await axios.get(url);
} catch (error) {
return
return;
}
retries++
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs))
retries++;
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
}
throw new Error(
`URL ${url} is still responding after ${maxRetries * retryIntervalMs}ms`,
)
}
);
};
const coderMainPath = (): string => {
return path.join(
@ -381,8 +381,8 @@ const coderMainPath = (): string => {
"cmd",
"coder",
"main.go",
)
}
);
};
// Allows users to more easily define properties they want for agents and resources!
type RecursivePartial<T> = {
@ -390,16 +390,16 @@ type RecursivePartial<T> = {
? RecursivePartial<U>[]
: T[P] extends object | undefined
? RecursivePartial<T[P]>
: T[P]
}
: T[P];
};
interface EchoProvisionerResponses {
// parse is for observing any Terraform variables
parse?: RecursivePartial<Response>[]
parse?: RecursivePartial<Response>[];
// plan occurs when the template is imported
plan?: RecursivePartial<Response>[]
plan?: RecursivePartial<Response>[];
// apply occurs when the workspace is built
apply?: RecursivePartial<Response>[]
apply?: RecursivePartial<Response>[];
}
// createTemplateVersionTar consumes a series of echo provisioner protobufs and
@ -408,26 +408,26 @@ const createTemplateVersionTar = async (
responses?: EchoProvisionerResponses,
): Promise<Buffer> => {
if (!responses) {
responses = {}
responses = {};
}
if (!responses.parse) {
responses.parse = [
{
parse: {},
},
]
];
}
if (!responses.apply) {
responses.apply = [
{
apply: {},
},
]
];
}
if (!responses.plan) {
responses.plan = responses.apply.map((response) => {
if (response.log) {
return response
return response;
}
return {
plan: {
@ -436,23 +436,23 @@ const createTemplateVersionTar = async (
parameters: response.apply?.parameters ?? [],
gitAuthProviders: response.apply?.gitAuthProviders ?? [],
},
}
})
};
});
}
const tar = new TarWriter()
const tar = new TarWriter();
responses.parse.forEach((response, index) => {
response.parse = {
templateVariables: [],
error: "",
readme: new Uint8Array(),
...response.parse,
} as ParseComplete
} as ParseComplete;
tar.addFile(
`${index}.parse.protobuf`,
Response.encode(response as Response).finish(),
)
})
);
});
const fillResource = (resource: RecursivePartial<Resource>) => {
if (resource.agents) {
@ -470,8 +470,8 @@ const createTemplateVersionTar = async (
subdomain: false,
url: "",
...app,
} as App
})
} as App;
});
}
return {
apps: [],
@ -492,9 +492,9 @@ const createTemplateVersionTar = async (
troubleshootingUrl: "",
token: randomUUID(),
...agent,
} as Agent
} as Agent;
},
)
);
}
return {
agents: [],
@ -506,8 +506,8 @@ const createTemplateVersionTar = async (
name: "dev",
type: "echo",
...resource,
} as Resource
}
} as Resource;
};
responses.apply.forEach((response, index) => {
response.apply = {
@ -517,14 +517,14 @@ const createTemplateVersionTar = async (
parameters: [],
gitAuthProviders: [],
...response.apply,
} as ApplyComplete
response.apply.resources = response.apply.resources?.map(fillResource)
} as ApplyComplete;
response.apply.resources = response.apply.resources?.map(fillResource);
tar.addFile(
`${index}.apply.protobuf`,
Response.encode(response as Response).finish(),
)
})
);
});
responses.plan.forEach((response, index) => {
response.plan = {
error: "",
@ -532,63 +532,63 @@ const createTemplateVersionTar = async (
parameters: [],
gitAuthProviders: [],
...response.plan,
} as PlanComplete
response.plan.resources = response.plan.resources?.map(fillResource)
} as PlanComplete;
response.plan.resources = response.plan.resources?.map(fillResource);
tar.addFile(
`${index}.plan.protobuf`,
Response.encode(response as Response).finish(),
)
})
const tarFile = await tar.write()
);
});
const tarFile = await tar.write();
return Buffer.from(
tarFile instanceof Blob ? await tarFile.arrayBuffer() : tarFile,
)
}
);
};
const randomName = () => {
return randomUUID().slice(0, 8)
}
return randomUUID().slice(0, 8);
};
// Awaiter is a helper that allows you to wait for a callback to be called.
// It is useful for waiting for events to occur.
export class Awaiter {
private promise: Promise<void>
private callback?: () => void
private promise: Promise<void>;
private callback?: () => void;
constructor() {
this.promise = new Promise((r) => (this.callback = r))
this.promise = new Promise((r) => (this.callback = r));
}
public done(): void {
if (this.callback) {
this.callback()
this.callback();
} else {
this.promise = Promise.resolve()
this.promise = Promise.resolve();
}
}
public wait(): Promise<void> {
return this.promise
return this.promise;
}
}
export const createServer = async (
port: number,
): Promise<ReturnType<typeof express>> => {
const e = express()
await new Promise<void>((r) => e.listen(port, r))
return e
}
const e = express();
await new Promise<void>((r) => e.listen(port, r));
return e;
};
const findSessionToken = async (page: Page): Promise<string> => {
const cookies = await page.context().cookies()
const sessionCookie = cookies.find((c) => c.name === "coder_session_token")
const cookies = await page.context().cookies();
const sessionCookie = cookies.find((c) => c.name === "coder_session_token");
if (!sessionCookie) {
throw new Error("session token not found")
throw new Error("session token not found");
}
return sessionCookie.value
}
return sessionCookie.value;
};
export const echoResponsesWithParameters = (
richParameters: RichParameter[],
@ -617,8 +617,8 @@ export const echoResponsesWithParameters = (
},
},
],
}
}
};
};
export const fillParameters = async (
page: Page,
@ -628,52 +628,52 @@ export const fillParameters = async (
for (const buildParameter of buildParameters) {
const richParameter = richParameters.find(
(richParam) => richParam.name === buildParameter.name,
)
);
if (!richParameter) {
throw new Error(
"build parameter is expected to be present in rich parameter schema",
)
);
}
const parameterLabel = await page.waitForSelector(
"[data-testid='parameter-field-" + richParameter.name + "']",
{ state: "visible" },
)
);
if (richParameter.type === "bool") {
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" +
buildParameter.value +
"']",
)
await parameterField.check()
);
await parameterField.check();
} else if (richParameter.options.length > 0) {
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-options'] .MuiRadio-root input[value='" +
buildParameter.value +
"']",
)
await parameterField.check()
);
await parameterField.check();
} else if (richParameter.type === "list(string)") {
throw new Error("not implemented yet") // FIXME
throw new Error("not implemented yet"); // FIXME
} else {
// text or number
const parameterField = await parameterLabel.waitForSelector(
"[data-testid='parameter-field-text'] input",
)
await parameterField.fill(buildParameter.value)
);
await parameterField.fill(buildParameter.value);
}
}
}
};
export const updateTemplate = async (
page: Page,
templateName: string,
responses?: EchoProvisionerResponses,
) => {
const tarball = await createTemplateVersionTar(responses)
const tarball = await createTemplateVersionTar(responses);
const sessionToken = await findSessionToken(page)
const sessionToken = await findSessionToken(page);
const child = spawn(
"go",
[
@ -695,23 +695,23 @@ export const updateTemplate = async (
CODER_URL: "http://localhost:3000",
},
},
)
);
const uploaded = new Awaiter()
const uploaded = new Awaiter();
child.on("exit", (code) => {
if (code === 0) {
uploaded.done()
return
uploaded.done();
return;
}
throw new Error(`coder templates push failed with code ${code}`)
})
throw new Error(`coder templates push failed with code ${code}`);
});
child.stdin.write(tarball)
child.stdin.end()
child.stdin.write(tarball);
child.stdin.end();
await uploaded.wait()
}
await uploaded.wait();
};
export const updateWorkspace = async (
page: Page,
@ -721,22 +721,22 @@ export const updateWorkspace = async (
) => {
await page.goto("/@admin/" + workspaceName, {
waitUntil: "domcontentloaded",
})
await expect(page).toHaveURL("/@admin/" + workspaceName)
});
await expect(page).toHaveURL("/@admin/" + workspaceName);
await page.getByTestId("workspace-update-button").click()
await page.getByTestId("confirm-button").click()
await page.getByTestId("workspace-update-button").click();
await page.getByTestId("confirm-button").click();
await fillParameters(page, richParameters, buildParameters)
await page.getByTestId("form-submit").click()
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
)
}
);
};
export const updateWorkspaceParameters = async (
page: Page,
@ -746,18 +746,18 @@ export const updateWorkspaceParameters = async (
) => {
await page.goto("/@admin/" + workspaceName + "/settings/parameters", {
waitUntil: "domcontentloaded",
})
});
await expect(page).toHaveURL(
"/@admin/" + workspaceName + "/settings/parameters",
)
);
await fillParameters(page, richParameters, buildParameters)
await page.getByTestId("form-submit").click()
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.waitForSelector(
"span[data-testid='build-status'] >> text=Running",
{
state: "visible",
},
)
}
);
};

View File

@ -1,12 +1,12 @@
import { Page } from "@playwright/test"
import { Page } from "@playwright/test";
export const beforeCoderTest = async (page: Page) => {
// eslint-disable-next-line no-console -- Show everything that was printed with console.log()
page.on("console", (msg) => console.log("[onConsole] " + msg.text()))
page.on("console", (msg) => console.log("[onConsole] " + msg.text()));
page.on("request", (request) => {
if (!isApiCall(request.url())) {
return
return;
}
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes
@ -14,40 +14,40 @@ export const beforeCoderTest = async (page: Page) => {
`[onRequest] method=${request.method()} url=${request.url()} postData=${
request.postData() ? request.postData() : ""
}`,
)
})
);
});
page.on("response", async (response) => {
if (!isApiCall(response.url())) {
return
return;
}
const shouldLogResponse =
!response.url().endsWith("/api/v2/deployment/config") &&
!response.url().endsWith("/api/v2/debug/health")
!response.url().endsWith("/api/v2/debug/health");
let responseText = ""
let responseText = "";
try {
if (shouldLogResponse) {
const buffer = await response.body()
responseText = buffer.toString("utf-8")
responseText = responseText.replace(/\n$/g, "")
const buffer = await response.body();
responseText = buffer.toString("utf-8");
responseText = responseText.replace(/\n$/g, "");
} else {
responseText = "skipped..."
responseText = "skipped...";
}
} catch (error) {
responseText = "not_available"
responseText = "not_available";
}
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes
console.log(
`[onResponse] url=${response.url()} status=${response.status()} body=${responseText}`,
)
})
}
);
});
};
const isApiCall = (urlString: string): boolean => {
const url = new URL(urlString)
const apiPath = "/api/v2"
const url = new URL(urlString);
const apiPath = "/api/v2";
return url.pathname.startsWith(apiPath)
}
return url.pathname.startsWith(apiPath);
};

View File

@ -1,4 +1,4 @@
import { RichParameter } from "./provisionerGenerated"
import { RichParameter } from "./provisionerGenerated";
// Rich parameters
@ -19,7 +19,7 @@ const emptyParameter: RichParameter = {
displayName: "",
order: 0,
ephemeral: false,
}
};
// firstParameter is mutable string with a default value (parameter value not required).
export const firstParameter: RichParameter = {
@ -33,7 +33,7 @@ export const firstParameter: RichParameter = {
defaultValue: "123",
mutable: true,
order: 1,
}
};
// secondParameter is immutable string with a default value (parameter value not required).
export const secondParameter: RichParameter = {
@ -45,7 +45,7 @@ export const secondParameter: RichParameter = {
description: "This is second parameter.",
defaultValue: "abc",
order: 2,
}
};
// thirdParameter is mutable string with an empty default value (parameter value not required).
export const thirdParameter: RichParameter = {
@ -57,7 +57,7 @@ export const thirdParameter: RichParameter = {
defaultValue: "",
mutable: true,
order: 3,
}
};
// fourthParameter is immutable boolean with a default "true" value (parameter value not required).
export const fourthParameter: RichParameter = {
@ -68,7 +68,7 @@ export const fourthParameter: RichParameter = {
description: "This is fourth parameter.",
defaultValue: "true",
order: 3,
}
};
// fifthParameter is immutable "string with options", with a default option selected (parameter value not required).
export const fifthParameter: RichParameter = {
@ -100,7 +100,7 @@ export const fifthParameter: RichParameter = {
description: "This is fifth parameter.",
defaultValue: "def",
order: 3,
}
};
// sixthParameter is mutable string without a default value (parameter value is required).
export const sixthParameter: RichParameter = {
@ -114,7 +114,7 @@ export const sixthParameter: RichParameter = {
required: true,
mutable: true,
order: 1,
}
};
// seventhParameter is immutable string without a default value (parameter value is required).
export const seventhParameter: RichParameter = {
@ -126,7 +126,7 @@ export const seventhParameter: RichParameter = {
description: "This is seventh parameter.",
required: true,
order: 1,
}
};
// Build options
@ -141,7 +141,7 @@ export const firstBuildOption: RichParameter = {
defaultValue: "ABCDEF",
mutable: true,
ephemeral: true,
}
};
export const secondBuildOption: RichParameter = {
...emptyParameter,
@ -153,4 +153,4 @@ export const secondBuildOption: RichParameter = {
defaultValue: "false",
mutable: true,
ephemeral: true,
}
};

View File

@ -1,18 +1,18 @@
import { defineConfig } from "@playwright/test"
import path from "path"
import { defaultPort, gitAuth } from "./constants"
import { defineConfig } from "@playwright/test";
import path from "path";
import { defaultPort, gitAuth } from "./constants";
export const port = process.env.CODER_E2E_PORT
? Number(process.env.CODER_E2E_PORT)
: defaultPort
: defaultPort;
const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go")
const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go");
export const STORAGE_STATE = path.join(__dirname, ".auth.json")
export const STORAGE_STATE = path.join(__dirname, ".auth.json");
const localURL = (port: number, path: string): string => {
return `http://localhost:${port}${path}`
}
return `http://localhost:${port}${path}`;
};
export default defineConfig({
projects: [
@ -92,4 +92,4 @@ export default defineConfig({
},
reuseExistingServer: false,
},
})
});

View File

@ -1,17 +1,17 @@
import { Page } from "@playwright/test"
import { Page } from "@playwright/test";
export abstract class BasePom {
protected readonly baseURL: string | undefined
protected readonly path: string
protected readonly page: Page
protected readonly baseURL: string | undefined;
protected readonly path: string;
protected readonly page: Page;
constructor(baseURL: string | undefined, path: string, page: Page) {
this.baseURL = baseURL
this.path = path
this.page = page
this.baseURL = baseURL;
this.path = path;
this.page = page;
}
get url(): string {
return this.baseURL + this.path
return this.baseURL + this.path;
}
}

View File

@ -1,17 +1,17 @@
import { Page } from "@playwright/test"
import { BasePom } from "./BasePom"
import { Page } from "@playwright/test";
import { BasePom } from "./BasePom";
export class SignInPage extends BasePom {
constructor(baseURL: string | undefined, page: Page) {
super(baseURL, "/login", page)
super(baseURL, "/login", page);
}
async submitBuiltInAuthentication(
email: string,
password: string,
): Promise<void> {
await this.page.fill("text=Email", email)
await this.page.fill("text=Password", password)
await this.page.click('button:has-text("Sign In")')
await this.page.fill("text=Email", email);
await this.page.fill("text=Password", password);
await this.page.click('button:has-text("Sign In")');
}
}

View File

@ -1,8 +1,8 @@
import { Page } from "@playwright/test"
import { BasePom } from "./BasePom"
import { Page } from "@playwright/test";
import { BasePom } from "./BasePom";
export class WorkspacesPage extends BasePom {
constructor(baseURL: string | undefined, page: Page, params?: string) {
super(baseURL, `/workspaces${params && params}`, page)
super(baseURL, `/workspaces${params && params}`, page);
}
}

View File

@ -1,2 +1,2 @@
export * from "./SignInPage"
export * from "./WorkspacesPage"
export * from "./SignInPage";
export * from "./WorkspacesPage";

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import fs from "fs"
import fs from "fs";
import type {
FullConfig,
Suite,
@ -6,18 +6,18 @@ import type {
TestResult,
FullResult,
Reporter,
} from "@playwright/test/reporter"
import axios from "axios"
} from "@playwright/test/reporter";
import axios from "axios";
class CoderReporter implements Reporter {
onBegin(config: FullConfig, suite: Suite) {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log(`Starting the run with ${suite.allTests().length} tests`)
console.log(`Starting the run with ${suite.allTests().length} tests`);
}
onTestBegin(test: TestCase) {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log(`Starting test ${test.title}`)
console.log(`Starting test ${test.title}`);
}
onStdOut(chunk: string, test: TestCase, _: TestResult): void {
@ -27,7 +27,7 @@ class CoderReporter implements Reporter {
/\n$/g,
"",
)}`,
)
);
}
onStdErr(chunk: string, test: TestCase, _: TestResult): void {
@ -37,50 +37,50 @@ class CoderReporter implements Reporter {
/\n$/g,
"",
)}`,
)
);
}
async onTestEnd(test: TestCase, result: TestResult) {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log(`Finished test ${test.title}: ${result.status}`)
console.log(`Finished test ${test.title}: ${result.status}`);
if (result.status !== "passed") {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log("errors", result.errors, "attachments", result.attachments)
console.log("errors", result.errors, "attachments", result.attachments);
}
await exportDebugPprof(test.title)
await exportDebugPprof(test.title);
}
onEnd(result: FullResult) {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log(`Finished the run: ${result.status}`)
console.log(`Finished the run: ${result.status}`);
}
}
const exportDebugPprof = async (testName: string) => {
const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1"
const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`
const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1";
const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`;
await axios
.get(url)
.then((response) => {
if (response.status !== 200) {
throw new Error(`Error: Received status code ${response.status}`)
throw new Error(`Error: Received status code ${response.status}`);
}
fs.writeFile(outputFile, response.data, (err) => {
if (err) {
throw new Error(`Error writing to ${outputFile}: ${err.message}`)
throw new Error(`Error writing to ${outputFile}: ${err.message}`);
} else {
// eslint-disable-next-line no-console -- Helpful for debugging
console.log(`Data from ${url} has been saved to ${outputFile}`)
console.log(`Data from ${url} has been saved to ${outputFile}`);
}
})
});
})
.catch((error) => {
throw new Error(`Error: ${error.message}`)
})
}
throw new Error(`Error: ${error.message}`);
});
};
// eslint-disable-next-line no-unused-vars -- Playwright config uses it
export default CoderReporter
export default CoderReporter;

View File

@ -1,31 +1,31 @@
import { test } from "@playwright/test"
import { randomUUID } from "crypto"
import * as http from "http"
import { test } from "@playwright/test";
import { randomUUID } from "crypto";
import * as http from "http";
import {
createTemplate,
createWorkspace,
startAgent,
stopAgent,
stopWorkspace,
} from "../helpers"
import { beforeCoderTest } from "../hooks"
} from "../helpers";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("app", async ({ context, page }) => {
const appContent = "Hello World"
const token = randomUUID()
const appContent = "Hello World";
const token = randomUUID();
const srv = http
.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" })
res.end(appContent)
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(appContent);
})
.listen(0)
const addr = srv.address()
.listen(0);
const addr = srv.address();
if (typeof addr !== "object" || !addr) {
throw new Error("Expected addr to be an object")
throw new Error("Expected addr to be an object");
}
const appName = "test-app"
const appName = "test-app";
const template = await createTemplate(page, {
apply: [
{
@ -48,17 +48,17 @@ test("app", async ({ context, page }) => {
},
},
],
})
const workspaceName = await createWorkspace(page, template)
const agent = await startAgent(page, token)
});
const workspaceName = await createWorkspace(page, template);
const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page")
await page.getByText(appName).click()
const app = await pagePromise
await app.waitForLoadState("domcontentloaded")
await app.getByText(appContent).isVisible()
const pagePromise = context.waitForEvent("page");
await page.getByText(appName).click();
const app = await pagePromise;
await app.waitForLoadState("domcontentloaded");
await app.getByText(appContent).isVisible();
await stopWorkspace(page, workspaceName)
await stopAgent(agent)
})
await stopWorkspace(page, workspaceName);
await stopAgent(agent);
});

View File

@ -1,10 +1,10 @@
import { test } from "@playwright/test"
import { test } from "@playwright/test";
import {
createTemplate,
createWorkspace,
echoResponsesWithParameters,
verifyParameters,
} from "../helpers"
} from "../helpers";
import {
secondParameter,
@ -14,11 +14,11 @@ import {
thirdParameter,
seventhParameter,
sixthParameter,
} from "../parameters"
import { RichParameter } from "../provisionerGenerated"
import { beforeCoderTest } from "../hooks"
} from "../parameters";
import { RichParameter } from "../provisionerGenerated";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("create workspace", async ({ page }) => {
const template = await createTemplate(page, {
@ -33,40 +33,40 @@ test("create workspace", async ({ page }) => {
},
},
],
})
await createWorkspace(page, template)
})
});
await createWorkspace(page, template);
});
test("create workspace with default immutable parameters", async ({ page }) => {
const richParameters: RichParameter[] = [
secondParameter,
fourthParameter,
fifthParameter,
]
];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
const workspaceName = await createWorkspace(page, template)
);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
{ name: secondParameter.name, value: secondParameter.defaultValue },
{ name: fourthParameter.name, value: fourthParameter.defaultValue },
{ name: fifthParameter.name, value: fifthParameter.defaultValue },
])
})
]);
});
test("create workspace with default mutable parameters", async ({ page }) => {
const richParameters: RichParameter[] = [firstParameter, thirdParameter]
const richParameters: RichParameter[] = [firstParameter, thirdParameter];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
const workspaceName = await createWorkspace(page, template)
);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: thirdParameter.name, value: thirdParameter.defaultValue },
])
})
]);
});
test("create workspace with default and required parameters", async ({
page,
@ -76,46 +76,46 @@ test("create workspace with default and required parameters", async ({
fourthParameter,
sixthParameter,
seventhParameter,
]
];
const buildParameters = [
{ name: sixthParameter.name, value: "12345" },
{ name: seventhParameter.name, value: "abcdef" },
]
];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
);
const workspaceName = await createWorkspace(
page,
template,
richParameters,
buildParameters,
)
);
await verifyParameters(page, workspaceName, richParameters, [
// user values:
...buildParameters,
// default values:
{ name: secondParameter.name, value: secondParameter.defaultValue },
{ name: fourthParameter.name, value: fourthParameter.defaultValue },
])
})
]);
});
test("create workspace and overwrite default parameters", async ({ page }) => {
const richParameters: RichParameter[] = [secondParameter, fourthParameter]
const richParameters: RichParameter[] = [secondParameter, fourthParameter];
const buildParameters = [
{ name: secondParameter.name, value: "AAAAA" },
{ name: fourthParameter.name, value: "false" },
]
];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
);
const workspaceName = await createWorkspace(
page,
template,
richParameters,
buildParameters,
)
await verifyParameters(page, workspaceName, richParameters, buildParameters)
})
);
await verifyParameters(page, workspaceName, richParameters, buildParameters);
});

View File

@ -1,11 +1,11 @@
import { test } from "@playwright/test"
import { gitAuth } from "../constants"
import { Endpoints } from "@octokit/types"
import { GitAuthDevice } from "api/typesGenerated"
import { Awaiter, createServer } from "../helpers"
import { beforeCoderTest } from "../hooks"
import { test } from "@playwright/test";
import { gitAuth } from "../constants";
import { Endpoints } from "@octokit/types";
import { GitAuthDevice } from "api/typesGenerated";
import { Awaiter, createServer } from "../helpers";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
// Ensures that a Git auth provider with the device flow functions and completes!
test("git auth device", async ({ page }) => {
@ -15,71 +15,71 @@ test("git auth device", async ({ page }) => {
expires_in: 900,
interval: 1,
verification_uri: "",
}
};
// Start a server to mock the GitHub API.
const srv = await createServer(gitAuth.devicePort)
const srv = await createServer(gitAuth.devicePort);
srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser))
res.end()
})
res.write(JSON.stringify(ghUser));
res.end();
});
srv.use(gitAuth.codePath, (req, res) => {
res.write(JSON.stringify(device))
res.end()
})
res.write(JSON.stringify(device));
res.end();
});
srv.use(gitAuth.installationsPath, (req, res) => {
res.write(JSON.stringify(ghInstall))
res.end()
})
res.write(JSON.stringify(ghInstall));
res.end();
});
const token = {
access_token: "",
error: "authorization_pending",
error_description: "",
}
};
// First we send a result from the API that the token hasn't been
// authorized yet to ensure the UI reacts properly.
const sentPending = new Awaiter()
const sentPending = new Awaiter();
srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify(token))
res.end()
sentPending.done()
})
res.write(JSON.stringify(token));
res.end();
sentPending.done();
});
await page.goto(`/gitauth/${gitAuth.deviceProvider}`, {
waitUntil: "domcontentloaded",
})
await page.getByText(device.user_code).isVisible()
await sentPending.wait()
});
await page.getByText(device.user_code).isVisible();
await sentPending.wait();
// Update the token to be valid and ensure the UI updates!
token.error = ""
token.access_token = "hello-world"
await page.waitForSelector("text=1 organization authorized")
})
token.error = "";
token.access_token = "hello-world";
await page.waitForSelector("text=1 organization authorized");
});
test("git auth web", async ({ baseURL, page }) => {
const srv = await createServer(gitAuth.webPort)
const srv = await createServer(gitAuth.webPort);
// The GitHub validate endpoint returns the currently authenticated user!
srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser))
res.end()
})
res.write(JSON.stringify(ghUser));
res.end();
});
srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify({ access_token: "hello-world" }))
res.end()
})
res.write(JSON.stringify({ access_token: "hello-world" }));
res.end();
});
srv.use(gitAuth.authPath, (req, res) => {
res.redirect(
`${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` +
req.query.state,
)
})
);
});
await page.goto(`/gitauth/${gitAuth.webProvider}`, {
waitUntil: "domcontentloaded",
})
});
// This endpoint doesn't have the installations URL set intentionally!
await page.waitForSelector("text=You've authenticated with GitHub!")
})
await page.waitForSelector("text=You've authenticated with GitHub!");
});
const ghUser: Endpoints["GET /user"]["response"]["data"] = {
login: "kylecarbs",
@ -115,7 +115,7 @@ const ghUser: Endpoints["GET /user"]["response"]["data"] = {
following: 31,
created_at: "2014-04-01T02:24:41Z",
updated_at: "2023-06-26T13:03:09Z",
}
};
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
installations: [
@ -140,4 +140,4 @@ const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
},
],
total_count: 1,
}
};

View File

@ -1,9 +1,9 @@
import { test, expect } from "@playwright/test"
import { beforeCoderTest } from "../hooks"
import { test, expect } from "@playwright/test";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("list templates", async ({ page, baseURL }) => {
await page.goto(`${baseURL}/templates`, { waitUntil: "domcontentloaded" })
await expect(page).toHaveTitle("Templates - Coder")
})
await page.goto(`${baseURL}/templates`, { waitUntil: "domcontentloaded" });
await expect(page).toHaveTitle("Templates - Coder");
});

View File

@ -1,5 +1,5 @@
import { test } from "@playwright/test"
import { randomUUID } from "crypto"
import { test } from "@playwright/test";
import { randomUUID } from "crypto";
import {
createTemplate,
createWorkspace,
@ -8,15 +8,15 @@ import {
startAgentWithCommand,
stopAgent,
stopWorkspace,
} from "../helpers"
import { beforeCoderTest } from "../hooks"
} from "../helpers";
import { beforeCoderTest } from "../hooks";
const agentVersion = "v0.14.0"
const agentVersion = "v0.14.0";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("ssh with agent " + agentVersion, async ({ page }) => {
const token = randomUUID()
const token = randomUUID();
const template = await createTemplate(page, {
apply: [
{
@ -33,28 +33,28 @@ test("ssh with agent " + agentVersion, async ({ page }) => {
},
},
],
})
const workspaceName = await createWorkspace(page, template)
const binaryPath = await downloadCoderVersion(agentVersion)
const agent = await startAgentWithCommand(page, token, binaryPath)
});
const workspaceName = await createWorkspace(page, template);
const binaryPath = await downloadCoderVersion(agentVersion);
const agent = await startAgentWithCommand(page, token, binaryPath);
const client = await sshIntoWorkspace(page, workspaceName)
const client = await sshIntoWorkspace(page, workspaceName);
await new Promise<void>((resolve, reject) => {
// We just exec a command to be certain the agent is running!
client.exec("exit 0", (err, stream) => {
if (err) {
return reject(err)
return reject(err);
}
stream.on("exit", (code) => {
if (code !== 0) {
return reject(new Error(`Command exited with code ${code}`))
return reject(new Error(`Command exited with code ${code}`));
}
client.end()
resolve()
})
})
})
client.end();
resolve();
});
});
});
await stopWorkspace(page, workspaceName)
await stopAgent(agent, false)
})
await stopWorkspace(page, workspaceName);
await stopAgent(agent, false);
});

View File

@ -1,5 +1,5 @@
import { test } from "@playwright/test"
import { randomUUID } from "crypto"
import { test } from "@playwright/test";
import { randomUUID } from "crypto";
import {
createTemplate,
createWorkspace,
@ -8,15 +8,15 @@ import {
startAgent,
stopAgent,
stopWorkspace,
} from "../helpers"
import { beforeCoderTest } from "../hooks"
} from "../helpers";
import { beforeCoderTest } from "../hooks";
const clientVersion = "v0.14.0"
const clientVersion = "v0.14.0";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("ssh with client " + clientVersion, async ({ page }) => {
const token = randomUUID()
const token = randomUUID();
const template = await createTemplate(page, {
apply: [
{
@ -33,28 +33,28 @@ test("ssh with client " + clientVersion, async ({ page }) => {
},
},
],
})
const workspaceName = await createWorkspace(page, template)
const agent = await startAgent(page, token)
const binaryPath = await downloadCoderVersion(clientVersion)
});
const workspaceName = await createWorkspace(page, template);
const agent = await startAgent(page, token);
const binaryPath = await downloadCoderVersion(clientVersion);
const client = await sshIntoWorkspace(page, workspaceName, binaryPath)
const client = await sshIntoWorkspace(page, workspaceName, binaryPath);
await new Promise<void>((resolve, reject) => {
// We just exec a command to be certain the agent is running!
client.exec("exit 0", (err, stream) => {
if (err) {
return reject(err)
return reject(err);
}
stream.on("exit", (code) => {
if (code !== 0) {
return reject(new Error(`Command exited with code ${code}`))
return reject(new Error(`Command exited with code ${code}`));
}
client.end()
resolve()
})
})
})
client.end();
resolve();
});
});
});
await stopWorkspace(page, workspaceName)
await stopAgent(agent)
})
await stopWorkspace(page, workspaceName);
await stopAgent(agent);
});

View File

@ -1,48 +1,48 @@
import { test } from "@playwright/test"
import { test } from "@playwright/test";
import {
buildWorkspaceWithParameters,
createTemplate,
createWorkspace,
echoResponsesWithParameters,
verifyParameters,
} from "../helpers"
} from "../helpers";
import { firstBuildOption, secondBuildOption } from "../parameters"
import { RichParameter } from "../provisionerGenerated"
import { beforeCoderTest } from "../hooks"
import { firstBuildOption, secondBuildOption } from "../parameters";
import { RichParameter } from "../provisionerGenerated";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("restart workspace with ephemeral parameters", async ({ page }) => {
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
const workspaceName = await createWorkspace(page, template)
);
const workspaceName = await createWorkspace(page, template);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
]);
// Now, restart the workspace with ephemeral parameters selected.
const buildParameters = [
{ name: firstBuildOption.name, value: "AAAAA" },
{ name: secondBuildOption.name, value: "true" },
]
];
await buildWorkspaceWithParameters(
page,
workspaceName,
richParameters,
buildParameters,
true,
)
);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
})
]);
});

View File

@ -1,4 +1,4 @@
import { test } from "@playwright/test"
import { test } from "@playwright/test";
import {
buildWorkspaceWithParameters,
createTemplate,
@ -6,44 +6,44 @@ import {
echoResponsesWithParameters,
stopWorkspace,
verifyParameters,
} from "../helpers"
} from "../helpers";
import { firstBuildOption, secondBuildOption } from "../parameters"
import { RichParameter } from "../provisionerGenerated"
import { firstBuildOption, secondBuildOption } from "../parameters";
import { RichParameter } from "../provisionerGenerated";
test("start workspace with ephemeral parameters", async ({ page }) => {
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
const workspaceName = await createWorkspace(page, template)
);
const workspaceName = await createWorkspace(page, template);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
]);
// Stop the workspace
await stopWorkspace(page, workspaceName)
await stopWorkspace(page, workspaceName);
// Now, start the workspace with ephemeral parameters selected.
const buildParameters = [
{ name: firstBuildOption.name, value: "AAAAA" },
{ name: secondBuildOption.name, value: "true" },
]
];
await buildWorkspaceWithParameters(
page,
workspaceName,
richParameters,
buildParameters,
)
);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
})
]);
});

View File

@ -1,4 +1,4 @@
import { test } from "@playwright/test"
import { test } from "@playwright/test";
import {
createTemplate,
@ -8,7 +8,7 @@ import {
updateWorkspace,
updateWorkspaceParameters,
verifyParameters,
} from "../helpers"
} from "../helpers";
import {
fifthParameter,
@ -16,119 +16,119 @@ import {
secondParameter,
sixthParameter,
secondBuildOption,
} from "../parameters"
import { RichParameter } from "../provisionerGenerated"
import { beforeCoderTest } from "../hooks"
} from "../parameters";
import { RichParameter } from "../provisionerGenerated";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("update workspace, new optional, immutable parameter added", async ({
page,
}) => {
const richParameters: RichParameter[] = [firstParameter, secondParameter]
const richParameters: RichParameter[] = [firstParameter, secondParameter];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
);
const workspaceName = await createWorkspace(page, template)
const workspaceName = await createWorkspace(page, template);
// Verify that parameter values are default.
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondParameter.name, value: secondParameter.defaultValue },
])
]);
// Push updated template.
const updatedRichParameters = [...richParameters, fifthParameter]
const updatedRichParameters = [...richParameters, fifthParameter];
await updateTemplate(
page,
template,
echoResponsesWithParameters(updatedRichParameters),
)
);
// Now, update the workspace, and select the value for immutable parameter.
await updateWorkspace(page, workspaceName, updatedRichParameters, [
{ name: fifthParameter.name, value: fifthParameter.options[0].value },
])
]);
// Verify parameter values.
await verifyParameters(page, workspaceName, updatedRichParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondParameter.name, value: secondParameter.defaultValue },
{ name: fifthParameter.name, value: fifthParameter.options[0].value },
])
})
]);
});
test("update workspace, new required, mutable parameter added", async ({
page,
}) => {
const richParameters: RichParameter[] = [firstParameter, secondParameter]
const richParameters: RichParameter[] = [firstParameter, secondParameter];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
);
const workspaceName = await createWorkspace(page, template)
const workspaceName = await createWorkspace(page, template);
// Verify that parameter values are default.
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondParameter.name, value: secondParameter.defaultValue },
])
]);
// Push updated template.
const updatedRichParameters = [...richParameters, sixthParameter]
const updatedRichParameters = [...richParameters, sixthParameter];
await updateTemplate(
page,
template,
echoResponsesWithParameters(updatedRichParameters),
)
);
// Now, update the workspace, and provide the parameter value.
const buildParameters = [{ name: sixthParameter.name, value: "99" }]
const buildParameters = [{ name: sixthParameter.name, value: "99" }];
await updateWorkspace(
page,
workspaceName,
updatedRichParameters,
buildParameters,
)
);
// Verify parameter values.
await verifyParameters(page, workspaceName, updatedRichParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondParameter.name, value: secondParameter.defaultValue },
...buildParameters,
])
})
]);
});
test("update workspace with ephemeral parameter enabled", async ({ page }) => {
const richParameters: RichParameter[] = [firstParameter, secondBuildOption]
const richParameters: RichParameter[] = [firstParameter, secondBuildOption];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
)
);
const workspaceName = await createWorkspace(page, template)
const workspaceName = await createWorkspace(page, template);
// Verify that parameter values are default.
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
]);
// Now, update the workspace, and select the value for ephemeral parameter.
const buildParameters = [{ name: secondBuildOption.name, value: "true" }]
const buildParameters = [{ name: secondBuildOption.name, value: "true" }];
await updateWorkspaceParameters(
page,
workspaceName,
richParameters,
buildParameters,
)
);
// Verify that parameter values are default.
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
])
})
]);
});

View File

@ -1,17 +1,17 @@
import { test } from "@playwright/test"
import { test } from "@playwright/test";
import {
createTemplate,
createWorkspace,
startAgent,
stopAgent,
} from "../helpers"
import { randomUUID } from "crypto"
import { beforeCoderTest } from "../hooks"
} from "../helpers";
import { randomUUID } from "crypto";
import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("web terminal", async ({ context, page }) => {
const token = randomUUID()
const token = randomUUID();
const template = await createTemplate(page, {
apply: [
{
@ -31,29 +31,29 @@ test("web terminal", async ({ context, page }) => {
},
},
],
})
await createWorkspace(page, template)
const agent = await startAgent(page, token)
});
await createWorkspace(page, template);
const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page")
await page.getByTestId("terminal").click()
const terminal = await pagePromise
await terminal.waitForLoadState("domcontentloaded")
const pagePromise = context.waitForEvent("page");
await page.getByTestId("terminal").click();
const terminal = await pagePromise;
await terminal.waitForLoadState("domcontentloaded");
// Ensure that we can type in it
await terminal.keyboard.type("echo hello")
await terminal.keyboard.press("Enter")
await terminal.keyboard.type("echo hello");
await terminal.keyboard.press("Enter");
const locator = terminal.locator("text=hello")
const locator = terminal.locator("text=hello");
for (let i = 0; i < 10; i++) {
const items = await locator.all()
const items = await locator.all();
// Make sure the text came back
if (items.length === 2) {
break
break;
}
await new Promise((r) => setTimeout(r, 250))
await new Promise((r) => setTimeout(r, 250));
}
await stopAgent(agent)
})
await stopAgent(agent);
});

View File

@ -1,5 +1,5 @@
// Toggle eslint --fix by specifying the `FIX` env.
const fix = !!process.env.FIX
const fix = !!process.env.FIX;
module.exports = {
cliOptions: {
@ -10,4 +10,4 @@ module.exports = {
resolvePluginsRelativeTo: ".",
maxWarnings: 0,
},
}
};

View File

@ -67,4 +67,4 @@ module.exports = {
"!<rootDir>/out/**/*.*",
"!<rootDir>/storybook-static/**/*.*",
],
}
};

View File

@ -1,16 +1,16 @@
import "@testing-library/jest-dom"
import { cleanup } from "@testing-library/react"
import crypto from "crypto"
import { server } from "./src/testHelpers/server"
import "jest-location-mock"
import { TextEncoder, TextDecoder } from "util"
import { Blob } from "buffer"
import jestFetchMock from "jest-fetch-mock"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
import { Region } from "api/typesGenerated"
import { useMemo } from "react"
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import crypto from "crypto";
import { server } from "./src/testHelpers/server";
import "jest-location-mock";
import { TextEncoder, TextDecoder } from "util";
import { Blob } from "buffer";
import jestFetchMock from "jest-fetch-mock";
import { ProxyLatencyReport } from "contexts/useProxyLatency";
import { Region } from "api/typesGenerated";
import { useMemo } from "react";
jestFetchMock.enableMocks()
jestFetchMock.enableMocks();
// useProxyLatency does some http requests to determine latency.
// This would fail unit testing, or at least make it very slow with
@ -21,7 +21,7 @@ jest.mock("contexts/useProxyLatency", () => ({
// Mocking the hook with a hook.
const proxyLatencies = useMemo(() => {
if (!proxies) {
return {} as Record<string, ProxyLatencyReport>
return {} as Record<string, ProxyLatencyReport>;
}
return proxies.reduce(
(acc, proxy) => {
@ -31,49 +31,49 @@ jest.mock("contexts/useProxyLatency", () => ({
// If you make this random it could break stories.
latencyMS: 8,
at: new Date(),
}
return acc
};
return acc;
},
{} as Record<string, ProxyLatencyReport>,
)
}, [proxies])
);
}, [proxies]);
return { proxyLatencies, refetch: jest.fn() }
return { proxyLatencies, refetch: jest.fn() };
},
}))
}));
global.TextEncoder = TextEncoder
global.TextEncoder = TextEncoder;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
global.TextDecoder = TextDecoder as any
global.TextDecoder = TextDecoder as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
global.Blob = Blob as any
global.Blob = Blob as any;
// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
value: {
getRandomValues: function (buffer: Buffer) {
return crypto.randomFillSync(buffer)
return crypto.randomFillSync(buffer);
},
},
})
});
// Establish API mocking before all tests through MSW.
beforeAll(() =>
server.listen({
onUnhandledRequest: "warn",
}),
)
);
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => {
cleanup()
server.resetHandlers()
jest.clearAllMocks()
})
cleanup();
server.resetHandlers();
jest.clearAllMocks();
});
// Clean up after the tests are finished.
afterAll(() => server.close())
afterAll(() => server.close());
// This is needed because we are compiling under `--isolatedModules`
export {}
export {};

View File

@ -1,9 +1,9 @@
declare module "@emoji-mart/react" {
const Picker: React.FC<{
theme: "dark" | "light"
data: Record<string, unknown>
onEmojiSelect: (emojiData: { unified: string }) => void
}>
theme: "dark" | "light";
data: Record<string, unknown>;
onEmojiSelect: (emojiData: { unified: string }) => void;
}>;
export default Picker
export default Picker;
}

View File

@ -1 +1 @@
declare module "eventsourcemock"
declare module "eventsourcemock";

View File

@ -1,10 +1,10 @@
import "i18next"
import "i18next";
// https://github.com/i18next/react-i18next/issues/1543#issuecomment-1528679591
declare module "i18next" {
interface TypeOptions {
returnNull: false
allowObjectInHTMLChildren: false
returnNull: false;
allowObjectInHTMLChildren: false;
}
export function t<T>(s: string): T
export function t<T>(s: string): T;
}

View File

@ -1,4 +1,4 @@
import { PaletteColor, PaletteColorOptions, Theme } from "@mui/material/styles"
import { PaletteColor, PaletteColorOptions, Theme } from "@mui/material/styles";
declare module "@mui/styles/defaultTheme" {
interface DefaultTheme extends Theme {}
@ -6,20 +6,20 @@ declare module "@mui/styles/defaultTheme" {
declare module "@mui/material/styles" {
interface TypeBackground {
paperLight: string
paperLight: string;
}
interface Palette {
neutral: PaletteColor
neutral: PaletteColor;
}
interface PaletteOptions {
neutral?: PaletteColorOptions
neutral?: PaletteColorOptions;
}
}
declare module "@mui/material/Button" {
interface ButtonPropsColorOverrides {
neutral: true
neutral: true;
}
}

View File

@ -1,186 +1,188 @@
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
import { UsersLayout } from "components/UsersLayout/UsersLayout"
import IndexPage from "pages"
import AuditPage from "pages/AuditPage/AuditPage"
import GroupsPage from "pages/GroupsPage/GroupsPage"
import LoginPage from "pages/LoginPage/LoginPage"
import { SetupPage } from "pages/SetupPage/SetupPage"
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage"
import TemplatesPage from "pages/TemplatesPage/TemplatesPage"
import UsersPage from "pages/UsersPage/UsersPage"
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
import { FC, lazy, Suspense } from "react"
import { Route, Routes, BrowserRouter as Router } from "react-router-dom"
import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout";
import { UsersLayout } from "components/UsersLayout/UsersLayout";
import IndexPage from "pages";
import AuditPage from "pages/AuditPage/AuditPage";
import GroupsPage from "pages/GroupsPage/GroupsPage";
import LoginPage from "pages/LoginPage/LoginPage";
import { SetupPage } from "pages/SetupPage/SetupPage";
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage";
import TemplatesPage from "pages/TemplatesPage/TemplatesPage";
import UsersPage from "pages/UsersPage/UsersPage";
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage";
import { FC, lazy, Suspense } from "react";
import { Route, Routes, BrowserRouter as Router } from "react-router-dom";
import { DashboardLayout } from "./components/Dashboard/DashboardLayout";
import { RequireAuth } from "./components/RequireAuth/RequireAuth";
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout";
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout";
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
// - Pages that use heavy dependencies like charts or time libraries
const NotFoundPage = lazy(() => import("./pages/404Page/404Page"))
const NotFoundPage = lazy(() => import("./pages/404Page/404Page"));
const CliAuthenticationPage = lazy(
() => import("./pages/CliAuthPage/CliAuthPage"),
)
);
const AccountPage = lazy(
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
)
);
const SecurityPage = lazy(
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
)
);
const SSHKeysPage = lazy(
() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"),
)
);
const TokensPage = lazy(
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
)
);
const WorkspaceProxyPage = lazy(
() =>
import("./pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage"),
)
);
const CreateUserPage = lazy(
() => import("./pages/CreateUserPage/CreateUserPage"),
)
);
const WorkspaceBuildPage = lazy(
() => import("./pages/WorkspaceBuildPage/WorkspaceBuildPage"),
)
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
);
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"));
const WorkspaceSchedulePage = lazy(
() =>
import(
"./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage"
),
)
);
const WorkspaceParametersPage = lazy(
() =>
import(
"./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage"
),
)
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
);
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"));
const TemplatePermissionsPage = lazy(
() =>
import(
"./pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage"
),
)
);
const TemplateSummaryPage = lazy(
() => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"),
)
);
const CreateWorkspacePage = lazy(
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
)
const CreateGroupPage = lazy(() => import("./pages/GroupsPage/CreateGroupPage"))
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"))
);
const CreateGroupPage = lazy(
() => import("./pages/GroupsPage/CreateGroupPage"),
);
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"));
const SettingsGroupPage = lazy(
() => import("./pages/GroupsPage/SettingsGroupPage"),
)
);
const GeneralSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage"
),
)
);
const SecuritySettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage"
),
)
);
const AppearanceSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage"
),
)
);
const UserAuthSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage"
),
)
);
const GitAuthSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage"
),
)
);
const NetworkSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage"
),
)
const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
);
const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"));
const TemplateVersionPage = lazy(
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
)
);
const TemplateVersionEditorPage = lazy(
() => import("./pages/TemplateVersionEditorPage/TemplateVersionEditorPage"),
)
);
const StarterTemplatesPage = lazy(
() => import("./pages/StarterTemplatesPage/StarterTemplatesPage"),
)
);
const StarterTemplatePage = lazy(
() => import("pages/StarterTemplatePage/StarterTemplatePage"),
)
);
const CreateTemplatePage = lazy(
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
)
);
const TemplateVariablesPage = lazy(
() =>
import(
"./pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage"
),
)
);
const WorkspaceSettingsPage = lazy(
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
)
);
const CreateTokenPage = lazy(
() => import("./pages/CreateTokenPage/CreateTokenPage"),
)
);
const TemplateDocsPage = lazy(
() => import("./pages/TemplatePage/TemplateDocsPage/TemplateDocsPage"),
)
);
const TemplateFilesPage = lazy(
() => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"),
)
);
const TemplateVersionsPage = lazy(
() =>
import("./pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage"),
)
);
const TemplateSchedulePage = lazy(
() =>
import(
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
),
)
);
const LicensesSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage"
),
)
);
const AddNewLicensePage = lazy(
() =>
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
)
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
)
);
const TemplateInsightsPage = lazy(
() =>
import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"),
)
const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"))
);
const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"));
export const AppRouter: FC = () => {
return (
@ -331,5 +333,5 @@ export const AppRouter: FC = () => {
</Routes>
</Router>
</Suspense>
)
}
);
};

View File

@ -1,8 +1,8 @@
import { inspect } from "@xstate/inspect"
import { createRoot } from "react-dom/client"
import { Interpreter } from "xstate"
import { App } from "./app"
import "./i18n"
import { inspect } from "@xstate/inspect";
import { createRoot } from "react-dom/client";
import { Interpreter } from "xstate";
import { App } from "./app";
import "./i18n";
// if this is a development build and the developer wants to inspect
// helpful to see realtime changes on the services
@ -14,9 +14,9 @@ if (
inspect({
url: "https://stately.ai/viz?inspect",
iframe: false,
})
});
// configure all XServices to use the inspector
Interpreter.defaultOptions.devTools = true
Interpreter.defaultOptions.devTools = true;
}
// This is the entry point for the app - where everything start.
@ -28,13 +28,13 @@ const main = () => {
`)
const element = document.getElementById("root")
`);
const element = document.getElementById("root");
if (element === null) {
throw new Error("root element is null")
throw new Error("root element is null");
}
const root = createRoot(element)
root.render(<App />)
}
const root = createRoot(element);
root.render(<App />);
};
main()
main();

View File

@ -1 +1 @@
export default jest.fn()
export default jest.fn();

View File

@ -7,14 +7,14 @@ const editor = {
dispose: () => {
//
},
}
};
},
}
};
const monaco = {
editor,
}
};
module.exports = monaco
module.exports = monaco;
export {}
export {};

View File

@ -1,7 +1,7 @@
import { FC, PropsWithChildren } from "react"
import { FC, PropsWithChildren } from "react";
const ReactMarkdown: FC<PropsWithChildren<unknown>> = ({ children }) => {
return <div data-testid="markdown">{children}</div>
}
return <div data-testid="markdown">{children}</div>;
};
export default ReactMarkdown
export default ReactMarkdown;

View File

@ -1,4 +1,4 @@
import axios from "axios"
import axios from "axios";
import {
MockTemplate,
MockTemplateVersionParameter1,
@ -6,9 +6,9 @@ import {
MockWorkspace,
MockWorkspaceBuild,
MockWorkspaceBuildParameter1,
} from "testHelpers/entities"
import * as api from "./api"
import * as TypesGen from "./typesGenerated"
} from "testHelpers/entities";
import * as api from "./api";
import * as TypesGen from "./typesGenerated";
describe("api.ts", () => {
describe("login", () => {
@ -16,111 +16,111 @@ describe("api.ts", () => {
// given
const loginResponse: TypesGen.LoginWithPasswordResponse = {
session_token: "abc_123_test",
}
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse })
};
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse });
// when
const result = await api.login("test", "123")
const result = await api.login("test", "123");
// then
expect(axios.post).toHaveBeenCalled()
expect(result).toStrictEqual(loginResponse)
})
expect(axios.post).toHaveBeenCalled();
expect(result).toStrictEqual(loginResponse);
});
it("should throw an error on 401", async () => {
// given
// ..ensure that we await our expect assertion in async/await test
expect.assertions(1)
expect.assertions(1);
const expectedError = {
message: "Validation failed",
errors: [{ field: "email", code: "email" }],
}
};
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError)
})
axios.post = axiosMockPost
return Promise.reject(expectedError);
});
axios.post = axiosMockPost;
try {
await api.login("test", "123")
await api.login("test", "123");
} catch (error) {
expect(error).toStrictEqual(expectedError)
expect(error).toStrictEqual(expectedError);
}
})
})
});
});
describe("logout", () => {
it("should return without erroring", async () => {
// given
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.resolve()
})
axios.post = axiosMockPost
return Promise.resolve();
});
axios.post = axiosMockPost;
// when
await api.logout()
await api.logout();
// then
expect(axiosMockPost).toHaveBeenCalled()
})
expect(axiosMockPost).toHaveBeenCalled();
});
it("should throw an error on 500", async () => {
// given
// ..ensure that we await our expect assertion in async/await test
expect.assertions(1)
expect.assertions(1);
const expectedError = {
message: "Failed to logout.",
}
};
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError)
})
axios.post = axiosMockPost
return Promise.reject(expectedError);
});
axios.post = axiosMockPost;
try {
await api.logout()
await api.logout();
} catch (error) {
expect(error).toStrictEqual(expectedError)
expect(error).toStrictEqual(expectedError);
}
})
})
});
});
describe("getApiKey", () => {
it("should return APIKeyResponse", async () => {
// given
const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = {
key: "abc_123_test",
}
};
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.resolve({ data: apiKeyResponse })
})
axios.post = axiosMockPost
return Promise.resolve({ data: apiKeyResponse });
});
axios.post = axiosMockPost;
// when
const result = await api.getApiKey()
const result = await api.getApiKey();
// then
expect(axiosMockPost).toHaveBeenCalled()
expect(result).toStrictEqual(apiKeyResponse)
})
expect(axiosMockPost).toHaveBeenCalled();
expect(result).toStrictEqual(apiKeyResponse);
});
it("should throw an error on 401", async () => {
// given
// ..ensure that we await our expect assertion in async/await test
expect.assertions(1)
expect.assertions(1);
const expectedError = {
message: "No Cookie!",
}
};
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError)
})
axios.post = axiosMockPost
return Promise.reject(expectedError);
});
axios.post = axiosMockPost;
try {
await api.getApiKey()
await api.getApiKey();
} catch (error) {
expect(error).toStrictEqual(expectedError)
expect(error).toStrictEqual(expectedError);
}
})
})
});
});
describe("getURLWithSearchParams - workspaces", () => {
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
@ -141,10 +141,10 @@ describe("api.ts", () => {
])(
`Workspaces - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => {
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
},
)
})
);
});
describe("getURLWithSearchParams - users", () => {
it.each<[string, TypesGen.UsersRequest | undefined, string]>([
@ -158,72 +158,72 @@ describe("api.ts", () => {
])(
`Users - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => {
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
},
)
})
);
});
describe("update", () => {
it("creates a build with start and the latest template", async () => {
jest
.spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild)
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
await api.updateWorkspace(MockWorkspace)
.mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
await api.updateWorkspace(MockWorkspace);
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
})
})
});
});
it("fails when having missing parameters", async () => {
jest
.spyOn(api, "postWorkspaceBuild")
.mockResolvedValue(MockWorkspaceBuild)
jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate)
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([])
.mockResolvedValue(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([]);
jest
.spyOn(api, "getTemplateVersionRichParameters")
.mockResolvedValue([
MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false },
])
]);
let error = new Error()
let error = new Error();
try {
await api.updateWorkspace(MockWorkspace)
await api.updateWorkspace(MockWorkspace);
} catch (e) {
error = e as Error
error = e as Error;
}
expect(error).toBeInstanceOf(api.MissingBuildParameters)
expect(error).toBeInstanceOf(api.MissingBuildParameters);
// Verify if the correct missing parameters are being passed
expect((error as api.MissingBuildParameters).parameters).toEqual([
MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false },
])
})
]);
});
it("creates a build with the no parameters if it is already filled", async () => {
jest
.spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild)
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
.mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest
.spyOn(api, "getWorkspaceBuildParameters")
.mockResolvedValue([MockWorkspaceBuildParameter1])
.mockResolvedValue([MockWorkspaceBuildParameter1]);
jest
.spyOn(api, "getTemplateVersionRichParameters")
.mockResolvedValue([
{ ...MockTemplateVersionParameter1, required: true, mutable: false },
])
await api.updateWorkspace(MockWorkspace)
]);
await api.updateWorkspace(MockWorkspace);
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
})
})
})
})
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
import { mockApiError } from "testHelpers/entities"
import { mockApiError } from "testHelpers/entities";
import {
getValidationErrorMessage,
isApiError,
mapApiErrorToFieldErrors,
} from "./errors"
} from "./errors";
describe("isApiError", () => {
it("returns true when the object is an API Error", () => {
@ -16,17 +16,17 @@ describe("isApiError", () => {
],
}),
),
).toBe(true)
})
).toBe(true);
});
it("returns false when the object is Error", () => {
expect(isApiError(new Error())).toBe(false)
})
expect(isApiError(new Error())).toBe(false);
});
it("returns false when the object is undefined", () => {
expect(isApiError(undefined)).toBe(false)
})
})
expect(isApiError(undefined)).toBe(false);
});
});
describe("mapApiErrorToFieldErrors", () => {
it("returns correct field errors", () => {
@ -39,9 +39,9 @@ describe("mapApiErrorToFieldErrors", () => {
}),
).toEqual({
username: "Username is already in use",
})
})
})
});
});
});
describe("getValidationErrorMessage", () => {
it("returns multiple validation messages", () => {
@ -63,14 +63,14 @@ describe("getValidationErrorMessage", () => {
),
).toEqual(
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
)
})
);
});
it("non-API error returns empty validation message", () => {
expect(
getValidationErrorMessage(new Error("Invalid user search query.")),
).toEqual("")
})
).toEqual("");
});
it("no validations field returns empty validation message", () => {
expect(
@ -80,6 +80,6 @@ describe("getValidationErrorMessage", () => {
detail: `Query element "role:a:e" can only contain 1 ':'`,
}),
),
).toEqual("")
})
})
).toEqual("");
});
});

View File

@ -1,56 +1,56 @@
import axios, { AxiosError, AxiosResponse } from "axios"
import axios, { AxiosError, AxiosResponse } from "axios";
const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
}
};
export interface FieldError {
field: string
detail: string
field: string;
detail: string;
}
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>;
export interface ApiErrorResponse {
message: string
detail?: string
validations?: FieldError[]
message: string;
detail?: string;
validations?: FieldError[];
}
export type ApiError = AxiosError<ApiErrorResponse> & {
response: AxiosResponse<ApiErrorResponse>
}
response: AxiosResponse<ApiErrorResponse>;
};
export const isApiError = (err: unknown): err is ApiError => {
return axios.isAxiosError(err) && err.response !== undefined
}
return axios.isAxiosError(err) && err.response !== undefined;
};
export const hasApiFieldErrors = (error: ApiError): boolean =>
Array.isArray(error.response.data.validations)
Array.isArray(error.response.data.validations);
export const isApiValidationError = (error: unknown): error is ApiError => {
return isApiError(error) && hasApiFieldErrors(error)
}
return isApiError(error) && hasApiFieldErrors(error);
};
export const hasError = (error: unknown) =>
error !== undefined && error !== null
error !== undefined && error !== null;
export const mapApiErrorToFieldErrors = (
apiErrorResponse: ApiErrorResponse,
): FieldErrors => {
const result: FieldErrors = {}
const result: FieldErrors = {};
if (apiErrorResponse.validations) {
for (const error of apiErrorResponse.validations) {
result[error.field] =
error.detail || Language.errorsByCode.defaultErrorCode
error.detail || Language.errorsByCode.defaultErrorCode;
}
}
return result
}
return result;
};
/**
*
@ -66,7 +66,7 @@ export const getErrorMessage = (
? error.response.data.message
: error instanceof Error
? error.message
: defaultMessage
: defaultMessage;
/**
*
@ -78,13 +78,13 @@ export const getValidationErrorMessage = (error: unknown): string => {
const validationErrors =
isApiError(error) && error.response.data.validations
? error.response.data.validations
: []
return validationErrors.map((error) => error.detail).join("\n")
}
: [];
return validationErrors.map((error) => error.detail).join("\n");
};
export const getErrorDetail = (error: unknown): string | undefined | null =>
isApiError(error)
? error.response.data.detail
: error instanceof Error
? `Please check the developer console for more details.`
: null
: null;

View File

@ -1,40 +1,40 @@
import { DeploymentValues } from "./typesGenerated"
import { DeploymentValues } from "./typesGenerated";
export interface UserAgent {
readonly browser: string
readonly device: string
readonly ip_address: string
readonly os: string
readonly browser: string;
readonly device: string;
readonly ip_address: string;
readonly os: string;
}
export interface ReconnectingPTYRequest {
readonly data?: string
readonly height?: number
readonly width?: number
readonly data?: string;
readonly height?: number;
readonly width?: number;
}
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
export type WorkspaceBuildTransition = "start" | "stop" | "delete";
export type Message = { message: string }
export type Message = { message: string };
export interface DeploymentGroup {
readonly name: string
readonly parent?: DeploymentGroup
readonly description: string
readonly children: DeploymentGroup[]
readonly name: string;
readonly parent?: DeploymentGroup;
readonly description: string;
readonly children: DeploymentGroup[];
}
export interface DeploymentOption {
readonly name: string
readonly description: string
readonly flag: string
readonly flag_shorthand: string
readonly value: unknown
readonly hidden: boolean
readonly group?: DeploymentGroup
readonly name: string;
readonly description: string;
readonly flag: string;
readonly flag_shorthand: string;
readonly value: unknown;
readonly hidden: boolean;
readonly group?: DeploymentGroup;
}
export type DeploymentConfig = {
readonly config: DeploymentValues
readonly options: DeploymentOption[]
}
readonly config: DeploymentValues;
readonly options: DeploymentOption[];
};

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
import CssBaseline from "@mui/material/CssBaseline"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { AuthProvider } from "components/AuthProvider/AuthProvider"
import { FC, PropsWithChildren } from "react"
import { HelmetProvider } from "react-helmet-async"
import { AppRouter } from "./AppRouter"
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary"
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
import { dark } from "./theme"
import "./theme/globalFonts"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import CssBaseline from "@mui/material/CssBaseline";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "components/AuthProvider/AuthProvider";
import { FC, PropsWithChildren } from "react";
import { HelmetProvider } from "react-helmet-async";
import { AppRouter } from "./AppRouter";
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
import { dark } from "./theme";
import "./theme/globalFonts";
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
const queryClient = new QueryClient({
defaultOptions: {
@ -19,7 +19,7 @@ const queryClient = new QueryClient({
networkMode: "offlineFirst",
},
},
})
});
export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
return (
@ -38,13 +38,13 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
</ThemeProvider>
</StyledEngineProvider>
</HelmetProvider>
)
}
);
};
export const App: FC = () => {
return (
<AppProviders>
<AppRouter />
</AppProviders>
)
}
);
};

View File

@ -1,21 +1,21 @@
import { Alert } from "./Alert"
import Button from "@mui/material/Button"
import Link from "@mui/material/Link"
import type { Meta, StoryObj } from "@storybook/react"
import { Alert } from "./Alert";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof Alert> = {
title: "components/Alert",
component: Alert,
}
};
export default meta
type Story = StoryObj<typeof Alert>
export default meta;
type Story = StoryObj<typeof Alert>;
const ExampleAction = (
<Button onClick={() => null} size="small" variant="text">
Button
</Button>
)
);
export const Success: Story = {
args: {
@ -23,14 +23,14 @@ export const Success: Story = {
severity: "success",
onRetry: undefined,
},
}
};
export const Warning: Story = {
args: {
children: "This is a warning",
severity: "warning",
},
}
};
export const WarningWithDismiss: Story = {
args: {
@ -38,7 +38,7 @@ export const WarningWithDismiss: Story = {
dismissible: true,
severity: "warning",
},
}
};
export const WarningWithAction: Story = {
args: {
@ -46,7 +46,7 @@ export const WarningWithAction: Story = {
actions: [ExampleAction],
severity: "warning",
},
}
};
export const WarningWithActionAndDismiss: Story = {
args: {
@ -55,7 +55,7 @@ export const WarningWithActionAndDismiss: Story = {
dismissible: true,
severity: "warning",
},
}
};
export const WithChildren: Story = {
args: {
@ -66,4 +66,4 @@ export const WithChildren: Story = {
</div>
),
},
}
};

View File

@ -1,16 +1,16 @@
import { useState, FC, ReactNode } from "react"
import Collapse from "@mui/material/Collapse"
import { useState, FC, ReactNode } from "react";
import Collapse from "@mui/material/Collapse";
// eslint-disable-next-line no-restricted-imports -- It is the base component
import MuiAlert, { AlertProps as MuiAlertProps } from "@mui/material/Alert"
import Button from "@mui/material/Button"
import Box from "@mui/material/Box"
import MuiAlert, { AlertProps as MuiAlertProps } from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
export type AlertProps = MuiAlertProps & {
actions?: ReactNode
dismissible?: boolean
onRetry?: () => void
onDismiss?: () => void
}
actions?: ReactNode;
dismissible?: boolean;
onRetry?: () => void;
onDismiss?: () => void;
};
export const Alert: FC<AlertProps> = ({
children,
@ -21,7 +21,7 @@ export const Alert: FC<AlertProps> = ({
onDismiss,
...alertProps
}) => {
const [open, setOpen] = useState(true)
const [open, setOpen] = useState(true);
return (
<Collapse in={open}>
@ -47,8 +47,8 @@ export const Alert: FC<AlertProps> = ({
variant="text"
size="small"
onClick={() => {
setOpen(false)
onDismiss && onDismiss()
setOpen(false);
onDismiss && onDismiss();
}}
data-testid="dismiss-banner-btn"
>
@ -61,8 +61,8 @@ export const Alert: FC<AlertProps> = ({
{children}
</MuiAlert>
</Collapse>
)
}
);
};
export const AlertDetail = ({ children }: { children: ReactNode }) => {
return (
@ -74,5 +74,5 @@ export const AlertDetail = ({ children }: { children: ReactNode }) => {
>
{children}
</Box>
)
}
);
};

View File

@ -1,13 +1,13 @@
import Button from "@mui/material/Button"
import { mockApiError } from "testHelpers/entities"
import type { Meta, StoryObj } from "@storybook/react"
import { action } from "@storybook/addon-actions"
import { ErrorAlert } from "./ErrorAlert"
import Button from "@mui/material/Button";
import { mockApiError } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { ErrorAlert } from "./ErrorAlert";
const mockError = mockApiError({
message: "Email or password was invalid",
detail: "Password is invalid",
})
});
const meta: Meta<typeof ErrorAlert> = {
title: "components/ErrorAlert",
@ -17,16 +17,16 @@ const meta: Meta<typeof ErrorAlert> = {
dismissible: false,
onRetry: undefined,
},
}
};
export default meta
type Story = StoryObj<typeof ErrorAlert>
export default meta;
type Story = StoryObj<typeof ErrorAlert>;
const ExampleAction = (
<Button onClick={() => null} size="small" variant="text">
Button
</Button>
)
);
export const WithOnlyMessage: Story = {
args: {
@ -34,33 +34,33 @@ export const WithOnlyMessage: Story = {
message: "Email or password was invalid",
}),
},
}
};
export const WithDismiss: Story = {
args: {
dismissible: true,
},
}
};
export const WithAction: Story = {
args: {
actions: [ExampleAction],
},
}
};
export const WithActionAndDismiss: Story = {
args: {
actions: [ExampleAction],
dismissible: true,
},
}
};
export const WithRetry: Story = {
args: {
onRetry: action("retry"),
dismissible: true,
},
}
};
export const WithActionRetryAndDismiss: Story = {
args: {
@ -68,10 +68,10 @@ export const WithActionRetryAndDismiss: Story = {
onRetry: action("retry"),
dismissible: true,
},
}
};
export const WithNonApiError: Story = {
args: {
error: new Error("Non API error here"),
},
}
};

View File

@ -1,17 +1,17 @@
import { AlertProps, Alert, AlertDetail } from "./Alert"
import AlertTitle from "@mui/material/AlertTitle"
import { getErrorMessage, getErrorDetail } from "api/errors"
import { FC } from "react"
import { AlertProps, Alert, AlertDetail } from "./Alert";
import AlertTitle from "@mui/material/AlertTitle";
import { getErrorMessage, getErrorDetail } from "api/errors";
import { FC } from "react";
export const ErrorAlert: FC<
Omit<AlertProps, "severity" | "children"> & { error: unknown }
> = ({ error, ...alertProps }) => {
const message = getErrorMessage(error, "Something went wrong.")
const detail = getErrorDetail(error)
const message = getErrorMessage(error, "Something went wrong.");
const detail = getErrorDetail(error);
// For some reason, the message and detail can be the same on the BE, but does
// not make sense in the FE to showing them duplicated
const shouldDisplayDetail = message !== detail
const shouldDisplayDetail = message !== detail;
return (
<Alert severity="error" {...alertProps}>
@ -24,5 +24,5 @@ export const ErrorAlert: FC<
message
)}
</Alert>
)
}
);
};

View File

@ -1,36 +1,36 @@
import { useActor, useInterpret } from "@xstate/react"
import { createContext, FC, PropsWithChildren, useContext } from "react"
import { authMachine } from "xServices/auth/authXService"
import { ActorRefFrom } from "xstate"
import { useActor, useInterpret } from "@xstate/react";
import { createContext, FC, PropsWithChildren, useContext } from "react";
import { authMachine } from "xServices/auth/authXService";
import { ActorRefFrom } from "xstate";
interface AuthContextValue {
authService: ActorRefFrom<typeof authMachine>
authService: ActorRefFrom<typeof authMachine>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const authService = useInterpret(authMachine)
const authService = useInterpret(authMachine);
return (
<AuthContext.Provider value={{ authService }}>
{children}
</AuthContext.Provider>
)
}
);
};
type UseAuthReturnType = ReturnType<
typeof useActor<AuthContextValue["authService"]>
>
>;
export const useAuth = (): UseAuthReturnType => {
const context = useContext(AuthContext)
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth should be used inside of <AuthProvider />")
throw new Error("useAuth should be used inside of <AuthProvider />");
}
const auth = useActor(context.authService)
const auth = useActor(context.authService);
return auth
}
return auth;
};

View File

@ -1,61 +1,63 @@
import { Story } from "@storybook/react"
import { Avatar, AvatarIcon, AvatarProps } from "./Avatar"
import PauseIcon from "@mui/icons-material/PauseOutlined"
import { Story } from "@storybook/react";
import { Avatar, AvatarIcon, AvatarProps } from "./Avatar";
import PauseIcon from "@mui/icons-material/PauseOutlined";
export default {
title: "components/Avatar",
component: Avatar,
}
};
const Template: Story<AvatarProps> = (args: AvatarProps) => <Avatar {...args} />
const Template: Story<AvatarProps> = (args: AvatarProps) => (
<Avatar {...args} />
);
export const Letter = Template.bind({})
export const Letter = Template.bind({});
Letter.args = {
children: "Coder",
}
};
export const LetterXL = Template.bind({})
export const LetterXL = Template.bind({});
LetterXL.args = {
children: "Coder",
size: "xl",
}
};
export const LetterDarken = Template.bind({})
export const LetterDarken = Template.bind({});
LetterDarken.args = {
children: "Coder",
colorScheme: "darken",
}
};
export const Image = Template.bind({})
export const Image = Template.bind({});
Image.args = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
}
};
export const ImageXL = Template.bind({})
export const ImageXL = Template.bind({});
ImageXL.args = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
size: "xl",
}
};
export const MuiIcon = Template.bind({})
export const MuiIcon = Template.bind({});
MuiIcon.args = {
children: <PauseIcon />,
}
};
export const MuiIconDarken = Template.bind({})
export const MuiIconDarken = Template.bind({});
MuiIconDarken.args = {
children: <PauseIcon />,
colorScheme: "darken",
}
};
export const MuiIconXL = Template.bind({})
export const MuiIconXL = Template.bind({});
MuiIconXL.args = {
children: <PauseIcon />,
size: "xl",
}
};
export const AvatarIconDarken = Template.bind({})
export const AvatarIconDarken = Template.bind({});
AvatarIconDarken.args = {
children: <AvatarIcon src="/icon/database.svg" />,
colorScheme: "darken",
}
};

View File

@ -1,16 +1,16 @@
// This is the only place MuiAvatar can be used
// eslint-disable-next-line no-restricted-imports -- Read above
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar"
import { makeStyles } from "@mui/styles"
import { FC } from "react"
import { combineClasses } from "utils/combineClasses"
import { firstLetter } from "./firstLetter"
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar";
import { makeStyles } from "@mui/styles";
import { FC } from "react";
import { combineClasses } from "utils/combineClasses";
import { firstLetter } from "./firstLetter";
export type AvatarProps = MuiAvatarProps & {
size?: "sm" | "md" | "xl"
colorScheme?: "light" | "darken"
fitImage?: boolean
}
size?: "sm" | "md" | "xl";
colorScheme?: "light" | "darken";
fitImage?: boolean;
};
export const Avatar: FC<AvatarProps> = ({
size = "md",
@ -20,7 +20,7 @@ export const Avatar: FC<AvatarProps> = ({
children,
...muiProps
}) => {
const styles = useStyles()
const styles = useStyles();
return (
<MuiAvatar
@ -35,16 +35,16 @@ export const Avatar: FC<AvatarProps> = ({
{/* If the children is a string, we always want to render the first letter */}
{typeof children === "string" ? firstLetter(children) : children}
</MuiAvatar>
)
}
);
};
/**
* Use it to make an img element behaves like a MaterialUI Icon component
*/
export const AvatarIcon: FC<{ src: string }> = ({ src }) => {
const styles = useStyles()
return <img src={src} alt="" className={styles.avatarIcon} />
}
const styles = useStyles();
return <img src={src} alt="" className={styles.avatarIcon} />;
};
const useStyles = makeStyles((theme) => ({
// Size styles
@ -77,4 +77,4 @@ const useStyles = makeStyles((theme) => ({
objectFit: "contain",
},
},
}))
}));

View File

@ -1,4 +1,4 @@
import { firstLetter } from "./firstLetter"
import { firstLetter } from "./firstLetter";
describe("first-letter", () => {
it.each<[string, string]>([
@ -6,6 +6,6 @@ describe("first-letter", () => {
["User", "U"],
["test", "T"],
])(`firstLetter(%p) returns %p`, (input, expected) => {
expect(firstLetter(input)).toBe(expected)
})
})
expect(firstLetter(input)).toBe(expected);
});
});

View File

@ -3,8 +3,8 @@
*/
export const firstLetter = (str: string): string => {
if (str.length > 0) {
return str[0].toLocaleUpperCase()
return str[0].toLocaleUpperCase();
}
return ""
}
return "";
};

View File

@ -1,24 +1,24 @@
import { Story } from "@storybook/react"
import { AvatarData, AvatarDataProps } from "./AvatarData"
import { Story } from "@storybook/react";
import { AvatarData, AvatarDataProps } from "./AvatarData";
export default {
title: "components/AvatarData",
component: AvatarData,
}
};
const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => (
<AvatarData {...args} />
)
);
export const Example = Template.bind({})
export const Example = Template.bind({});
Example.args = {
title: "coder",
subtitle: "coder@coder.com",
}
};
export const WithImage = Template.bind({})
export const WithImage = Template.bind({});
WithImage.args = {
title: "coder",
subtitle: "coder@coder.com",
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
}
};

View File

@ -1,13 +1,13 @@
import { Avatar } from "components/Avatar/Avatar"
import { FC, PropsWithChildren } from "react"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@mui/styles"
import { Avatar } from "components/Avatar/Avatar";
import { FC, PropsWithChildren } from "react";
import { Stack } from "components/Stack/Stack";
import { makeStyles } from "@mui/styles";
export interface AvatarDataProps {
title: string | JSX.Element
subtitle?: string
src?: string
avatar?: React.ReactNode
title: string | JSX.Element;
subtitle?: string;
src?: string;
avatar?: React.ReactNode;
}
export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
@ -16,10 +16,10 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
src,
avatar,
}) => {
const styles = useStyles()
const styles = useStyles();
if (!avatar) {
avatar = <Avatar src={src}>{title}</Avatar>
avatar = <Avatar src={src}>{title}</Avatar>;
}
return (
@ -36,8 +36,8 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
</Stack>
</Stack>
)
}
);
};
const useStyles = makeStyles((theme) => ({
root: {
@ -61,4 +61,4 @@ const useStyles = makeStyles((theme) => ({
lineHeight: "150%",
maxWidth: 540,
},
}))
}));

View File

@ -1,6 +1,6 @@
import { FC } from "react"
import { Stack } from "components/Stack/Stack"
import Skeleton from "@mui/material/Skeleton"
import { FC } from "react";
import { Stack } from "components/Stack/Stack";
import Skeleton from "@mui/material/Skeleton";
export const AvatarDataSkeleton: FC = () => {
return (
@ -12,5 +12,5 @@ export const AvatarDataSkeleton: FC = () => {
<Skeleton variant="text" width={60} />
</Stack>
</Stack>
)
}
);
};

View File

@ -1,15 +1,15 @@
import Badge from "@mui/material/Badge"
import { useTheme, withStyles } from "@mui/styles"
import { FC } from "react"
import { WorkspaceBuild } from "api/typesGenerated"
import { getDisplayWorkspaceBuildStatus } from "utils/workspace"
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
import { PaletteIndex } from "theme/theme"
import { Theme } from "@mui/material/styles"
import { BuildIcon } from "components/BuildIcon/BuildIcon"
import Badge from "@mui/material/Badge";
import { useTheme, withStyles } from "@mui/styles";
import { FC } from "react";
import { WorkspaceBuild } from "api/typesGenerated";
import { getDisplayWorkspaceBuildStatus } from "utils/workspace";
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
import { PaletteIndex } from "theme/theme";
import { Theme } from "@mui/material/styles";
import { BuildIcon } from "components/BuildIcon/BuildIcon";
interface StylesBadgeProps {
type: PaletteIndex
type: PaletteIndex;
}
const StyledBadge = withStyles((theme) => ({
@ -22,16 +22,16 @@ const StyledBadge = withStyles((theme) => ({
display: "block",
padding: 0,
},
}))(Badge)
}))(Badge);
export interface BuildAvatarProps {
build: WorkspaceBuild
size?: AvatarProps["size"]
build: WorkspaceBuild;
size?: AvatarProps["size"];
}
export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
const theme = useTheme<Theme>()
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build)
const theme = useTheme<Theme>();
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build);
return (
<StyledBadge
@ -50,5 +50,5 @@ export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
<BuildIcon transition={build.transition} />
</Avatar>
</StyledBadge>
)
}
);
};

View File

@ -1,22 +1,22 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"
import StopOutlined from "@mui/icons-material/StopOutlined"
import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
import { WorkspaceTransition } from "api/typesGenerated"
import { ComponentProps } from "react"
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import StopOutlined from "@mui/icons-material/StopOutlined";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import { WorkspaceTransition } from "api/typesGenerated";
import { ComponentProps } from "react";
type SVGIcon = typeof PlayArrowOutlined
type SVGIcon = typeof PlayArrowOutlined;
type SVGIconProps = ComponentProps<SVGIcon>
type SVGIconProps = ComponentProps<SVGIcon>;
const iconByTransition: Record<WorkspaceTransition, SVGIcon> = {
start: PlayArrowOutlined,
stop: StopOutlined,
delete: DeleteOutlined,
}
};
export const BuildIcon = (
props: SVGIconProps & { transition: WorkspaceTransition },
) => {
const Icon = iconByTransition[props.transition]
return <Icon {...props} />
}
const Icon = iconByTransition[props.transition];
return <Icon {...props} />;
};

View File

@ -1,7 +1,7 @@
import { Story } from "@storybook/react"
import { CodeExample, CodeExampleProps } from "./CodeExample"
import { Story } from "@storybook/react";
import { CodeExample, CodeExampleProps } from "./CodeExample";
const sampleCode = `echo "Hello, world"`
const sampleCode = `echo "Hello, world"`;
export default {
title: "components/CodeExample",
@ -9,18 +9,18 @@ export default {
argTypes: {
code: { control: "string", defaultValue: sampleCode },
},
}
};
const Template: Story<CodeExampleProps> = (args: CodeExampleProps) => (
<CodeExample {...args} />
)
);
export const Example = Template.bind({})
export const Example = Template.bind({});
Example.args = {
code: sampleCode,
}
};
export const LongCode = Template.bind({})
export const LongCode = Template.bind({});
LongCode.args = {
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
}
};

View File

@ -1,14 +1,14 @@
import { screen } from "@testing-library/react"
import { render } from "../../testHelpers/renderHelpers"
import { CodeExample } from "./CodeExample"
import { screen } from "@testing-library/react";
import { render } from "../../testHelpers/renderHelpers";
import { CodeExample } from "./CodeExample";
describe("CodeExample", () => {
it("renders code", async () => {
// When
render(<CodeExample code="echo hello" />)
render(<CodeExample code="echo hello" />);
// Then
// Both lines should be rendered
await screen.findByText("echo hello")
})
})
await screen.findByText("echo hello");
});
});

View File

@ -1,17 +1,17 @@
import { makeStyles } from "@mui/styles"
import { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../utils/combineClasses"
import { CopyButton } from "../CopyButton/CopyButton"
import { Theme } from "@mui/material/styles"
import { makeStyles } from "@mui/styles";
import { FC } from "react";
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants";
import { combineClasses } from "../../utils/combineClasses";
import { CopyButton } from "../CopyButton/CopyButton";
import { Theme } from "@mui/material/styles";
export interface CodeExampleProps {
code: string
className?: string
buttonClassName?: string
tooltipTitle?: string
inline?: boolean
password?: boolean
code: string;
className?: string;
buttonClassName?: string;
tooltipTitle?: string;
inline?: boolean;
password?: boolean;
}
/**
@ -24,7 +24,7 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
tooltipTitle,
inline,
}) => {
const styles = useStyles({ inline: inline })
const styles = useStyles({ inline: inline });
return (
<div className={combineClasses([styles.root, className])}>
@ -35,12 +35,12 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
buttonClassName={buttonClassName}
/>
</div>
)
}
);
};
interface styleProps {
inline?: boolean
password?: boolean
inline?: boolean;
password?: boolean;
}
const useStyles = makeStyles<Theme, styleProps>((theme) => ({
@ -65,4 +65,4 @@ const useStyles = makeStyles<Theme, styleProps>((theme) => ({
wordBreak: "break-all",
"-webkit-text-security": (props) => (props.password ? "disc" : undefined),
},
}))
}));

View File

@ -1,11 +1,11 @@
import { Story } from "@storybook/react"
import { ChooseOne, Cond } from "./ChooseOne"
import { Story } from "@storybook/react";
import { ChooseOne, Cond } from "./ChooseOne";
export default {
title: "components/Conditionals/ChooseOne",
component: ChooseOne,
subcomponents: { Cond },
}
};
export const FirstIsTrue: Story = () => (
<ChooseOne>
@ -13,7 +13,7 @@ export const FirstIsTrue: Story = () => (
<Cond condition={false}>The second one does not show.</Cond>
<Cond>The default does not show.</Cond>
</ChooseOne>
)
);
export const SecondIsTrue: Story = () => (
<ChooseOne>
@ -21,7 +21,7 @@ export const SecondIsTrue: Story = () => (
<Cond condition>The second one shows.</Cond>
<Cond>The default does not show.</Cond>
</ChooseOne>
)
);
export const AllAreTrue: Story = () => (
<ChooseOne>
@ -29,7 +29,7 @@ export const AllAreTrue: Story = () => (
<Cond condition>The second one does not show.</Cond>
<Cond>The default does not show.</Cond>
</ChooseOne>
)
);
export const NoneAreTrue: Story = () => (
<ChooseOne>
@ -37,10 +37,10 @@ export const NoneAreTrue: Story = () => (
<Cond condition={false}>The second one does not show.</Cond>
<Cond>The default shows.</Cond>
</ChooseOne>
)
);
export const OneCond: Story = () => (
<ChooseOne>
<Cond>An only child renders.</Cond>
</ChooseOne>
)
);

View File

@ -1,7 +1,7 @@
import { Children, PropsWithChildren } from "react"
import { Children, PropsWithChildren } from "react";
export interface CondProps {
condition?: boolean
condition?: boolean;
}
/**
@ -14,8 +14,8 @@ export interface CondProps {
export const Cond = ({
children,
}: PropsWithChildren<CondProps>): JSX.Element => {
return <>{children}</>
}
return <>{children}</>;
};
/**
* Wrapper component for rendering exactly one of its children. Wrap each child in Cond to associate it
@ -27,22 +27,22 @@ export const Cond = ({
export const ChooseOne = ({
children,
}: PropsWithChildren): JSX.Element | null => {
const childArray = Children.toArray(children) as JSX.Element[]
const childArray = Children.toArray(children) as JSX.Element[];
if (childArray.length === 0) {
return null
return null;
}
const conditionedOptions = childArray.slice(0, childArray.length - 1)
const defaultCase = childArray[childArray.length - 1]
const conditionedOptions = childArray.slice(0, childArray.length - 1);
const defaultCase = childArray[childArray.length - 1];
if (defaultCase.props.condition !== undefined) {
throw new Error(
"The last Cond in a ChooseOne was given a condition prop, but it is the default case.",
)
);
}
if (conditionedOptions.some((cond) => cond.props.condition === undefined)) {
throw new Error(
"A non-final Cond in a ChooseOne does not have a condition prop or the prop is undefined.",
)
);
}
const chosen = conditionedOptions.find((child) => child.props.condition)
return chosen ?? defaultCase
}
const chosen = conditionedOptions.find((child) => child.props.condition);
return chosen ?? defaultCase;
};

View File

@ -1,21 +1,21 @@
import { Story } from "@storybook/react"
import { Maybe, MaybeProps } from "./Maybe"
import { Story } from "@storybook/react";
import { Maybe, MaybeProps } from "./Maybe";
export default {
title: "components/Conditionals/Maybe",
component: Maybe,
}
};
const Template: Story<MaybeProps> = (args: MaybeProps) => (
<Maybe {...args}>Now you see me</Maybe>
)
);
export const ConditionIsTrue = Template.bind({})
export const ConditionIsTrue = Template.bind({});
ConditionIsTrue.args = {
condition: true,
}
};
export const ConditionIsFalse = Template.bind({})
export const ConditionIsFalse = Template.bind({});
ConditionIsFalse.args = {
condition: false,
}
};

View File

@ -1,7 +1,7 @@
import { PropsWithChildren } from "react"
import { PropsWithChildren } from "react";
export interface MaybeProps {
condition: boolean
condition: boolean;
}
/**
@ -13,5 +13,5 @@ export const Maybe = ({
children,
condition,
}: PropsWithChildren<MaybeProps>): JSX.Element | null => {
return condition ? <>{children}</> : null
}
return condition ? <>{children}</> : null;
};

View File

@ -1,23 +1,23 @@
import IconButton from "@mui/material/Button"
import { makeStyles } from "@mui/styles"
import Tooltip from "@mui/material/Tooltip"
import Check from "@mui/icons-material/Check"
import { useClipboard } from "hooks/useClipboard"
import { combineClasses } from "../../utils/combineClasses"
import { FileCopyIcon } from "../Icons/FileCopyIcon"
import IconButton from "@mui/material/Button";
import { makeStyles } from "@mui/styles";
import Tooltip from "@mui/material/Tooltip";
import Check from "@mui/icons-material/Check";
import { useClipboard } from "hooks/useClipboard";
import { combineClasses } from "../../utils/combineClasses";
import { FileCopyIcon } from "../Icons/FileCopyIcon";
interface CopyButtonProps {
text: string
ctaCopy?: string
wrapperClassName?: string
buttonClassName?: string
tooltipTitle?: string
text: string;
ctaCopy?: string;
wrapperClassName?: string;
buttonClassName?: string;
tooltipTitle?: string;
}
export const Language = {
tooltipTitle: "Copy to clipboard",
ariaLabel: "Copy to clipboard",
}
};
/**
* Copy button used inside the CodeBlock component internally
@ -29,8 +29,8 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
buttonClassName = "",
tooltipTitle = Language.tooltipTitle,
}) => {
const styles = useStyles()
const { isCopied, copy: copyToClipboard } = useClipboard(text)
const styles = useStyles();
const { isCopied, copy: copyToClipboard } = useClipboard(text);
return (
<Tooltip title={tooltipTitle} placement="top">
@ -53,8 +53,8 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
</IconButton>
</div>
</Tooltip>
)
}
);
};
const useStyles = makeStyles((theme) => ({
copyButtonWrapper: {
@ -76,4 +76,4 @@ const useStyles = makeStyles((theme) => ({
buttonCopy: {
marginLeft: theme.spacing(1),
},
}))
}));

View File

@ -1,12 +1,12 @@
import { makeStyles } from "@mui/styles"
import Tooltip from "@mui/material/Tooltip"
import { useClickable } from "hooks/useClickable"
import { useClipboard } from "hooks/useClipboard"
import { FC, HTMLProps } from "react"
import { combineClasses } from "utils/combineClasses"
import { makeStyles } from "@mui/styles";
import Tooltip from "@mui/material/Tooltip";
import { useClickable } from "hooks/useClickable";
import { useClipboard } from "hooks/useClipboard";
import { FC, HTMLProps } from "react";
import { combineClasses } from "utils/combineClasses";
interface CopyableValueProps extends HTMLProps<HTMLDivElement> {
value: string
value: string;
}
export const CopyableValue: FC<CopyableValueProps> = ({
@ -14,9 +14,9 @@ export const CopyableValue: FC<CopyableValueProps> = ({
className,
...props
}) => {
const { isCopied, copy } = useClipboard(value)
const clickableProps = useClickable(copy)
const styles = useStyles()
const { isCopied, copy } = useClipboard(value);
const clickableProps = useClickable(copy);
const styles = useStyles();
return (
<Tooltip
@ -29,11 +29,11 @@ export const CopyableValue: FC<CopyableValueProps> = ({
className={combineClasses([styles.value, className])}
/>
</Tooltip>
)
}
);
};
const useStyles = makeStyles(() => ({
value: {
cursor: "pointer",
},
}))
}));

View File

@ -1,7 +1,7 @@
import Box from "@mui/material/Box"
import { Theme } from "@mui/material/styles"
import useTheme from "@mui/styles/useTheme"
import * as TypesGen from "api/typesGenerated"
import Box from "@mui/material/Box";
import { Theme } from "@mui/material/styles";
import useTheme from "@mui/styles/useTheme";
import * as TypesGen from "api/typesGenerated";
import {
CategoryScale,
Chart as ChartJS,
@ -13,16 +13,16 @@ import {
TimeScale,
Title,
Tooltip,
} from "chart.js"
import "chartjs-adapter-date-fns"
} from "chart.js";
import "chartjs-adapter-date-fns";
import {
HelpTooltip,
HelpTooltipTitle,
HelpTooltipText,
} from "components/HelpTooltip/HelpTooltip"
import dayjs from "dayjs"
import { FC } from "react"
import { Bar } from "react-chartjs-2"
} from "components/HelpTooltip/HelpTooltip";
import dayjs from "dayjs";
import { FC } from "react";
import { Bar } from "react-chartjs-2";
ChartJS.register(
CategoryScale,
@ -32,25 +32,25 @@ ChartJS.register(
Title,
Tooltip,
Legend,
)
);
export interface DAUChartProps {
daus: TypesGen.DAUsResponse
daus: TypesGen.DAUsResponse;
}
export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
const theme: Theme = useTheme()
const theme: Theme = useTheme();
const labels = daus.entries.map((val) => {
return dayjs(val.date).format("YYYY-MM-DD")
})
return dayjs(val.date).format("YYYY-MM-DD");
});
const data = daus.entries.map((val) => {
return val.amount
})
return val.amount;
});
defaults.font.family = theme.typography.fontFamily as string
defaults.color = theme.palette.text.secondary
defaults.font.family = theme.typography.fontFamily as string;
defaults.color = theme.palette.text.secondary;
const options: ChartOptions<"bar"> = {
responsive: true,
@ -62,8 +62,8 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
displayColors: false,
callbacks: {
title: (context) => {
const date = new Date(context[0].parsed.x)
return date.toLocaleDateString()
const date = new Date(context[0].parsed.x);
return date.toLocaleDateString();
},
},
},
@ -86,7 +86,7 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
},
},
maintainAspectRatio: false,
}
};
return (
<Bar
@ -108,8 +108,8 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
}}
options={options}
/>
)
}
);
};
export const DAUTitle = () => {
return (
@ -125,5 +125,5 @@ export const DAUTitle = () => {
</HelpTooltipText>
</HelpTooltip>
</Box>
)
}
);
};

View File

@ -1,16 +1,16 @@
import { renderWithAuth } from "testHelpers/renderHelpers"
import { DashboardLayout } from "./DashboardLayout"
import * as API from "api/api"
import { screen } from "@testing-library/react"
import { renderWithAuth } from "testHelpers/renderHelpers";
import { DashboardLayout } from "./DashboardLayout";
import * as API from "api/api";
import { screen } from "@testing-library/react";
test("Show the new Coder version notification", async () => {
jest.spyOn(API, "getUpdateCheck").mockResolvedValue({
current: false,
version: "v0.12.9",
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
})
});
renderWithAuth(<DashboardLayout />, {
children: [{ element: <h1>Test page</h1> }],
})
await screen.findByTestId("update-check-snackbar")
})
});
await screen.findByTestId("update-check-snackbar");
});

View File

@ -1,33 +1,33 @@
import { makeStyles } from "@mui/styles"
import { useMachine } from "@xstate/react"
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner"
import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner"
import { Loader } from "components/Loader/Loader"
import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner"
import { usePermissions } from "hooks/usePermissions"
import { FC, Suspense } from "react"
import { Outlet } from "react-router-dom"
import { dashboardContentBottomPadding } from "theme/constants"
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
import { Navbar } from "./Navbar/Navbar"
import Snackbar from "@mui/material/Snackbar"
import Link from "@mui/material/Link"
import Box, { BoxProps } from "@mui/material/Box"
import InfoOutlined from "@mui/icons-material/InfoOutlined"
import Button from "@mui/material/Button"
import { docs } from "utils/docs"
import { HealthBanner } from "./HealthBanner"
import { makeStyles } from "@mui/styles";
import { useMachine } from "@xstate/react";
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner";
import { Loader } from "components/Loader/Loader";
import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner";
import { usePermissions } from "hooks/usePermissions";
import { FC, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { dashboardContentBottomPadding } from "theme/constants";
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService";
import { Navbar } from "./Navbar/Navbar";
import Snackbar from "@mui/material/Snackbar";
import Link from "@mui/material/Link";
import Box, { BoxProps } from "@mui/material/Box";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import Button from "@mui/material/Button";
import { docs } from "utils/docs";
import { HealthBanner } from "./HealthBanner";
export const DashboardLayout: FC = () => {
const styles = useStyles()
const permissions = usePermissions()
const styles = useStyles();
const permissions = usePermissions();
const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, {
context: {
permissions,
},
})
const { updateCheck } = updateCheckState.context
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
});
const { updateCheck } = updateCheckState.context;
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
return (
<>
@ -99,8 +99,8 @@ export const DashboardLayout: FC = () => {
/>
</div>
</>
)
}
);
};
export const DashboardFullPage = (props: BoxProps) => {
return (
@ -116,8 +116,8 @@ export const DashboardFullPage = (props: BoxProps) => {
minHeight: "100%",
}}
/>
)
}
);
};
const useStyles = makeStyles({
site: {
@ -131,4 +131,4 @@ const useStyles = makeStyles({
display: "flex",
flexDirection: "column",
},
})
});

View File

@ -1,62 +1,62 @@
import { useMachine } from "@xstate/react"
import { useMachine } from "@xstate/react";
import {
AppearanceConfig,
BuildInfoResponse,
Entitlements,
Experiments,
} from "api/typesGenerated"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { createContext, FC, PropsWithChildren, useContext } from "react"
import { appearanceMachine } from "xServices/appearance/appearanceXService"
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService"
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"
import { experimentsMachine } from "xServices/experiments/experimentsMachine"
} from "api/typesGenerated";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import { createContext, FC, PropsWithChildren, useContext } from "react";
import { appearanceMachine } from "xServices/appearance/appearanceXService";
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService";
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService";
import { experimentsMachine } from "xServices/experiments/experimentsMachine";
interface Appearance {
config: AppearanceConfig
preview: boolean
setPreview: (config: AppearanceConfig) => void
save: (config: AppearanceConfig) => void
config: AppearanceConfig;
preview: boolean;
setPreview: (config: AppearanceConfig) => void;
save: (config: AppearanceConfig) => void;
}
interface DashboardProviderValue {
buildInfo: BuildInfoResponse
entitlements: Entitlements
appearance: Appearance
experiments: Experiments
buildInfo: BuildInfoResponse;
entitlements: Entitlements;
appearance: Appearance;
experiments: Experiments;
}
export const DashboardProviderContext = createContext<
DashboardProviderValue | undefined
>(undefined)
>(undefined);
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const [buildInfoState] = useMachine(buildInfoMachine)
const [entitlementsState] = useMachine(entitlementsMachine)
const [appearanceState, appearanceSend] = useMachine(appearanceMachine)
const [experimentsState] = useMachine(experimentsMachine)
const { buildInfo } = buildInfoState.context
const { entitlements } = entitlementsState.context
const { appearance, preview } = appearanceState.context
const { experiments } = experimentsState.context
const isLoading = !buildInfo || !entitlements || !appearance || !experiments
const [buildInfoState] = useMachine(buildInfoMachine);
const [entitlementsState] = useMachine(entitlementsMachine);
const [appearanceState, appearanceSend] = useMachine(appearanceMachine);
const [experimentsState] = useMachine(experimentsMachine);
const { buildInfo } = buildInfoState.context;
const { entitlements } = entitlementsState.context;
const { appearance, preview } = appearanceState.context;
const { experiments } = experimentsState.context;
const isLoading = !buildInfo || !entitlements || !appearance || !experiments;
const setAppearancePreview = (config: AppearanceConfig) => {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: config,
})
}
});
};
const saveAppearance = (config: AppearanceConfig) => {
appearanceSend({
type: "SAVE_APPEARANCE",
appearance: config,
})
}
});
};
if (isLoading) {
return <FullScreenLoader />
return <FullScreenLoader />;
}
return (
@ -75,25 +75,27 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
>
{children}
</DashboardProviderContext.Provider>
)
}
);
};
export const useDashboard = (): DashboardProviderValue => {
const context = useContext(DashboardProviderContext)
const context = useContext(DashboardProviderContext);
if (!context) {
throw new Error("useDashboard only can be used inside of DashboardProvider")
throw new Error(
"useDashboard only can be used inside of DashboardProvider",
);
}
return context
}
return context;
};
export const useIsWorkspaceActionsEnabled = (): boolean => {
const { entitlements, experiments } = useDashboard()
const { entitlements, experiments } = useDashboard();
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
entitlements.features["advanced_template_scheduling"].enabled;
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")
return allowWorkspaceActions && allowAdvancedScheduling
}
const allowWorkspaceActions = experiments.includes("workspace_actions");
return allowWorkspaceActions && allowAdvancedScheduling;
};

View File

@ -1,14 +1,14 @@
import { useMachine } from "@xstate/react"
import { usePermissions } from "hooks/usePermissions"
import { DeploymentBannerView } from "./DeploymentBannerView"
import { deploymentStatsMachine } from "xServices/deploymentStats/deploymentStatsMachine"
import { useMachine } from "@xstate/react";
import { usePermissions } from "hooks/usePermissions";
import { DeploymentBannerView } from "./DeploymentBannerView";
import { deploymentStatsMachine } from "xServices/deploymentStats/deploymentStatsMachine";
export const DeploymentBanner: React.FC = () => {
const permissions = usePermissions()
const [state, sendEvent] = useMachine(deploymentStatsMachine)
const permissions = usePermissions();
const [state, sendEvent] = useMachine(deploymentStatsMachine);
if (!permissions.viewDeploymentValues || !state.context.deploymentStats) {
return null
return null;
}
return (
@ -16,5 +16,5 @@ export const DeploymentBanner: React.FC = () => {
stats={state.context.deploymentStats}
fetchStats={() => sendEvent("RELOAD")}
/>
)
}
);
};

View File

@ -1,20 +1,20 @@
import { Story } from "@storybook/react"
import { MockDeploymentStats } from "testHelpers/entities"
import { Story } from "@storybook/react";
import { MockDeploymentStats } from "testHelpers/entities";
import {
DeploymentBannerView,
DeploymentBannerViewProps,
} from "./DeploymentBannerView"
} from "./DeploymentBannerView";
export default {
title: "components/DeploymentBannerView",
component: DeploymentBannerView,
}
};
const Template: Story<DeploymentBannerViewProps> = (args) => (
<DeploymentBannerView {...args} />
)
);
export const Preview = Template.bind({})
export const Preview = Template.bind({});
Preview.args = {
stats: MockDeploymentStats,
}
};

View File

@ -1,83 +1,83 @@
import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated"
import { FC, useMemo, useEffect, useState } from "react"
import prettyBytes from "pretty-bytes"
import BuildingIcon from "@mui/icons-material/Build"
import { makeStyles } from "@mui/styles"
import { RocketIcon } from "components/Icons/RocketIcon"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import Tooltip from "@mui/material/Tooltip"
import { Link as RouterLink } from "react-router-dom"
import Link from "@mui/material/Link"
import { VSCodeIcon } from "components/Icons/VSCodeIcon"
import DownloadIcon from "@mui/icons-material/CloudDownload"
import UploadIcon from "@mui/icons-material/CloudUpload"
import LatencyIcon from "@mui/icons-material/SettingsEthernet"
import WebTerminalIcon from "@mui/icons-material/WebAsset"
import { TerminalIcon } from "components/Icons/TerminalIcon"
import dayjs from "dayjs"
import CollectedIcon from "@mui/icons-material/Compare"
import RefreshIcon from "@mui/icons-material/Refresh"
import Button from "@mui/material/Button"
import { getDisplayWorkspaceStatus } from "utils/workspace"
import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated";
import { FC, useMemo, useEffect, useState } from "react";
import prettyBytes from "pretty-bytes";
import BuildingIcon from "@mui/icons-material/Build";
import { makeStyles } from "@mui/styles";
import { RocketIcon } from "components/Icons/RocketIcon";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import Tooltip from "@mui/material/Tooltip";
import { Link as RouterLink } from "react-router-dom";
import Link from "@mui/material/Link";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import DownloadIcon from "@mui/icons-material/CloudDownload";
import UploadIcon from "@mui/icons-material/CloudUpload";
import LatencyIcon from "@mui/icons-material/SettingsEthernet";
import WebTerminalIcon from "@mui/icons-material/WebAsset";
import { TerminalIcon } from "components/Icons/TerminalIcon";
import dayjs from "dayjs";
import CollectedIcon from "@mui/icons-material/Compare";
import RefreshIcon from "@mui/icons-material/Refresh";
import Button from "@mui/material/Button";
import { getDisplayWorkspaceStatus } from "utils/workspace";
export const bannerHeight = 36
export const bannerHeight = 36;
export interface DeploymentBannerViewProps {
fetchStats?: () => void
stats?: DeploymentStats
fetchStats?: () => void;
stats?: DeploymentStats;
}
export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
stats,
fetchStats,
}) => {
const styles = useStyles()
const styles = useStyles();
const aggregatedMinutes = useMemo(() => {
if (!stats) {
return
return;
}
return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes")
}, [stats])
const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1
const [timeUntilRefresh, setTimeUntilRefresh] = useState(0)
return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes");
}, [stats]);
const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1;
const [timeUntilRefresh, setTimeUntilRefresh] = useState(0);
useEffect(() => {
if (!stats || !fetchStats) {
return
return;
}
let timeUntilRefresh = dayjs(stats.next_update_at).diff(
stats.collected_at,
"seconds",
)
setTimeUntilRefresh(timeUntilRefresh)
let canceled = false
);
setTimeUntilRefresh(timeUntilRefresh);
let canceled = false;
const loop = () => {
if (canceled) {
return undefined
return undefined;
}
setTimeUntilRefresh(timeUntilRefresh--)
setTimeUntilRefresh(timeUntilRefresh--);
if (timeUntilRefresh > 0) {
return window.setTimeout(loop, 1000)
return window.setTimeout(loop, 1000);
}
fetchStats()
}
const timeout = setTimeout(loop, 1000)
fetchStats();
};
const timeout = setTimeout(loop, 1000);
return () => {
canceled = true
clearTimeout(timeout)
}
}, [fetchStats, stats])
canceled = true;
clearTimeout(timeout);
};
}, [fetchStats, stats]);
const lastAggregated = useMemo(() => {
if (!stats) {
return
return;
}
if (!fetchStats) {
// Storybook!
return "just now"
return "just now";
}
return dayjs().to(dayjs(stats.collected_at))
return dayjs().to(dayjs(stats.collected_at));
// eslint-disable-next-line react-hooks/exhaustive-deps -- We want this to periodically update!
}, [timeUntilRefresh, stats])
}, [timeUntilRefresh, stats]);
return (
<div className={styles.container}>
@ -194,7 +194,7 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
className={`${styles.value} ${styles.refreshButton}`}
onClick={() => {
if (fetchStats) {
fetchStats()
fetchStats();
}
}}
variant="text"
@ -205,25 +205,25 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
</Tooltip>
</div>
</div>
)
}
);
};
const ValueSeparator: FC = () => {
const styles = useStyles()
return <div className={styles.valueSeparator}>/</div>
}
const styles = useStyles();
return <div className={styles.valueSeparator}>/</div>;
};
const WorkspaceBuildValue: FC<{
status: WorkspaceStatus
count?: number
status: WorkspaceStatus;
count?: number;
}> = ({ status, count }) => {
const styles = useStyles()
const displayStatus = getDisplayWorkspaceStatus(status)
let statusText = displayStatus.text
let icon = displayStatus.icon
const styles = useStyles();
const displayStatus = getDisplayWorkspaceStatus(status);
let statusText = displayStatus.text;
let icon = displayStatus.icon;
if (status === "starting") {
icon = <BuildingIcon />
statusText = "Building"
icon = <BuildingIcon />;
statusText = "Building";
}
return (
@ -238,8 +238,8 @@ const WorkspaceBuildValue: FC<{
</div>
</Link>
</Tooltip>
)
}
);
};
const useStyles = makeStyles((theme) => ({
rocket: {
@ -324,4 +324,4 @@ const useStyles = makeStyles((theme) => ({
marginRight: theme.spacing(0.5),
},
},
}))
}));

View File

@ -1,18 +1,18 @@
import { Alert } from "components/Alert/Alert"
import { Link as RouterLink } from "react-router-dom"
import Link from "@mui/material/Link"
import { colors } from "theme/colors"
import { useQuery } from "@tanstack/react-query"
import { getHealth } from "api/api"
import { useDashboard } from "./DashboardProvider"
import { Alert } from "components/Alert/Alert";
import { Link as RouterLink } from "react-router-dom";
import Link from "@mui/material/Link";
import { colors } from "theme/colors";
import { useQuery } from "@tanstack/react-query";
import { getHealth } from "api/api";
import { useDashboard } from "./DashboardProvider";
export const HealthBanner = () => {
const { data: healthStatus } = useQuery({
queryKey: ["health"],
queryFn: () => getHealth(),
})
const dashboard = useDashboard()
const hasHealthIssues = healthStatus && !healthStatus.data.healthy
});
const dashboard = useDashboard();
const hasHealthIssues = healthStatus && !healthStatus.data.healthy;
if (
dashboard.experiments.includes("deployment_health_page") &&
@ -38,8 +38,8 @@ export const HealthBanner = () => {
</Link>
.
</Alert>
)
);
}
return null
}
return null;
};

View File

@ -1,13 +1,13 @@
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { LicenseBannerView } from "./LicenseBannerView"
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { LicenseBannerView } from "./LicenseBannerView";
export const LicenseBanner: React.FC = () => {
const { entitlements } = useDashboard()
const { errors, warnings } = entitlements
const { entitlements } = useDashboard();
const { errors, warnings } = entitlements;
if (errors.length > 0 || warnings.length > 0) {
return <LicenseBannerView errors={errors} warnings={warnings} />
return <LicenseBannerView errors={errors} warnings={warnings} />;
} else {
return null
return null;
}
}
};

View File

@ -1,34 +1,34 @@
import { Story } from "@storybook/react"
import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView"
import { Story } from "@storybook/react";
import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView";
export default {
title: "components/LicenseBannerView",
component: LicenseBannerView,
}
};
const Template: Story<LicenseBannerViewProps> = (args) => (
<LicenseBannerView {...args} />
)
);
export const OneWarning = Template.bind({})
export const OneWarning = Template.bind({});
OneWarning.args = {
errors: [],
warnings: ["You have exceeded the number of seats in your license."],
}
};
export const TwoWarnings = Template.bind({})
export const TwoWarnings = Template.bind({});
TwoWarnings.args = {
errors: [],
warnings: [
"You have exceeded the number of seats in your license.",
"You are flying too close to the sun.",
],
}
};
export const OneError = Template.bind({})
export const OneError = Template.bind({});
OneError.args = {
errors: [
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
],
warnings: [],
}
};

View File

@ -1,9 +1,9 @@
import Link from "@mui/material/Link"
import { makeStyles } from "@mui/styles"
import { Expander } from "components/Expander/Expander"
import { Pill } from "components/Pill/Pill"
import { useState } from "react"
import { colors } from "theme/colors"
import Link from "@mui/material/Link";
import { makeStyles } from "@mui/styles";
import { Expander } from "components/Expander/Expander";
import { Pill } from "components/Pill/Pill";
import { useState } from "react";
import { colors } from "theme/colors";
export const Language = {
licenseIssue: "License Issue",
@ -12,22 +12,22 @@ export const Language = {
exceeded: "It looks like you've exceeded some limits of your license.",
lessDetails: "Less",
moreDetails: "More",
}
};
export interface LicenseBannerViewProps {
errors: string[]
warnings: string[]
errors: string[];
warnings: string[];
}
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
errors,
warnings,
}) => {
const styles = useStyles()
const [showDetails, setShowDetails] = useState(false)
const isError = errors.length > 0
const messages = [...errors, ...warnings]
const type = isError ? "error" : "warning"
const styles = useStyles();
const [showDetails, setShowDetails] = useState(false);
const isError = errors.length > 0;
const messages = [...errors, ...warnings];
const type = isError ? "error" : "warning";
if (messages.length === 1) {
return (
@ -41,7 +41,7 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
</Link>
</div>
</div>
)
);
} else {
return (
<div className={`${styles.container} ${type}`}>
@ -73,9 +73,9 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
</Expander>
</div>
</div>
)
);
}
}
};
const useStyles = makeStyles((theme) => ({
container: {
@ -103,4 +103,4 @@ const useStyles = makeStyles((theme) => ({
listItem: {
margin: theme.spacing(0.5),
},
}))
}));

View File

@ -1,12 +1,12 @@
import { render, screen, waitFor } from "@testing-library/react"
import { App } from "app"
import { Language } from "./NavbarView"
import { rest } from "msw"
import { render, screen, waitFor } from "@testing-library/react";
import { App } from "app";
import { Language } from "./NavbarView";
import { rest } from "msw";
import {
MockEntitlementsWithAuditLog,
MockMemberPermissions,
} from "testHelpers/entities"
import { server } from "testHelpers/server"
} from "testHelpers/entities";
import { server } from "testHelpers/server";
/**
* The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their
@ -17,52 +17,52 @@ describe("Navbar", () => {
// set entitlements to allow audit log
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog));
}),
)
render(<App />)
);
render(<App />);
await waitFor(
() => {
const link = screen.getByText(Language.audit)
expect(link).toBeDefined()
const link = screen.getByText(Language.audit);
expect(link).toBeDefined();
},
{ timeout: 2000 },
)
})
);
});
it("does not show Audit Log link when not entitled", async () => {
// by default, user is an Admin with permission to see the audit log,
// but is unlicensed so not entitled to see the audit log
render(<App />)
render(<App />);
await waitFor(
() => {
const link = screen.queryByText(Language.audit)
expect(link).toBe(null)
const link = screen.queryByText(Language.audit);
expect(link).toBe(null);
},
{ timeout: 2000 },
)
})
);
});
it("does not show Audit Log link when not permitted via role", async () => {
// set permissions to Member (can't audit)
server.use(
rest.post("/api/v2/authcheck", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockMemberPermissions))
return res(ctx.status(200), ctx.json(MockMemberPermissions));
}),
)
);
// set entitlements to allow audit log
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog));
}),
)
render(<App />)
);
render(<App />);
await waitFor(
() => {
const link = screen.queryByText(Language.audit)
expect(link).toBe(null)
const link = screen.queryByText(Language.audit);
expect(link).toBe(null);
},
{ timeout: 2000 },
)
})
})
);
});
});

View File

@ -1,25 +1,25 @@
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
import { FC } from "react"
import { NavbarView } from "./NavbarView"
import { useProxy } from "contexts/ProxyContext"
import { useAuth } from "components/AuthProvider/AuthProvider";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { FC } from "react";
import { NavbarView } from "./NavbarView";
import { useProxy } from "contexts/ProxyContext";
export const Navbar: FC = () => {
const { appearance, buildInfo } = useDashboard()
const [_, authSend] = useAuth()
const me = useMe()
const permissions = usePermissions()
const featureVisibility = useFeatureVisibility()
const { appearance, buildInfo } = useDashboard();
const [_, authSend] = useAuth();
const me = useMe();
const permissions = usePermissions();
const featureVisibility = useFeatureVisibility();
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
const canViewAllUsers = Boolean(permissions.readAllUsers)
const onSignOut = () => authSend("SIGN_OUT")
const proxyContextValue = useProxy()
const dashboard = useDashboard()
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog);
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
const canViewAllUsers = Boolean(permissions.readAllUsers);
const onSignOut = () => authSend("SIGN_OUT");
const proxyContextValue = useProxy();
const dashboard = useDashboard();
return (
<NavbarView
@ -35,5 +35,5 @@ export const Navbar: FC = () => {
dashboard.experiments.includes("moons") ? proxyContextValue : undefined
}
/>
)
}
);
};

View File

@ -1,6 +1,6 @@
import { Story } from "@storybook/react"
import { MockUser, MockUser2 } from "../../../testHelpers/entities"
import { NavbarView, NavbarViewProps } from "./NavbarView"
import { Story } from "@storybook/react";
import { MockUser, MockUser2 } from "../../../testHelpers/entities";
import { NavbarView, NavbarViewProps } from "./NavbarView";
export default {
title: "components/NavbarView",
@ -8,38 +8,38 @@ export default {
argTypes: {
onSignOut: { action: "Sign Out" },
},
}
};
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => (
<NavbarView {...args} />
)
);
export const ForAdmin = Template.bind({})
export const ForAdmin = Template.bind({});
ForAdmin.args = {
user: MockUser,
onSignOut: () => {
return Promise.resolve()
return Promise.resolve();
},
}
};
export const ForMember = Template.bind({})
export const ForMember = Template.bind({});
ForMember.args = {
user: MockUser2,
onSignOut: () => {
return Promise.resolve()
return Promise.resolve();
},
}
};
export const SmallViewport = Template.bind({})
export const SmallViewport = Template.bind({});
SmallViewport.args = {
user: MockUser,
onSignOut: () => {
return Promise.resolve()
return Promise.resolve();
},
}
};
SmallViewport.parameters = {
viewport: {
defaultViewport: "tablet",
},
chromatic: { viewports: [420] },
}
};

View File

@ -1,13 +1,13 @@
import { screen } from "@testing-library/react"
import { screen } from "@testing-library/react";
import {
MockPrimaryWorkspaceProxy,
MockUser,
MockUser2,
} from "../../../testHelpers/entities"
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import { Language as navLanguage, NavbarView } from "./NavbarView"
import { ProxyContextValue } from "contexts/ProxyContext"
import { action } from "@storybook/addon-actions"
} from "../../../testHelpers/entities";
import { renderWithAuth } from "../../../testHelpers/renderHelpers";
import { Language as navLanguage, NavbarView } from "./NavbarView";
import { ProxyContextValue } from "contexts/ProxyContext";
import { action } from "@storybook/addon-actions";
const proxyContextValue: ProxyContextValue = {
proxy: {
@ -21,24 +21,24 @@ const proxyContextValue: ProxyContextValue = {
clearProxy: action("clearProxy"),
refetchProxyLatencies: jest.fn(),
proxyLatencies: {},
}
};
describe("NavbarView", () => {
const noop = () => {
return
}
return;
};
const env = process.env
const env = process.env;
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
beforeEach(() => {
process.env = { ...env }
})
process.env = { ...env };
});
// REMARK: restoring process.env
afterEach(() => {
process.env = env
})
process.env = env;
});
it("workspaces nav link has the correct href", async () => {
renderWithAuth(
@ -50,10 +50,10 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const workspacesLink = await screen.findByText(navLanguage.workspaces)
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
})
);
const workspacesLink = await screen.findByText(navLanguage.workspaces);
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces");
});
it("templates nav link has the correct href", async () => {
renderWithAuth(
@ -65,10 +65,10 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const templatesLink = await screen.findByText(navLanguage.templates)
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
})
);
const templatesLink = await screen.findByText(navLanguage.templates);
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates");
});
it("users nav link has the correct href", async () => {
renderWithAuth(
@ -80,10 +80,10 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const userLink = await screen.findByText(navLanguage.users)
expect((userLink as HTMLAnchorElement).href).toContain("/users")
})
);
const userLink = await screen.findByText(navLanguage.users);
expect((userLink as HTMLAnchorElement).href).toContain("/users");
});
it("renders profile picture for user", async () => {
// Given
@ -91,7 +91,7 @@ describe("NavbarView", () => {
...MockUser,
username: "bryan",
avatar_url: "",
}
};
// When
renderWithAuth(
@ -103,13 +103,13 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
);
// Then
// There should be a 'B' avatar!
const element = await screen.findByText("B")
expect(element).toBeDefined()
})
const element = await screen.findByText("B");
expect(element).toBeDefined();
});
it("audit nav link has the correct href", async () => {
renderWithAuth(
@ -121,10 +121,10 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const auditLink = await screen.findByText(navLanguage.audit)
expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
})
);
const auditLink = await screen.findByText(navLanguage.audit);
expect((auditLink as HTMLAnchorElement).href).toContain("/audit");
});
it("audit nav link is hidden for members", async () => {
renderWithAuth(
@ -136,10 +136,10 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const auditLink = screen.queryByText(navLanguage.audit)
expect(auditLink).not.toBeInTheDocument()
})
);
const auditLink = screen.queryByText(navLanguage.audit);
expect(auditLink).not.toBeInTheDocument();
});
it("deployment nav link has the correct href", async () => {
renderWithAuth(
@ -151,12 +151,12 @@ describe("NavbarView", () => {
canViewDeployment
canViewAllUsers
/>,
)
const auditLink = await screen.findByText(navLanguage.deployment)
);
const auditLink = await screen.findByText(navLanguage.deployment);
expect((auditLink as HTMLAnchorElement).href).toContain(
"/deployment/general",
)
})
);
});
it("deployment nav link is hidden for members", async () => {
renderWithAuth(
@ -168,8 +168,8 @@ describe("NavbarView", () => {
canViewDeployment={false}
canViewAllUsers
/>,
)
const auditLink = screen.queryByText(navLanguage.deployment)
expect(auditLink).not.toBeInTheDocument()
})
})
);
const auditLink = screen.queryByText(navLanguage.deployment);
expect(auditLink).not.toBeInTheDocument();
});
});

View File

@ -1,43 +1,45 @@
import Drawer from "@mui/material/Drawer"
import IconButton from "@mui/material/IconButton"
import List from "@mui/material/List"
import ListItem from "@mui/material/ListItem"
import { makeStyles } from "@mui/styles"
import MenuIcon from "@mui/icons-material/Menu"
import { CoderIcon } from "components/Icons/CoderIcon"
import { FC, useRef, useState } from "react"
import { NavLink, useLocation, useNavigate } from "react-router-dom"
import { colors } from "theme/colors"
import * as TypesGen from "../../../api/typesGenerated"
import { navHeight } from "../../../theme/constants"
import { combineClasses } from "../../../utils/combineClasses"
import { UserDropdown } from "./UserDropdown/UserDropdown"
import Box from "@mui/material/Box"
import Menu from "@mui/material/Menu"
import Button from "@mui/material/Button"
import MenuItem from "@mui/material/MenuItem"
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"
import { ProxyContextValue } from "contexts/ProxyContext"
import { displayError } from "components/GlobalSnackbar/utils"
import Divider from "@mui/material/Divider"
import Skeleton from "@mui/material/Skeleton"
import { BUTTON_SM_HEIGHT } from "theme/theme"
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
import { usePermissions } from "hooks/usePermissions"
import Typography from "@mui/material/Typography"
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import { makeStyles } from "@mui/styles";
import MenuIcon from "@mui/icons-material/Menu";
import { CoderIcon } from "components/Icons/CoderIcon";
import { FC, useRef, useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { colors } from "theme/colors";
import * as TypesGen from "../../../api/typesGenerated";
import { navHeight } from "../../../theme/constants";
import { combineClasses } from "../../../utils/combineClasses";
import { UserDropdown } from "./UserDropdown/UserDropdown";
import Box from "@mui/material/Box";
import Menu from "@mui/material/Menu";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import { ProxyContextValue } from "contexts/ProxyContext";
import { displayError } from "components/GlobalSnackbar/utils";
import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import { BUTTON_SM_HEIGHT } from "theme/theme";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import { usePermissions } from "hooks/usePermissions";
import Typography from "@mui/material/Typography";
export const USERS_LINK = `/users?filter=${encodeURIComponent("status:active")}`
export const USERS_LINK = `/users?filter=${encodeURIComponent(
"status:active",
)}`;
export interface NavbarViewProps {
logo_url?: string
user?: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onSignOut: () => void
canViewAuditLog: boolean
canViewDeployment: boolean
canViewAllUsers: boolean
proxyContextValue?: ProxyContextValue
logo_url?: string;
user?: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onSignOut: () => void;
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewAllUsers: boolean;
proxyContextValue?: ProxyContextValue;
}
export const Language = {
@ -46,18 +48,18 @@ export const Language = {
users: "Users",
audit: "Audit",
deployment: "Deployment",
}
};
const NavItems: React.FC<
React.PropsWithChildren<{
className?: string
canViewAuditLog: boolean
canViewDeployment: boolean
canViewAllUsers: boolean
className?: string;
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewAllUsers: boolean;
}>
> = ({ className, canViewAuditLog, canViewDeployment, canViewAllUsers }) => {
const styles = useStyles()
const location = useLocation()
const styles = useStyles();
const location = useLocation();
return (
<List className={combineClasses([styles.navItems, className])}>
@ -99,8 +101,8 @@ const NavItems: React.FC<
</ListItem>
)}
</List>
)
}
);
};
export const NavbarView: FC<NavbarViewProps> = ({
user,
logo_url,
@ -112,8 +114,8 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewAllUsers,
proxyContextValue,
}) => {
const styles = useStyles()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const styles = useStyles();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<nav className={styles.root}>
@ -122,7 +124,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
aria-label="Open menu"
className={styles.mobileMenuButton}
onClick={() => {
setIsDrawerOpen(true)
setIsDrawerOpen(true);
}}
size="large"
>
@ -188,30 +190,30 @@ export const NavbarView: FC<NavbarViewProps> = ({
</Box>
</div>
</nav>
)
}
);
};
const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
proxyContextValue,
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [refetchDate, setRefetchDate] = useState<Date>()
const selectedProxy = proxyContextValue.proxy.proxy
const refreshLatencies = proxyContextValue.refetchProxyLatencies
const closeMenu = () => setIsOpen(false)
const navigate = useNavigate()
const latencies = proxyContextValue.proxyLatencies
const isLoadingLatencies = Object.keys(latencies).length === 0
const isLoading = proxyContextValue.isLoading || isLoadingLatencies
const permissions = usePermissions()
const buttonRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [refetchDate, setRefetchDate] = useState<Date>();
const selectedProxy = proxyContextValue.proxy.proxy;
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
const closeMenu = () => setIsOpen(false);
const navigate = useNavigate();
const latencies = proxyContextValue.proxyLatencies;
const isLoadingLatencies = Object.keys(latencies).length === 0;
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
const permissions = usePermissions();
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
if (!refetchDate) {
// Only show loading if the user manually requested a refetch
return false
return false;
}
const latency = latencies?.[proxy.id]
const latency = latencies?.[proxy.id];
// Only show a loading spinner if:
// - A latency exists. This means the latency was fetched at some point, so the
// loader *should* be resolved.
@ -220,11 +222,11 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
// is stale and we should show a loading spinner until the new latency is
// fetched.
if (proxy.healthy && latency && latency.at < refetchDate) {
return true
return true;
}
return false
}
return false;
};
if (isLoading) {
return (
@ -233,7 +235,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
height={BUTTON_SM_HEIGHT}
sx={{ borderRadius: "4px", transform: "none" }}
/>
)
);
}
return (
@ -315,21 +317,21 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
{proxyContextValue.proxies
?.sort((a, b) => {
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity
return latencyA - latencyB
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
return latencyA - latencyB;
})
.map((proxy) => (
<MenuItem
onClick={() => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.")
closeMenu()
return
displayError("Please select a healthy workspace proxy.");
closeMenu();
return;
}
proxyContextValue.setProxy(proxy)
closeMenu()
proxyContextValue.setProxy(proxy);
closeMenu();
}}
key={proxy.id}
selected={proxy.id === selectedProxy?.id}
@ -361,7 +363,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
<MenuItem
sx={{ fontSize: 14 }}
onClick={() => {
navigate("deployment/workspace-proxies")
navigate("deployment/workspace-proxies");
}}
>
Proxy settings
@ -371,18 +373,18 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
sx={{ fontSize: 14 }}
onClick={(e) => {
// Stop the menu from closing
e.stopPropagation()
e.stopPropagation();
// Refresh the latencies.
const refetchDate = refreshLatencies()
setRefetchDate(refetchDate)
const refetchDate = refreshLatencies();
setRefetchDate(refetchDate);
}}
>
Refresh Latencies
</MenuItem>
</Menu>
</>
)
}
);
};
const useStyles = makeStyles((theme) => ({
displayInitial: {
@ -472,4 +474,4 @@ const useStyles = makeStyles((theme) => ({
padding: `0 ${theme.spacing(3)}`,
},
},
}))
}));

View File

@ -1,19 +1,19 @@
import Popover, { PopoverProps } from "@mui/material/Popover"
import { makeStyles } from "@mui/styles"
import { FC, PropsWithChildren } from "react"
import Popover, { PopoverProps } from "@mui/material/Popover";
import { makeStyles } from "@mui/styles";
import { FC, PropsWithChildren } from "react";
type BorderedMenuVariant = "user-dropdown"
type BorderedMenuVariant = "user-dropdown";
export type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant
}
variant?: BorderedMenuVariant;
};
export const BorderedMenu: FC<PropsWithChildren<BorderedMenuProps>> = ({
children,
variant,
...rest
}) => {
const styles = useStyles()
const styles = useStyles();
return (
<Popover
@ -23,8 +23,8 @@ export const BorderedMenu: FC<PropsWithChildren<BorderedMenuProps>> = ({
>
{children}
</Popover>
)
}
);
};
const useStyles = makeStyles((theme) => ({
paperRoot: {
@ -32,4 +32,4 @@ const useStyles = makeStyles((theme) => ({
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[6],
},
}))
}));

View File

@ -1,32 +1,32 @@
import ListItem from "@mui/material/ListItem"
import { makeStyles } from "@mui/styles"
import CheckIcon from "@mui/icons-material/Check"
import { FC } from "react"
import { NavLink } from "react-router-dom"
import { ellipsizeText } from "../../../../../utils/ellipsizeText"
import { Typography } from "../../../../Typography/Typography"
import ListItem from "@mui/material/ListItem";
import { makeStyles } from "@mui/styles";
import CheckIcon from "@mui/icons-material/Check";
import { FC } from "react";
import { NavLink } from "react-router-dom";
import { ellipsizeText } from "../../../../../utils/ellipsizeText";
import { Typography } from "../../../../Typography/Typography";
type BorderedMenuRowVariant = "narrow" | "wide"
type BorderedMenuRowVariant = "narrow" | "wide";
interface BorderedMenuRowProps {
/** `true` indicates this row is currently selected */
active?: boolean
active?: boolean;
/** Optional description that appears beneath the title */
description?: string
description?: string;
/** URL path */
path: string
path: string;
/** Required title of this row */
title: string
title: string;
/** Defaults to `"wide"` */
variant?: BorderedMenuRowVariant
variant?: BorderedMenuRowVariant;
/** Callback fired when this row is clicked */
onClick?: () => void
onClick?: () => void;
}
export const BorderedMenuRow: FC<
React.PropsWithChildren<BorderedMenuRowProps>
> = ({ active, description, path, title, variant, onClick }) => {
const styles = useStyles()
const styles = useStyles();
return (
<NavLink className={styles.link} to={path}>
@ -54,10 +54,10 @@ export const BorderedMenuRow: FC<
</div>
</ListItem>
</NavLink>
)
}
);
};
const iconSize = 20
const iconSize = 20;
const useStyles = makeStyles((theme) => ({
root: {
@ -127,4 +127,4 @@ const useStyles = makeStyles((theme) => ({
marginLeft: theme.spacing(4.5),
marginTop: theme.spacing(0.5),
},
}))
}));

View File

@ -1,7 +1,7 @@
import Box from "@mui/material/Box"
import { Story } from "@storybook/react"
import { MockUser } from "../../../../testHelpers/entities"
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
import Box from "@mui/material/Box";
import { Story } from "@storybook/react";
import { MockUser } from "../../../../testHelpers/entities";
import { UserDropdown, UserDropdownProps } from "./UserDropdown";
export default {
title: "components/UserDropdown",
@ -9,18 +9,18 @@ export default {
argTypes: {
onSignOut: { action: "Sign Out" },
},
}
};
const Template: Story<UserDropdownProps> = (args: UserDropdownProps) => (
<Box style={{ backgroundColor: "#000", width: 88 }}>
<UserDropdown {...args} />
</Box>
)
);
export const Example = Template.bind({})
export const Example = Template.bind({});
Example.args = {
user: MockUser,
onSignOut: () => {
return Promise.resolve()
return Promise.resolve();
},
}
};

View File

@ -1,8 +1,8 @@
import { fireEvent, screen } from "@testing-library/react"
import { MockSupportLinks, MockUser } from "../../../../testHelpers/entities"
import { render } from "../../../../testHelpers/renderHelpers"
import { Language } from "./UserDropdownContent/UserDropdownContent"
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
import { fireEvent, screen } from "@testing-library/react";
import { MockSupportLinks, MockUser } from "../../../../testHelpers/entities";
import { render } from "../../../../testHelpers/renderHelpers";
import { Language } from "./UserDropdownContent/UserDropdownContent";
import { UserDropdown, UserDropdownProps } from "./UserDropdown";
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
render(
@ -11,20 +11,20 @@ const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
supportLinks={MockSupportLinks}
onSignOut={props.onSignOut ?? jest.fn()}
/>,
)
const trigger = await screen.findByTestId("user-dropdown-trigger")
fireEvent.click(trigger)
}
);
const trigger = await screen.findByTestId("user-dropdown-trigger");
fireEvent.click(trigger);
};
describe("UserDropdown", () => {
describe("when the trigger is clicked", () => {
it("opens the menu", async () => {
await renderAndClick()
expect(screen.getByText(Language.accountLabel)).toBeDefined()
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
})
})
})
await renderAndClick();
expect(screen.getByText(Language.accountLabel)).toBeDefined();
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined();
expect(screen.getByText(Language.signOutLabel)).toBeDefined();
});
});
});

View File

@ -1,24 +1,24 @@
import Badge from "@mui/material/Badge"
import MenuItem from "@mui/material/MenuItem"
import { makeStyles } from "@mui/styles"
import { useState, FC, PropsWithChildren, MouseEvent } from "react"
import { colors } from "theme/colors"
import * as TypesGen from "../../../../api/typesGenerated"
import { navHeight } from "../../../../theme/constants"
import { BorderedMenu } from "./BorderedMenu/BorderedMenu"
import Badge from "@mui/material/Badge";
import MenuItem from "@mui/material/MenuItem";
import { makeStyles } from "@mui/styles";
import { useState, FC, PropsWithChildren, MouseEvent } from "react";
import { colors } from "theme/colors";
import * as TypesGen from "../../../../api/typesGenerated";
import { navHeight } from "../../../../theme/constants";
import { BorderedMenu } from "./BorderedMenu/BorderedMenu";
import {
CloseDropdown,
OpenDropdown,
} from "../../../DropdownArrows/DropdownArrows"
import { UserAvatar } from "../../../UserAvatar/UserAvatar"
import { UserDropdownContent } from "./UserDropdownContent/UserDropdownContent"
import { BUTTON_SM_HEIGHT } from "theme/theme"
} from "../../../DropdownArrows/DropdownArrows";
import { UserAvatar } from "../../../UserAvatar/UserAvatar";
import { UserDropdownContent } from "./UserDropdownContent/UserDropdownContent";
import { BUTTON_SM_HEIGHT } from "theme/theme";
export interface UserDropdownProps {
user: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onSignOut: () => void
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onSignOut: () => void;
}
export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
@ -27,15 +27,15 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
supportLinks,
onSignOut,
}: UserDropdownProps) => {
const styles = useStyles()
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>()
const styles = useStyles();
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>();
const handleDropdownClick = (ev: MouseEvent<HTMLLIElement>): void => {
setAnchorEl(ev.currentTarget)
}
setAnchorEl(ev.currentTarget);
};
const onPopoverClose = () => {
setAnchorEl(undefined)
}
setAnchorEl(undefined);
};
return (
<>
@ -88,8 +88,8 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
/>
</BorderedMenu>
</>
)
}
);
};
export const useStyles = makeStyles((theme) => ({
divider: {
@ -110,4 +110,4 @@ export const useStyles = makeStyles((theme) => ({
backgroundColor: "transparent",
},
},
}))
}));

View File

@ -1,36 +1,36 @@
import { Story } from "@storybook/react"
import { MockUser } from "../../../../../testHelpers/entities"
import { Story } from "@storybook/react";
import { MockUser } from "../../../../../testHelpers/entities";
import {
UserDropdownContent,
UserDropdownContentProps,
} from "./UserDropdownContent"
} from "./UserDropdownContent";
export default {
title: "components/UserDropdownContent",
component: UserDropdownContent,
}
};
const Template: Story<UserDropdownContentProps> = (args) => (
<UserDropdownContent {...args} />
)
);
export const ExampleNoRoles = Template.bind({})
export const ExampleNoRoles = Template.bind({});
ExampleNoRoles.args = {
user: {
...MockUser,
roles: [],
},
}
};
export const ExampleOneRole = Template.bind({})
export const ExampleOneRole = Template.bind({});
ExampleOneRole.args = {
user: {
...MockUser,
roles: [{ name: "member", display_name: "Member" }],
},
}
};
export const ExampleThreeRoles = Template.bind({})
export const ExampleThreeRoles = Template.bind({});
ExampleThreeRoles.args = {
user: {
...MockUser,
@ -40,4 +40,4 @@ ExampleThreeRoles.args = {
{ name: "auditor", display_name: "Auditor" },
],
},
}
};

View File

@ -1,24 +1,24 @@
import { screen } from "@testing-library/react"
import { screen } from "@testing-library/react";
import {
MockBuildInfo,
MockSupportLinks,
MockUser,
} from "../../../../../testHelpers/entities"
import { render } from "../../../../../testHelpers/renderHelpers"
import { Language, UserDropdownContent } from "./UserDropdownContent"
} from "../../../../../testHelpers/entities";
import { render } from "../../../../../testHelpers/renderHelpers";
import { Language, UserDropdownContent } from "./UserDropdownContent";
describe("UserDropdownContent", () => {
const env = process.env
const env = process.env;
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
beforeEach(() => {
process.env = { ...env }
})
process.env = { ...env };
});
// REMARK: restoring process.env
afterEach(() => {
process.env = env
})
process.env = env;
});
it("displays the menu items", () => {
render(
@ -29,21 +29,21 @@ describe("UserDropdownContent", () => {
onSignOut={jest.fn()}
onPopoverClose={jest.fn()}
/>,
)
expect(screen.getByText(Language.accountLabel)).toBeDefined()
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
expect(screen.getByText(Language.copyrightText)).toBeDefined()
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
);
expect(screen.getByText(Language.accountLabel)).toBeDefined();
expect(screen.getByText(Language.signOutLabel)).toBeDefined();
expect(screen.getByText(Language.copyrightText)).toBeDefined();
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined();
expect(
screen.getByText(MockSupportLinks[2].name).closest("a"),
).toHaveAttribute(
"href",
"https://github.com/coder/coder/issues/new?labels=needs+grooming&body=Version%3A%20%5B%60v99.999.9999%2Bc9cdf14%60%5D(file%3A%2F%2F%2Fmock-url)",
)
expect(screen.getByText(MockBuildInfo.version)).toBeDefined()
})
);
expect(screen.getByText(MockBuildInfo.version)).toBeDefined();
});
it("has the correct link for the account item", () => {
render(
@ -52,26 +52,26 @@ describe("UserDropdownContent", () => {
onSignOut={jest.fn()}
onPopoverClose={jest.fn()}
/>,
)
);
const link = screen.getByText(Language.accountLabel).closest("a")
const link = screen.getByText(Language.accountLabel).closest("a");
if (!link) {
throw new Error("Anchor tag not found for the account menu item")
throw new Error("Anchor tag not found for the account menu item");
}
expect(link.getAttribute("href")).toBe("/settings/account")
})
expect(link.getAttribute("href")).toBe("/settings/account");
});
it("calls the onSignOut function", () => {
const onSignOut = jest.fn()
const onSignOut = jest.fn();
render(
<UserDropdownContent
user={MockUser}
onSignOut={onSignOut}
onPopoverClose={jest.fn()}
/>,
)
screen.getByText(Language.signOutLabel).click()
expect(onSignOut).toBeCalledTimes(1)
})
})
);
screen.getByText(Language.signOutLabel).click();
expect(onSignOut).toBeCalledTimes(1);
});
});

View File

@ -1,30 +1,30 @@
import Divider from "@mui/material/Divider"
import MenuItem from "@mui/material/MenuItem"
import { makeStyles } from "@mui/styles"
import AccountIcon from "@mui/icons-material/AccountCircleOutlined"
import BugIcon from "@mui/icons-material/BugReportOutlined"
import ChatIcon from "@mui/icons-material/ChatOutlined"
import LaunchIcon from "@mui/icons-material/LaunchOutlined"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import { Link } from "react-router-dom"
import * as TypesGen from "../../../../../api/typesGenerated"
import DocsIcon from "@mui/icons-material/MenuBook"
import LogoutIcon from "@mui/icons-material/ExitToAppOutlined"
import { combineClasses } from "utils/combineClasses"
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import { makeStyles } from "@mui/styles";
import AccountIcon from "@mui/icons-material/AccountCircleOutlined";
import BugIcon from "@mui/icons-material/BugReportOutlined";
import ChatIcon from "@mui/icons-material/ChatOutlined";
import LaunchIcon from "@mui/icons-material/LaunchOutlined";
import { Stack } from "components/Stack/Stack";
import { FC } from "react";
import { Link } from "react-router-dom";
import * as TypesGen from "../../../../../api/typesGenerated";
import DocsIcon from "@mui/icons-material/MenuBook";
import LogoutIcon from "@mui/icons-material/ExitToAppOutlined";
import { combineClasses } from "utils/combineClasses";
export const Language = {
accountLabel: "Account",
signOutLabel: "Sign Out",
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
}
};
export interface UserDropdownContentProps {
user: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onPopoverClose: () => void
onSignOut: () => void
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onPopoverClose: () => void;
onSignOut: () => void;
}
export const UserDropdownContent: FC<UserDropdownContentProps> = ({
@ -34,7 +34,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
onPopoverClose,
onSignOut,
}) => {
const styles = useStyles()
const styles = useStyles();
return (
<div>
@ -101,8 +101,8 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<div className={styles.footerText}>{Language.copyrightText}</div>
</Stack>
</div>
)
}
);
};
const useStyles = makeStyles((theme) => ({
info: {
@ -166,7 +166,7 @@ const useStyles = makeStyles((theme) => ({
buildInfo: {
color: theme.palette.text.primary,
},
}))
}));
const includeBuildInfo = (
href: string,
@ -177,5 +177,5 @@ const includeBuildInfo = (
`${encodeURIComponent(
`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})`,
)}`,
)
}
);
};

View File

@ -1,13 +1,13 @@
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { ServiceBannerView } from "./ServiceBannerView"
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { ServiceBannerView } from "./ServiceBannerView";
export const ServiceBanner: React.FC = () => {
const { appearance } = useDashboard()
const { appearance } = useDashboard();
const { message, background_color, enabled } =
appearance.config.service_banner
appearance.config.service_banner;
if (!enabled) {
return null
return null;
}
if (message !== undefined && background_color !== undefined) {
@ -17,8 +17,8 @@ export const ServiceBanner: React.FC = () => {
backgroundColor={background_color}
preview={appearance.preview}
/>
)
);
} else {
return null
return null;
}
}
};

View File

@ -1,24 +1,24 @@
import { Story } from "@storybook/react"
import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView"
import { Story } from "@storybook/react";
import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView";
export default {
title: "components/ServiceBannerView",
component: ServiceBannerView,
}
};
const Template: Story<ServiceBannerViewProps> = (args) => (
<ServiceBannerView {...args} />
)
);
export const Production = Template.bind({})
export const Production = Template.bind({});
Production.args = {
message: "weeeee",
backgroundColor: "#FFFFFF",
}
};
export const Preview = Template.bind({})
export const Preview = Template.bind({});
Preview.args = {
message: "weeeee",
backgroundColor: "#000000",
preview: true,
}
};

View File

@ -1,13 +1,13 @@
import { makeStyles } from "@mui/styles"
import { Pill } from "components/Pill/Pill"
import ReactMarkdown from "react-markdown"
import { colors } from "theme/colors"
import { hex } from "color-convert"
import { makeStyles } from "@mui/styles";
import { Pill } from "components/Pill/Pill";
import ReactMarkdown from "react-markdown";
import { colors } from "theme/colors";
import { hex } from "color-convert";
export interface ServiceBannerViewProps {
message: string
backgroundColor: string
preview: boolean
message: string;
backgroundColor: string;
preview: boolean;
}
export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
@ -15,7 +15,7 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
backgroundColor,
preview,
}) => {
const styles = useStyles()
const styles = useStyles();
// We don't want anything funky like an image or a heading in the service
// banner.
const markdownElementsAllowed = [
@ -28,7 +28,7 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
"italic",
"link",
"em",
]
];
return (
<div
className={styles.container}
@ -50,8 +50,8 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
</ReactMarkdown>
</div>
</div>
)
}
);
};
const useStyles = makeStyles((theme) => ({
container: {
@ -74,14 +74,14 @@ const useStyles = makeStyles((theme) => ({
color: "inherit",
},
},
}))
}));
const readableForegroundColor = (backgroundColor: string): string => {
const rgb = hex.rgb(backgroundColor)
const rgb = hex.rgb(backgroundColor);
// Logic taken from here:
// https://github.com/casesandberg/react-color/blob/bc9a0e1dc5d11b06c511a8e02a95bd85c7129f4b/src/helpers/color.js#L56
// to be consistent with the color-picker label.
const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000
return yiq >= 128 ? "#000" : "#fff"
}
const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
return yiq >= 128 ? "#000" : "#fff";
};

View File

@ -1,92 +1,92 @@
import { makeStyles } from "@mui/styles"
import { Stack } from "components/Stack/Stack"
import { PropsWithChildren, FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import { combineClasses } from "utils/combineClasses"
import Tooltip from "@mui/material/Tooltip"
import { makeStyles } from "@mui/styles";
import { Stack } from "components/Stack/Stack";
import { PropsWithChildren, FC } from "react";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { combineClasses } from "utils/combineClasses";
import Tooltip from "@mui/material/Tooltip";
export const EnabledBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
Enabled
</span>
)
}
);
};
export const EntitledBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
Entitled
</span>
)
}
);
};
export const HealthyBadge: FC<{ derpOnly: boolean }> = ({ derpOnly }) => {
const styles = useStyles()
let text = "Healthy"
const styles = useStyles();
let text = "Healthy";
if (derpOnly) {
text = "Healthy (DERP Only)"
text = "Healthy (DERP Only)";
}
return (
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
{text}
</span>
)
}
);
};
export const NotHealthyBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<span className={combineClasses([styles.badge, styles.errorBadge])}>
Unhealthy
</span>
)
}
);
};
export const NotRegisteredBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<Tooltip title="Workspace Proxy has never come online and needs to be started.">
<span className={combineClasses([styles.badge, styles.warnBadge])}>
Never Seen
</span>
</Tooltip>
)
}
);
};
export const NotReachableBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<Tooltip title="Workspace Proxy not responding to http(s) requests.">
<span className={combineClasses([styles.badge, styles.warnBadge])}>
Not Dialable
</span>
</Tooltip>
)
}
);
};
export const DisabledBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<span className={combineClasses([styles.badge, styles.disabledBadge])}>
Disabled
</span>
)
}
);
};
export const EnterpriseBadge: FC = () => {
const styles = useStyles()
const styles = useStyles();
return (
<span className={combineClasses([styles.badge, styles.enterpriseBadge])}>
Enterprise
</span>
)
}
);
};
export const Badges: FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
const styles = useStyles();
return (
<Stack
className={styles.badges}
@ -96,8 +96,8 @@ export const Badges: FC<PropsWithChildren> = ({ children }) => {
>
{children}
</Stack>
)
}
);
};
const useStyles = makeStyles((theme) => ({
badges: {
@ -152,4 +152,4 @@ const useStyles = makeStyles((theme) => ({
border: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
},
}))
}));

Some files were not shown because too many files have changed in this diff Show More