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 # This config file is used in conjunction with `.editorconfig` to specify
# formatting for prettier-supported files. See `.editorconfig` and # formatting for prettier-supported files. See `.editorconfig` and
# `site/.editorconfig`for whitespace formatting options. # `site/.editorconfig` for whitespace formatting options.
printWidth: 80 printWidth: 80
proseWrap: always proseWrap: always
semi: false
trailingComma: all trailingComma: all
useTabs: false useTabs: false
tabWidth: 2 tabWidth: 2

View File

@ -128,9 +128,9 @@ export const getAgentListeningPorts = async (
): Promise<TypesGen.ListeningPortsResponse> => { ): Promise<TypesGen.ListeningPortsResponse> => {
const response = await axios.get( const response = await axios.get(
`/api/v2/workspaceagents/${agentID}/listening-ports`, `/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 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 ( export const updateWorkspaceVersion = async (
workspace: TypesGen.Workspace, workspace: TypesGen.Workspace,
): Promise<TypesGen.WorkspaceBuild> => { ): Promise<TypesGen.WorkspaceBuild> => {
const template = await getTemplate(workspace.template_id) const template = await getTemplate(workspace.template_id);
return startWorkspace(workspace.id, template.active_version_id) return startWorkspace(workspace.id, template.active_version_id);
} };
``` ```
If you need more granular errors or control, you may should consider keep them 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. slow.
```tsx ```tsx
user.click(screen.getByRole("button")) user.click(screen.getByRole("button"));
``` ```
✅ Better. We can limit the number of elements we are querying. ✅ Better. We can limit the number of elements we are querying.
```tsx ```tsx
const form = screen.getByTestId("form") const form = screen.getByTestId("form");
user.click(within(form).getByRole("button")) user.click(within(form).getByRole("button"));
``` ```
#### `jest.spyOn` with the API is not working #### `jest.spyOn` with the API is not working

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { Page } from "@playwright/test" import { Page } from "@playwright/test";
export const beforeCoderTest = async (page: Page) => { export const beforeCoderTest = async (page: Page) => {
// eslint-disable-next-line no-console -- Show everything that was printed with console.log() // 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) => { page.on("request", (request) => {
if (!isApiCall(request.url())) { if (!isApiCall(request.url())) {
return return;
} }
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes // 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=${ `[onRequest] method=${request.method()} url=${request.url()} postData=${
request.postData() ? request.postData() : "" request.postData() ? request.postData() : ""
}`, }`,
) );
}) });
page.on("response", async (response) => { page.on("response", async (response) => {
if (!isApiCall(response.url())) { if (!isApiCall(response.url())) {
return return;
} }
const shouldLogResponse = const shouldLogResponse =
!response.url().endsWith("/api/v2/deployment/config") && !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 { try {
if (shouldLogResponse) { if (shouldLogResponse) {
const buffer = await response.body() const buffer = await response.body();
responseText = buffer.toString("utf-8") responseText = buffer.toString("utf-8");
responseText = responseText.replace(/\n$/g, "") responseText = responseText.replace(/\n$/g, "");
} else { } else {
responseText = "skipped..." responseText = "skipped...";
} }
} catch (error) { } catch (error) {
responseText = "not_available" responseText = "not_available";
} }
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes // eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes
console.log( console.log(
`[onResponse] url=${response.url()} status=${response.status()} body=${responseText}`, `[onResponse] url=${response.url()} status=${response.status()} body=${responseText}`,
) );
}) });
} };
const isApiCall = (urlString: string): boolean => { const isApiCall = (urlString: string): boolean => {
const url = new URL(urlString) const url = new URL(urlString);
const apiPath = "/api/v2" 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 // Rich parameters
@ -19,7 +19,7 @@ const emptyParameter: RichParameter = {
displayName: "", displayName: "",
order: 0, order: 0,
ephemeral: false, ephemeral: false,
} };
// firstParameter is mutable string with a default value (parameter value not required). // firstParameter is mutable string with a default value (parameter value not required).
export const firstParameter: RichParameter = { export const firstParameter: RichParameter = {
@ -33,7 +33,7 @@ export const firstParameter: RichParameter = {
defaultValue: "123", defaultValue: "123",
mutable: true, mutable: true,
order: 1, order: 1,
} };
// secondParameter is immutable string with a default value (parameter value not required). // secondParameter is immutable string with a default value (parameter value not required).
export const secondParameter: RichParameter = { export const secondParameter: RichParameter = {
@ -45,7 +45,7 @@ export const secondParameter: RichParameter = {
description: "This is second parameter.", description: "This is second parameter.",
defaultValue: "abc", defaultValue: "abc",
order: 2, order: 2,
} };
// thirdParameter is mutable string with an empty default value (parameter value not required). // thirdParameter is mutable string with an empty default value (parameter value not required).
export const thirdParameter: RichParameter = { export const thirdParameter: RichParameter = {
@ -57,7 +57,7 @@ export const thirdParameter: RichParameter = {
defaultValue: "", defaultValue: "",
mutable: true, mutable: true,
order: 3, order: 3,
} };
// fourthParameter is immutable boolean with a default "true" value (parameter value not required). // fourthParameter is immutable boolean with a default "true" value (parameter value not required).
export const fourthParameter: RichParameter = { export const fourthParameter: RichParameter = {
@ -68,7 +68,7 @@ export const fourthParameter: RichParameter = {
description: "This is fourth parameter.", description: "This is fourth parameter.",
defaultValue: "true", defaultValue: "true",
order: 3, order: 3,
} };
// fifthParameter is immutable "string with options", with a default option selected (parameter value not required). // fifthParameter is immutable "string with options", with a default option selected (parameter value not required).
export const fifthParameter: RichParameter = { export const fifthParameter: RichParameter = {
@ -100,7 +100,7 @@ export const fifthParameter: RichParameter = {
description: "This is fifth parameter.", description: "This is fifth parameter.",
defaultValue: "def", defaultValue: "def",
order: 3, order: 3,
} };
// sixthParameter is mutable string without a default value (parameter value is required). // sixthParameter is mutable string without a default value (parameter value is required).
export const sixthParameter: RichParameter = { export const sixthParameter: RichParameter = {
@ -114,7 +114,7 @@ export const sixthParameter: RichParameter = {
required: true, required: true,
mutable: true, mutable: true,
order: 1, order: 1,
} };
// seventhParameter is immutable string without a default value (parameter value is required). // seventhParameter is immutable string without a default value (parameter value is required).
export const seventhParameter: RichParameter = { export const seventhParameter: RichParameter = {
@ -126,7 +126,7 @@ export const seventhParameter: RichParameter = {
description: "This is seventh parameter.", description: "This is seventh parameter.",
required: true, required: true,
order: 1, order: 1,
} };
// Build options // Build options
@ -141,7 +141,7 @@ export const firstBuildOption: RichParameter = {
defaultValue: "ABCDEF", defaultValue: "ABCDEF",
mutable: true, mutable: true,
ephemeral: true, ephemeral: true,
} };
export const secondBuildOption: RichParameter = { export const secondBuildOption: RichParameter = {
...emptyParameter, ...emptyParameter,
@ -153,4 +153,4 @@ export const secondBuildOption: RichParameter = {
defaultValue: "false", defaultValue: "false",
mutable: true, mutable: true,
ephemeral: true, ephemeral: true,
} };

View File

@ -1,18 +1,18 @@
import { defineConfig } from "@playwright/test" import { defineConfig } from "@playwright/test";
import path from "path" import path from "path";
import { defaultPort, gitAuth } from "./constants" import { defaultPort, gitAuth } from "./constants";
export const port = process.env.CODER_E2E_PORT export const port = process.env.CODER_E2E_PORT
? Number(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 => { const localURL = (port: number, path: string): string => {
return `http://localhost:${port}${path}` return `http://localhost:${port}${path}`;
} };
export default defineConfig({ export default defineConfig({
projects: [ projects: [
@ -92,4 +92,4 @@ export default defineConfig({
}, },
reuseExistingServer: false, reuseExistingServer: false,
}, },
}) });

View File

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

View File

@ -1,8 +1,8 @@
import { Page } from "@playwright/test" import { Page } from "@playwright/test";
import { BasePom } from "./BasePom" import { BasePom } from "./BasePom";
export class WorkspacesPage extends BasePom { export class WorkspacesPage extends BasePom {
constructor(baseURL: string | undefined, page: Page, params?: string) { 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 "./SignInPage";
export * from "./WorkspacesPage" 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 { import type {
FullConfig, FullConfig,
Suite, Suite,
@ -6,18 +6,18 @@ import type {
TestResult, TestResult,
FullResult, FullResult,
Reporter, Reporter,
} from "@playwright/test/reporter" } from "@playwright/test/reporter";
import axios from "axios" import axios from "axios";
class CoderReporter implements Reporter { class CoderReporter implements Reporter {
onBegin(config: FullConfig, suite: Suite) { onBegin(config: FullConfig, suite: Suite) {
// eslint-disable-next-line no-console -- Helpful for debugging // 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) { onTestBegin(test: TestCase) {
// eslint-disable-next-line no-console -- Helpful for debugging // 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 { onStdOut(chunk: string, test: TestCase, _: TestResult): void {
@ -27,7 +27,7 @@ class CoderReporter implements Reporter {
/\n$/g, /\n$/g,
"", "",
)}`, )}`,
) );
} }
onStdErr(chunk: string, test: TestCase, _: TestResult): void { onStdErr(chunk: string, test: TestCase, _: TestResult): void {
@ -37,50 +37,50 @@ class CoderReporter implements Reporter {
/\n$/g, /\n$/g,
"", "",
)}`, )}`,
) );
} }
async onTestEnd(test: TestCase, result: TestResult) { async onTestEnd(test: TestCase, result: TestResult) {
// eslint-disable-next-line no-console -- Helpful for debugging // 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") { if (result.status !== "passed") {
// eslint-disable-next-line no-console -- Helpful for debugging // 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) { onEnd(result: FullResult) {
// eslint-disable-next-line no-console -- Helpful for debugging // 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 exportDebugPprof = async (testName: string) => {
const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1";
const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt` const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`;
await axios await axios
.get(url) .get(url)
.then((response) => { .then((response) => {
if (response.status !== 200) { 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) => { fs.writeFile(outputFile, response.data, (err) => {
if (err) { if (err) {
throw new Error(`Error writing to ${outputFile}: ${err.message}`) throw new Error(`Error writing to ${outputFile}: ${err.message}`);
} else { } else {
// eslint-disable-next-line no-console -- Helpful for debugging // 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) => { .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 // 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 { test } from "@playwright/test";
import { randomUUID } from "crypto" import { randomUUID } from "crypto";
import * as http from "http" import * as http from "http";
import { import {
createTemplate, createTemplate,
createWorkspace, createWorkspace,
startAgent, startAgent,
stopAgent, stopAgent,
stopWorkspace, stopWorkspace,
} from "../helpers" } from "../helpers";
import { beforeCoderTest } from "../hooks" import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page)) test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("app", async ({ context, page }) => { test("app", async ({ context, page }) => {
const appContent = "Hello World" const appContent = "Hello World";
const token = randomUUID() const token = randomUUID();
const srv = http const srv = http
.createServer((req, res) => { .createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" }) res.writeHead(200, { "Content-Type": "text/plain" });
res.end(appContent) res.end(appContent);
}) })
.listen(0) .listen(0);
const addr = srv.address() const addr = srv.address();
if (typeof addr !== "object" || !addr) { 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, { const template = await createTemplate(page, {
apply: [ apply: [
{ {
@ -48,17 +48,17 @@ test("app", async ({ context, page }) => {
}, },
}, },
], ],
}) });
const workspaceName = await createWorkspace(page, template) const workspaceName = await createWorkspace(page, template);
const agent = await startAgent(page, token) const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab // Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page") const pagePromise = context.waitForEvent("page");
await page.getByText(appName).click() await page.getByText(appName).click();
const app = await pagePromise const app = await pagePromise;
await app.waitForLoadState("domcontentloaded") await app.waitForLoadState("domcontentloaded");
await app.getByText(appContent).isVisible() await app.getByText(appContent).isVisible();
await stopWorkspace(page, workspaceName) await stopWorkspace(page, workspaceName);
await stopAgent(agent) await stopAgent(agent);
}) });

View File

@ -1,10 +1,10 @@
import { test } from "@playwright/test" import { test } from "@playwright/test";
import { import {
createTemplate, createTemplate,
createWorkspace, createWorkspace,
echoResponsesWithParameters, echoResponsesWithParameters,
verifyParameters, verifyParameters,
} from "../helpers" } from "../helpers";
import { import {
secondParameter, secondParameter,
@ -14,11 +14,11 @@ import {
thirdParameter, thirdParameter,
seventhParameter, seventhParameter,
sixthParameter, sixthParameter,
} from "../parameters" } from "../parameters";
import { RichParameter } from "../provisionerGenerated" import { RichParameter } from "../provisionerGenerated";
import { beforeCoderTest } from "../hooks" import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page)) test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("create workspace", async ({ page }) => { test("create workspace", async ({ page }) => {
const template = await createTemplate(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 }) => { test("create workspace with default immutable parameters", async ({ page }) => {
const richParameters: RichParameter[] = [ const richParameters: RichParameter[] = [
secondParameter, secondParameter,
fourthParameter, fourthParameter,
fifthParameter, fifthParameter,
] ];
const template = await createTemplate( const template = await createTemplate(
page, page,
echoResponsesWithParameters(richParameters), echoResponsesWithParameters(richParameters),
) );
const workspaceName = await createWorkspace(page, template) const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [ await verifyParameters(page, workspaceName, richParameters, [
{ name: secondParameter.name, value: secondParameter.defaultValue }, { name: secondParameter.name, value: secondParameter.defaultValue },
{ name: fourthParameter.name, value: fourthParameter.defaultValue }, { name: fourthParameter.name, value: fourthParameter.defaultValue },
{ name: fifthParameter.name, value: fifthParameter.defaultValue }, { name: fifthParameter.name, value: fifthParameter.defaultValue },
]) ]);
}) });
test("create workspace with default mutable parameters", async ({ page }) => { test("create workspace with default mutable parameters", async ({ page }) => {
const richParameters: RichParameter[] = [firstParameter, thirdParameter] const richParameters: RichParameter[] = [firstParameter, thirdParameter];
const template = await createTemplate( const template = await createTemplate(
page, page,
echoResponsesWithParameters(richParameters), echoResponsesWithParameters(richParameters),
) );
const workspaceName = await createWorkspace(page, template) const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [ await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue }, { name: firstParameter.name, value: firstParameter.defaultValue },
{ name: thirdParameter.name, value: thirdParameter.defaultValue }, { name: thirdParameter.name, value: thirdParameter.defaultValue },
]) ]);
}) });
test("create workspace with default and required parameters", async ({ test("create workspace with default and required parameters", async ({
page, page,
@ -76,46 +76,46 @@ test("create workspace with default and required parameters", async ({
fourthParameter, fourthParameter,
sixthParameter, sixthParameter,
seventhParameter, seventhParameter,
] ];
const buildParameters = [ const buildParameters = [
{ name: sixthParameter.name, value: "12345" }, { name: sixthParameter.name, value: "12345" },
{ name: seventhParameter.name, value: "abcdef" }, { name: seventhParameter.name, value: "abcdef" },
] ];
const template = await createTemplate( const template = await createTemplate(
page, page,
echoResponsesWithParameters(richParameters), echoResponsesWithParameters(richParameters),
) );
const workspaceName = await createWorkspace( const workspaceName = await createWorkspace(
page, page,
template, template,
richParameters, richParameters,
buildParameters, buildParameters,
) );
await verifyParameters(page, workspaceName, richParameters, [ await verifyParameters(page, workspaceName, richParameters, [
// user values: // user values:
...buildParameters, ...buildParameters,
// default values: // default values:
{ name: secondParameter.name, value: secondParameter.defaultValue }, { name: secondParameter.name, value: secondParameter.defaultValue },
{ name: fourthParameter.name, value: fourthParameter.defaultValue }, { name: fourthParameter.name, value: fourthParameter.defaultValue },
]) ]);
}) });
test("create workspace and overwrite default parameters", async ({ page }) => { test("create workspace and overwrite default parameters", async ({ page }) => {
const richParameters: RichParameter[] = [secondParameter, fourthParameter] const richParameters: RichParameter[] = [secondParameter, fourthParameter];
const buildParameters = [ const buildParameters = [
{ name: secondParameter.name, value: "AAAAA" }, { name: secondParameter.name, value: "AAAAA" },
{ name: fourthParameter.name, value: "false" }, { name: fourthParameter.name, value: "false" },
] ];
const template = await createTemplate( const template = await createTemplate(
page, page,
echoResponsesWithParameters(richParameters), echoResponsesWithParameters(richParameters),
) );
const workspaceName = await createWorkspace( const workspaceName = await createWorkspace(
page, page,
template, template,
richParameters, richParameters,
buildParameters, 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 { test } from "@playwright/test";
import { gitAuth } from "../constants" import { gitAuth } from "../constants";
import { Endpoints } from "@octokit/types" import { Endpoints } from "@octokit/types";
import { GitAuthDevice } from "api/typesGenerated" import { GitAuthDevice } from "api/typesGenerated";
import { Awaiter, createServer } from "../helpers" import { Awaiter, createServer } from "../helpers";
import { beforeCoderTest } from "../hooks" 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! // Ensures that a Git auth provider with the device flow functions and completes!
test("git auth device", async ({ page }) => { test("git auth device", async ({ page }) => {
@ -15,71 +15,71 @@ test("git auth device", async ({ page }) => {
expires_in: 900, expires_in: 900,
interval: 1, interval: 1,
verification_uri: "", verification_uri: "",
} };
// Start a server to mock the GitHub API. // 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) => { srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser)) res.write(JSON.stringify(ghUser));
res.end() res.end();
}) });
srv.use(gitAuth.codePath, (req, res) => { srv.use(gitAuth.codePath, (req, res) => {
res.write(JSON.stringify(device)) res.write(JSON.stringify(device));
res.end() res.end();
}) });
srv.use(gitAuth.installationsPath, (req, res) => { srv.use(gitAuth.installationsPath, (req, res) => {
res.write(JSON.stringify(ghInstall)) res.write(JSON.stringify(ghInstall));
res.end() res.end();
}) });
const token = { const token = {
access_token: "", access_token: "",
error: "authorization_pending", error: "authorization_pending",
error_description: "", error_description: "",
} };
// First we send a result from the API that the token hasn't been // First we send a result from the API that the token hasn't been
// authorized yet to ensure the UI reacts properly. // authorized yet to ensure the UI reacts properly.
const sentPending = new Awaiter() const sentPending = new Awaiter();
srv.use(gitAuth.tokenPath, (req, res) => { srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify(token)) res.write(JSON.stringify(token));
res.end() res.end();
sentPending.done() sentPending.done();
}) });
await page.goto(`/gitauth/${gitAuth.deviceProvider}`, { await page.goto(`/gitauth/${gitAuth.deviceProvider}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}) });
await page.getByText(device.user_code).isVisible() await page.getByText(device.user_code).isVisible();
await sentPending.wait() await sentPending.wait();
// Update the token to be valid and ensure the UI updates! // Update the token to be valid and ensure the UI updates!
token.error = "" token.error = "";
token.access_token = "hello-world" token.access_token = "hello-world";
await page.waitForSelector("text=1 organization authorized") await page.waitForSelector("text=1 organization authorized");
}) });
test("git auth web", async ({ baseURL, page }) => { 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! // The GitHub validate endpoint returns the currently authenticated user!
srv.use(gitAuth.validatePath, (req, res) => { srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser)) res.write(JSON.stringify(ghUser));
res.end() res.end();
}) });
srv.use(gitAuth.tokenPath, (req, res) => { srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify({ access_token: "hello-world" })) res.write(JSON.stringify({ access_token: "hello-world" }));
res.end() res.end();
}) });
srv.use(gitAuth.authPath, (req, res) => { srv.use(gitAuth.authPath, (req, res) => {
res.redirect( res.redirect(
`${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` + `${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` +
req.query.state, req.query.state,
) );
}) });
await page.goto(`/gitauth/${gitAuth.webProvider}`, { await page.goto(`/gitauth/${gitAuth.webProvider}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}) });
// This endpoint doesn't have the installations URL set intentionally! // 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"] = { const ghUser: Endpoints["GET /user"]["response"]["data"] = {
login: "kylecarbs", login: "kylecarbs",
@ -115,7 +115,7 @@ const ghUser: Endpoints["GET /user"]["response"]["data"] = {
following: 31, following: 31,
created_at: "2014-04-01T02:24:41Z", created_at: "2014-04-01T02:24:41Z",
updated_at: "2023-06-26T13:03:09Z", updated_at: "2023-06-26T13:03:09Z",
} };
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = { const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
installations: [ installations: [
@ -140,4 +140,4 @@ const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
}, },
], ],
total_count: 1, total_count: 1,
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,17 @@
import { test } from "@playwright/test" import { test } from "@playwright/test";
import { import {
createTemplate, createTemplate,
createWorkspace, createWorkspace,
startAgent, startAgent,
stopAgent, stopAgent,
} from "../helpers" } from "../helpers";
import { randomUUID } from "crypto" import { randomUUID } from "crypto";
import { beforeCoderTest } from "../hooks" import { beforeCoderTest } from "../hooks";
test.beforeEach(async ({ page }) => await beforeCoderTest(page)) test.beforeEach(async ({ page }) => await beforeCoderTest(page));
test("web terminal", async ({ context, page }) => { test("web terminal", async ({ context, page }) => {
const token = randomUUID() const token = randomUUID();
const template = await createTemplate(page, { const template = await createTemplate(page, {
apply: [ apply: [
{ {
@ -31,29 +31,29 @@ test("web terminal", async ({ context, page }) => {
}, },
}, },
], ],
}) });
await createWorkspace(page, template) await createWorkspace(page, template);
const agent = await startAgent(page, token) const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab // Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page") const pagePromise = context.waitForEvent("page");
await page.getByTestId("terminal").click() await page.getByTestId("terminal").click();
const terminal = await pagePromise const terminal = await pagePromise;
await terminal.waitForLoadState("domcontentloaded") await terminal.waitForLoadState("domcontentloaded");
// Ensure that we can type in it // Ensure that we can type in it
await terminal.keyboard.type("echo hello") await terminal.keyboard.type("echo hello");
await terminal.keyboard.press("Enter") await terminal.keyboard.press("Enter");
const locator = terminal.locator("text=hello") const locator = terminal.locator("text=hello");
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const items = await locator.all() const items = await locator.all();
// Make sure the text came back // Make sure the text came back
if (items.length === 2) { 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. // Toggle eslint --fix by specifying the `FIX` env.
const fix = !!process.env.FIX const fix = !!process.env.FIX;
module.exports = { module.exports = {
cliOptions: { cliOptions: {
@ -10,4 +10,4 @@ module.exports = {
resolvePluginsRelativeTo: ".", resolvePluginsRelativeTo: ".",
maxWarnings: 0, maxWarnings: 0,
}, },
} };

View File

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

View File

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

View File

@ -1,9 +1,9 @@
declare module "@emoji-mart/react" { declare module "@emoji-mart/react" {
const Picker: React.FC<{ const Picker: React.FC<{
theme: "dark" | "light" theme: "dark" | "light";
data: Record<string, unknown> data: Record<string, unknown>;
onEmojiSelect: (emojiData: { unified: string }) => void 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 // https://github.com/i18next/react-i18next/issues/1543#issuecomment-1528679591
declare module "i18next" { declare module "i18next" {
interface TypeOptions { interface TypeOptions {
returnNull: false returnNull: false;
allowObjectInHTMLChildren: 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" { declare module "@mui/styles/defaultTheme" {
interface DefaultTheme extends Theme {} interface DefaultTheme extends Theme {}
@ -6,20 +6,20 @@ declare module "@mui/styles/defaultTheme" {
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface TypeBackground { interface TypeBackground {
paperLight: string paperLight: string;
} }
interface Palette { interface Palette {
neutral: PaletteColor neutral: PaletteColor;
} }
interface PaletteOptions { interface PaletteOptions {
neutral?: PaletteColorOptions neutral?: PaletteColorOptions;
} }
} }
declare module "@mui/material/Button" { declare module "@mui/material/Button" {
interface ButtonPropsColorOverrides { interface ButtonPropsColorOverrides {
neutral: true neutral: true;
} }
} }

View File

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

View File

@ -1,8 +1,8 @@
import { inspect } from "@xstate/inspect" import { inspect } from "@xstate/inspect";
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client";
import { Interpreter } from "xstate" import { Interpreter } from "xstate";
import { App } from "./app" import { App } from "./app";
import "./i18n" import "./i18n";
// if this is a development build and the developer wants to inspect // if this is a development build and the developer wants to inspect
// helpful to see realtime changes on the services // helpful to see realtime changes on the services
@ -14,9 +14,9 @@ if (
inspect({ inspect({
url: "https://stately.ai/viz?inspect", url: "https://stately.ai/viz?inspect",
iframe: false, iframe: false,
}) });
// configure all XServices to use the inspector // 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. // 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) { if (element === null) {
throw new Error("root element is null") throw new Error("root element is null");
} }
const root = createRoot(element) const root = createRoot(element);
root.render(<App />) 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: () => { dispose: () => {
// //
}, },
} };
}, },
} };
const monaco = { const monaco = {
editor, 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 }) => { 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 { import {
MockTemplate, MockTemplate,
MockTemplateVersionParameter1, MockTemplateVersionParameter1,
@ -6,9 +6,9 @@ import {
MockWorkspace, MockWorkspace,
MockWorkspaceBuild, MockWorkspaceBuild,
MockWorkspaceBuildParameter1, MockWorkspaceBuildParameter1,
} from "testHelpers/entities" } from "testHelpers/entities";
import * as api from "./api" import * as api from "./api";
import * as TypesGen from "./typesGenerated" import * as TypesGen from "./typesGenerated";
describe("api.ts", () => { describe("api.ts", () => {
describe("login", () => { describe("login", () => {
@ -16,111 +16,111 @@ describe("api.ts", () => {
// given // given
const loginResponse: TypesGen.LoginWithPasswordResponse = { const loginResponse: TypesGen.LoginWithPasswordResponse = {
session_token: "abc_123_test", session_token: "abc_123_test",
} };
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse }) jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse });
// when // when
const result = await api.login("test", "123") const result = await api.login("test", "123");
// then // then
expect(axios.post).toHaveBeenCalled() expect(axios.post).toHaveBeenCalled();
expect(result).toStrictEqual(loginResponse) expect(result).toStrictEqual(loginResponse);
}) });
it("should throw an error on 401", async () => { it("should throw an error on 401", async () => {
// given // given
// ..ensure that we await our expect assertion in async/await test // ..ensure that we await our expect assertion in async/await test
expect.assertions(1) expect.assertions(1);
const expectedError = { const expectedError = {
message: "Validation failed", message: "Validation failed",
errors: [{ field: "email", code: "email" }], errors: [{ field: "email", code: "email" }],
} };
const axiosMockPost = jest.fn().mockImplementationOnce(() => { const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError) return Promise.reject(expectedError);
}) });
axios.post = axiosMockPost axios.post = axiosMockPost;
try { try {
await api.login("test", "123") await api.login("test", "123");
} catch (error) { } catch (error) {
expect(error).toStrictEqual(expectedError) expect(error).toStrictEqual(expectedError);
} }
}) });
}) });
describe("logout", () => { describe("logout", () => {
it("should return without erroring", async () => { it("should return without erroring", async () => {
// given // given
const axiosMockPost = jest.fn().mockImplementationOnce(() => { const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.resolve() return Promise.resolve();
}) });
axios.post = axiosMockPost axios.post = axiosMockPost;
// when // when
await api.logout() await api.logout();
// then // then
expect(axiosMockPost).toHaveBeenCalled() expect(axiosMockPost).toHaveBeenCalled();
}) });
it("should throw an error on 500", async () => { it("should throw an error on 500", async () => {
// given // given
// ..ensure that we await our expect assertion in async/await test // ..ensure that we await our expect assertion in async/await test
expect.assertions(1) expect.assertions(1);
const expectedError = { const expectedError = {
message: "Failed to logout.", message: "Failed to logout.",
} };
const axiosMockPost = jest.fn().mockImplementationOnce(() => { const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError) return Promise.reject(expectedError);
}) });
axios.post = axiosMockPost axios.post = axiosMockPost;
try { try {
await api.logout() await api.logout();
} catch (error) { } catch (error) {
expect(error).toStrictEqual(expectedError) expect(error).toStrictEqual(expectedError);
} }
}) });
}) });
describe("getApiKey", () => { describe("getApiKey", () => {
it("should return APIKeyResponse", async () => { it("should return APIKeyResponse", async () => {
// given // given
const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = { const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = {
key: "abc_123_test", key: "abc_123_test",
} };
const axiosMockPost = jest.fn().mockImplementationOnce(() => { const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.resolve({ data: apiKeyResponse }) return Promise.resolve({ data: apiKeyResponse });
}) });
axios.post = axiosMockPost axios.post = axiosMockPost;
// when // when
const result = await api.getApiKey() const result = await api.getApiKey();
// then // then
expect(axiosMockPost).toHaveBeenCalled() expect(axiosMockPost).toHaveBeenCalled();
expect(result).toStrictEqual(apiKeyResponse) expect(result).toStrictEqual(apiKeyResponse);
}) });
it("should throw an error on 401", async () => { it("should throw an error on 401", async () => {
// given // given
// ..ensure that we await our expect assertion in async/await test // ..ensure that we await our expect assertion in async/await test
expect.assertions(1) expect.assertions(1);
const expectedError = { const expectedError = {
message: "No Cookie!", message: "No Cookie!",
} };
const axiosMockPost = jest.fn().mockImplementationOnce(() => { const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.reject(expectedError) return Promise.reject(expectedError);
}) });
axios.post = axiosMockPost axios.post = axiosMockPost;
try { try {
await api.getApiKey() await api.getApiKey();
} catch (error) { } catch (error) {
expect(error).toStrictEqual(expectedError) expect(error).toStrictEqual(expectedError);
} }
}) });
}) });
describe("getURLWithSearchParams - workspaces", () => { describe("getURLWithSearchParams - workspaces", () => {
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([ it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
@ -141,10 +141,10 @@ describe("api.ts", () => {
])( ])(
`Workspaces - getURLWithSearchParams(%p, %p) returns %p`, `Workspaces - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => { (basePath, filter, expected) => {
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected) expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
}, },
) );
}) });
describe("getURLWithSearchParams - users", () => { describe("getURLWithSearchParams - users", () => {
it.each<[string, TypesGen.UsersRequest | undefined, string]>([ it.each<[string, TypesGen.UsersRequest | undefined, string]>([
@ -158,72 +158,72 @@ describe("api.ts", () => {
])( ])(
`Users - getURLWithSearchParams(%p, %p) returns %p`, `Users - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => { (basePath, filter, expected) => {
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected) expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
}, },
) );
}) });
describe("update", () => { describe("update", () => {
it("creates a build with start and the latest template", async () => { it("creates a build with start and the latest template", async () => {
jest jest
.spyOn(api, "postWorkspaceBuild") .spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild) .mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
await api.updateWorkspace(MockWorkspace) await api.updateWorkspace(MockWorkspace);
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start", transition: "start",
template_version_id: MockTemplate.active_version_id, template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [], rich_parameter_values: [],
}) });
}) });
it("fails when having missing parameters", async () => { it("fails when having missing parameters", async () => {
jest jest
.spyOn(api, "postWorkspaceBuild") .spyOn(api, "postWorkspaceBuild")
.mockResolvedValue(MockWorkspaceBuild) .mockResolvedValue(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate) jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([]) jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([]);
jest jest
.spyOn(api, "getTemplateVersionRichParameters") .spyOn(api, "getTemplateVersionRichParameters")
.mockResolvedValue([ .mockResolvedValue([
MockTemplateVersionParameter1, MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false }, { ...MockTemplateVersionParameter2, mutable: false },
]) ]);
let error = new Error() let error = new Error();
try { try {
await api.updateWorkspace(MockWorkspace) await api.updateWorkspace(MockWorkspace);
} catch (e) { } 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 // Verify if the correct missing parameters are being passed
expect((error as api.MissingBuildParameters).parameters).toEqual([ expect((error as api.MissingBuildParameters).parameters).toEqual([
MockTemplateVersionParameter1, MockTemplateVersionParameter1,
{ ...MockTemplateVersionParameter2, mutable: false }, { ...MockTemplateVersionParameter2, mutable: false },
]) ]);
}) });
it("creates a build with the no parameters if it is already filled", async () => { it("creates a build with the no parameters if it is already filled", async () => {
jest jest
.spyOn(api, "postWorkspaceBuild") .spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild) .mockResolvedValueOnce(MockWorkspaceBuild);
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest jest
.spyOn(api, "getWorkspaceBuildParameters") .spyOn(api, "getWorkspaceBuildParameters")
.mockResolvedValue([MockWorkspaceBuildParameter1]) .mockResolvedValue([MockWorkspaceBuildParameter1]);
jest jest
.spyOn(api, "getTemplateVersionRichParameters") .spyOn(api, "getTemplateVersionRichParameters")
.mockResolvedValue([ .mockResolvedValue([
{ ...MockTemplateVersionParameter1, required: true, mutable: false }, { ...MockTemplateVersionParameter1, required: true, mutable: false },
]) ]);
await api.updateWorkspace(MockWorkspace) await api.updateWorkspace(MockWorkspace);
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start", transition: "start",
template_version_id: MockTemplate.active_version_id, template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [], 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 { import {
getValidationErrorMessage, getValidationErrorMessage,
isApiError, isApiError,
mapApiErrorToFieldErrors, mapApiErrorToFieldErrors,
} from "./errors" } from "./errors";
describe("isApiError", () => { describe("isApiError", () => {
it("returns true when the object is an API Error", () => { 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", () => { 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", () => { it("returns false when the object is undefined", () => {
expect(isApiError(undefined)).toBe(false) expect(isApiError(undefined)).toBe(false);
}) });
}) });
describe("mapApiErrorToFieldErrors", () => { describe("mapApiErrorToFieldErrors", () => {
it("returns correct field errors", () => { it("returns correct field errors", () => {
@ -39,9 +39,9 @@ describe("mapApiErrorToFieldErrors", () => {
}), }),
).toEqual({ ).toEqual({
username: "Username is already in use", username: "Username is already in use",
}) });
}) });
}) });
describe("getValidationErrorMessage", () => { describe("getValidationErrorMessage", () => {
it("returns multiple validation messages", () => { it("returns multiple validation messages", () => {
@ -63,14 +63,14 @@ describe("getValidationErrorMessage", () => {
), ),
).toEqual( ).toEqual(
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`, `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", () => { it("non-API error returns empty validation message", () => {
expect( expect(
getValidationErrorMessage(new Error("Invalid user search query.")), getValidationErrorMessage(new Error("Invalid user search query.")),
).toEqual("") ).toEqual("");
}) });
it("no validations field returns empty validation message", () => { it("no validations field returns empty validation message", () => {
expect( expect(
@ -80,6 +80,6 @@ describe("getValidationErrorMessage", () => {
detail: `Query element "role:a:e" can only contain 1 ':'`, 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 = { const Language = {
errorsByCode: { errorsByCode: {
defaultErrorCode: "Invalid value", defaultErrorCode: "Invalid value",
}, },
} };
export interface FieldError { export interface FieldError {
field: string field: string;
detail: string detail: string;
} }
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]> export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>;
export interface ApiErrorResponse { export interface ApiErrorResponse {
message: string message: string;
detail?: string detail?: string;
validations?: FieldError[] validations?: FieldError[];
} }
export type ApiError = AxiosError<ApiErrorResponse> & { export type ApiError = AxiosError<ApiErrorResponse> & {
response: AxiosResponse<ApiErrorResponse> response: AxiosResponse<ApiErrorResponse>;
} };
export const isApiError = (err: unknown): err is ApiError => { 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 => 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 => { export const isApiValidationError = (error: unknown): error is ApiError => {
return isApiError(error) && hasApiFieldErrors(error) return isApiError(error) && hasApiFieldErrors(error);
} };
export const hasError = (error: unknown) => export const hasError = (error: unknown) =>
error !== undefined && error !== null error !== undefined && error !== null;
export const mapApiErrorToFieldErrors = ( export const mapApiErrorToFieldErrors = (
apiErrorResponse: ApiErrorResponse, apiErrorResponse: ApiErrorResponse,
): FieldErrors => { ): FieldErrors => {
const result: FieldErrors = {} const result: FieldErrors = {};
if (apiErrorResponse.validations) { if (apiErrorResponse.validations) {
for (const error of apiErrorResponse.validations) { for (const error of apiErrorResponse.validations) {
result[error.field] = 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.response.data.message
: error instanceof Error : error instanceof Error
? error.message ? error.message
: defaultMessage : defaultMessage;
/** /**
* *
@ -78,13 +78,13 @@ export const getValidationErrorMessage = (error: unknown): string => {
const validationErrors = const validationErrors =
isApiError(error) && error.response.data.validations isApiError(error) && error.response.data.validations
? 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 => export const getErrorDetail = (error: unknown): string | undefined | null =>
isApiError(error) isApiError(error)
? error.response.data.detail ? error.response.data.detail
: error instanceof Error : error instanceof Error
? `Please check the developer console for more details.` ? `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 { export interface UserAgent {
readonly browser: string readonly browser: string;
readonly device: string readonly device: string;
readonly ip_address: string readonly ip_address: string;
readonly os: string readonly os: string;
} }
export interface ReconnectingPTYRequest { export interface ReconnectingPTYRequest {
readonly data?: string readonly data?: string;
readonly height?: number readonly height?: number;
readonly width?: 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 { export interface DeploymentGroup {
readonly name: string readonly name: string;
readonly parent?: DeploymentGroup readonly parent?: DeploymentGroup;
readonly description: string readonly description: string;
readonly children: DeploymentGroup[] readonly children: DeploymentGroup[];
} }
export interface DeploymentOption { export interface DeploymentOption {
readonly name: string readonly name: string;
readonly description: string readonly description: string;
readonly flag: string readonly flag: string;
readonly flag_shorthand: string readonly flag_shorthand: string;
readonly value: unknown readonly value: unknown;
readonly hidden: boolean readonly hidden: boolean;
readonly group?: DeploymentGroup readonly group?: DeploymentGroup;
} }
export type DeploymentConfig = { export type DeploymentConfig = {
readonly config: DeploymentValues readonly config: DeploymentValues;
readonly options: DeploymentOption[] 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 CssBaseline from "@mui/material/CssBaseline";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "components/AuthProvider/AuthProvider" import { AuthProvider } from "components/AuthProvider/AuthProvider";
import { FC, PropsWithChildren } from "react" import { FC, PropsWithChildren } from "react";
import { HelmetProvider } from "react-helmet-async" import { HelmetProvider } from "react-helmet-async";
import { AppRouter } from "./AppRouter" import { AppRouter } from "./AppRouter";
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary" import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar" import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
import { dark } from "./theme" import { dark } from "./theme";
import "./theme/globalFonts" import "./theme/globalFonts";
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles" import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -19,7 +19,7 @@ const queryClient = new QueryClient({
networkMode: "offlineFirst", networkMode: "offlineFirst",
}, },
}, },
}) });
export const AppProviders: FC<PropsWithChildren> = ({ children }) => { export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
return ( return (
@ -38,13 +38,13 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
</ThemeProvider> </ThemeProvider>
</StyledEngineProvider> </StyledEngineProvider>
</HelmetProvider> </HelmetProvider>
) );
} };
export const App: FC = () => { export const App: FC = () => {
return ( return (
<AppProviders> <AppProviders>
<AppRouter /> <AppRouter />
</AppProviders> </AppProviders>
) );
} };

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +1,36 @@
import { useActor, useInterpret } from "@xstate/react" import { useActor, useInterpret } from "@xstate/react";
import { createContext, FC, PropsWithChildren, useContext } from "react" import { createContext, FC, PropsWithChildren, useContext } from "react";
import { authMachine } from "xServices/auth/authXService" import { authMachine } from "xServices/auth/authXService";
import { ActorRefFrom } from "xstate" import { ActorRefFrom } from "xstate";
interface AuthContextValue { 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 }) => { export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const authService = useInterpret(authMachine) const authService = useInterpret(authMachine);
return ( return (
<AuthContext.Provider value={{ authService }}> <AuthContext.Provider value={{ authService }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) );
} };
type UseAuthReturnType = ReturnType< type UseAuthReturnType = ReturnType<
typeof useActor<AuthContextValue["authService"]> typeof useActor<AuthContextValue["authService"]>
> >;
export const useAuth = (): UseAuthReturnType => { export const useAuth = (): UseAuthReturnType => {
const context = useContext(AuthContext) const context = useContext(AuthContext);
if (!context) { 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 { Story } from "@storybook/react";
import { Avatar, AvatarIcon, AvatarProps } from "./Avatar" import { Avatar, AvatarIcon, AvatarProps } from "./Avatar";
import PauseIcon from "@mui/icons-material/PauseOutlined" import PauseIcon from "@mui/icons-material/PauseOutlined";
export default { export default {
title: "components/Avatar", title: "components/Avatar",
component: 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 = { Letter.args = {
children: "Coder", children: "Coder",
} };
export const LetterXL = Template.bind({}) export const LetterXL = Template.bind({});
LetterXL.args = { LetterXL.args = {
children: "Coder", children: "Coder",
size: "xl", size: "xl",
} };
export const LetterDarken = Template.bind({}) export const LetterDarken = Template.bind({});
LetterDarken.args = { LetterDarken.args = {
children: "Coder", children: "Coder",
colorScheme: "darken", colorScheme: "darken",
} };
export const Image = Template.bind({}) export const Image = Template.bind({});
Image.args = { Image.args = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
} };
export const ImageXL = Template.bind({}) export const ImageXL = Template.bind({});
ImageXL.args = { ImageXL.args = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
size: "xl", size: "xl",
} };
export const MuiIcon = Template.bind({}) export const MuiIcon = Template.bind({});
MuiIcon.args = { MuiIcon.args = {
children: <PauseIcon />, children: <PauseIcon />,
} };
export const MuiIconDarken = Template.bind({}) export const MuiIconDarken = Template.bind({});
MuiIconDarken.args = { MuiIconDarken.args = {
children: <PauseIcon />, children: <PauseIcon />,
colorScheme: "darken", colorScheme: "darken",
} };
export const MuiIconXL = Template.bind({}) export const MuiIconXL = Template.bind({});
MuiIconXL.args = { MuiIconXL.args = {
children: <PauseIcon />, children: <PauseIcon />,
size: "xl", size: "xl",
} };
export const AvatarIconDarken = Template.bind({}) export const AvatarIconDarken = Template.bind({});
AvatarIconDarken.args = { AvatarIconDarken.args = {
children: <AvatarIcon src="/icon/database.svg" />, children: <AvatarIcon src="/icon/database.svg" />,
colorScheme: "darken", colorScheme: "darken",
} };

View File

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

View File

@ -1,4 +1,4 @@
import { firstLetter } from "./firstLetter" import { firstLetter } from "./firstLetter";
describe("first-letter", () => { describe("first-letter", () => {
it.each<[string, string]>([ it.each<[string, string]>([
@ -6,6 +6,6 @@ describe("first-letter", () => {
["User", "U"], ["User", "U"],
["test", "T"], ["test", "T"],
])(`firstLetter(%p) returns %p`, (input, expected) => { ])(`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 => { export const firstLetter = (str: string): string => {
if (str.length > 0) { 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 { Story } from "@storybook/react";
import { AvatarData, AvatarDataProps } from "./AvatarData" import { AvatarData, AvatarDataProps } from "./AvatarData";
export default { export default {
title: "components/AvatarData", title: "components/AvatarData",
component: AvatarData, component: AvatarData,
} };
const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => ( const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => (
<AvatarData {...args} /> <AvatarData {...args} />
) );
export const Example = Template.bind({}) export const Example = Template.bind({});
Example.args = { Example.args = {
title: "coder", title: "coder",
subtitle: "coder@coder.com", subtitle: "coder@coder.com",
} };
export const WithImage = Template.bind({}) export const WithImage = Template.bind({});
WithImage.args = { WithImage.args = {
title: "coder", title: "coder",
subtitle: "coder@coder.com", subtitle: "coder@coder.com",
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react";
import { render } from "../../testHelpers/renderHelpers" import { render } from "../../testHelpers/renderHelpers";
import { CodeExample } from "./CodeExample" import { CodeExample } from "./CodeExample";
describe("CodeExample", () => { describe("CodeExample", () => {
it("renders code", async () => { it("renders code", async () => {
// When // When
render(<CodeExample code="echo hello" />) render(<CodeExample code="echo hello" />);
// Then // Then
// Both lines should be rendered // 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 { makeStyles } from "@mui/styles";
import { FC } from "react" import { FC } from "react";
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants";
import { combineClasses } from "../../utils/combineClasses" import { combineClasses } from "../../utils/combineClasses";
import { CopyButton } from "../CopyButton/CopyButton" import { CopyButton } from "../CopyButton/CopyButton";
import { Theme } from "@mui/material/styles" import { Theme } from "@mui/material/styles";
export interface CodeExampleProps { export interface CodeExampleProps {
code: string code: string;
className?: string className?: string;
buttonClassName?: string buttonClassName?: string;
tooltipTitle?: string tooltipTitle?: string;
inline?: boolean inline?: boolean;
password?: boolean password?: boolean;
} }
/** /**
@ -24,7 +24,7 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
tooltipTitle, tooltipTitle,
inline, inline,
}) => { }) => {
const styles = useStyles({ inline: inline }) const styles = useStyles({ inline: inline });
return ( return (
<div className={combineClasses([styles.root, className])}> <div className={combineClasses([styles.root, className])}>
@ -35,12 +35,12 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
buttonClassName={buttonClassName} buttonClassName={buttonClassName}
/> />
</div> </div>
) );
} };
interface styleProps { interface styleProps {
inline?: boolean inline?: boolean;
password?: boolean password?: boolean;
} }
const useStyles = makeStyles<Theme, styleProps>((theme) => ({ const useStyles = makeStyles<Theme, styleProps>((theme) => ({
@ -65,4 +65,4 @@ const useStyles = makeStyles<Theme, styleProps>((theme) => ({
wordBreak: "break-all", wordBreak: "break-all",
"-webkit-text-security": (props) => (props.password ? "disc" : undefined), "-webkit-text-security": (props) => (props.password ? "disc" : undefined),
}, },
})) }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,16 @@
import { renderWithAuth } from "testHelpers/renderHelpers" import { renderWithAuth } from "testHelpers/renderHelpers";
import { DashboardLayout } from "./DashboardLayout" import { DashboardLayout } from "./DashboardLayout";
import * as API from "api/api" import * as API from "api/api";
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react";
test("Show the new Coder version notification", async () => { test("Show the new Coder version notification", async () => {
jest.spyOn(API, "getUpdateCheck").mockResolvedValue({ jest.spyOn(API, "getUpdateCheck").mockResolvedValue({
current: false, current: false,
version: "v0.12.9", version: "v0.12.9",
url: "https://github.com/coder/coder/releases/tag/v0.12.9", url: "https://github.com/coder/coder/releases/tag/v0.12.9",
}) });
renderWithAuth(<DashboardLayout />, { renderWithAuth(<DashboardLayout />, {
children: [{ element: <h1>Test page</h1> }], 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 { makeStyles } from "@mui/styles";
import { useMachine } from "@xstate/react" import { useMachine } from "@xstate/react";
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner" import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner" import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner";
import { Loader } from "components/Loader/Loader" import { Loader } from "components/Loader/Loader";
import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner" import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner";
import { usePermissions } from "hooks/usePermissions" import { usePermissions } from "hooks/usePermissions";
import { FC, Suspense } from "react" import { FC, Suspense } from "react";
import { Outlet } from "react-router-dom" import { Outlet } from "react-router-dom";
import { dashboardContentBottomPadding } from "theme/constants" import { dashboardContentBottomPadding } from "theme/constants";
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService" import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService";
import { Navbar } from "./Navbar/Navbar" import { Navbar } from "./Navbar/Navbar";
import Snackbar from "@mui/material/Snackbar" import Snackbar from "@mui/material/Snackbar";
import Link from "@mui/material/Link" import Link from "@mui/material/Link";
import Box, { BoxProps } from "@mui/material/Box" import Box, { BoxProps } from "@mui/material/Box";
import InfoOutlined from "@mui/icons-material/InfoOutlined" import InfoOutlined from "@mui/icons-material/InfoOutlined";
import Button from "@mui/material/Button" import Button from "@mui/material/Button";
import { docs } from "utils/docs" import { docs } from "utils/docs";
import { HealthBanner } from "./HealthBanner" import { HealthBanner } from "./HealthBanner";
export const DashboardLayout: FC = () => { export const DashboardLayout: FC = () => {
const styles = useStyles() const styles = useStyles();
const permissions = usePermissions() const permissions = usePermissions();
const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, { const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, {
context: { context: {
permissions, permissions,
}, },
}) });
const { updateCheck } = updateCheckState.context const { updateCheck } = updateCheckState.context;
const canViewDeployment = Boolean(permissions.viewDeploymentValues) const canViewDeployment = Boolean(permissions.viewDeploymentValues);
return ( return (
<> <>
@ -99,8 +99,8 @@ export const DashboardLayout: FC = () => {
/> />
</div> </div>
</> </>
) );
} };
export const DashboardFullPage = (props: BoxProps) => { export const DashboardFullPage = (props: BoxProps) => {
return ( return (
@ -116,8 +116,8 @@ export const DashboardFullPage = (props: BoxProps) => {
minHeight: "100%", minHeight: "100%",
}} }}
/> />
) );
} };
const useStyles = makeStyles({ const useStyles = makeStyles({
site: { site: {
@ -131,4 +131,4 @@ const useStyles = makeStyles({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}, },
}) });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,24 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react";
import { import {
MockBuildInfo, MockBuildInfo,
MockSupportLinks, MockSupportLinks,
MockUser, MockUser,
} from "../../../../../testHelpers/entities" } from "../../../../../testHelpers/entities";
import { render } from "../../../../../testHelpers/renderHelpers" import { render } from "../../../../../testHelpers/renderHelpers";
import { Language, UserDropdownContent } from "./UserDropdownContent" import { Language, UserDropdownContent } from "./UserDropdownContent";
describe("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 // REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
beforeEach(() => { beforeEach(() => {
process.env = { ...env } process.env = { ...env };
}) });
// REMARK: restoring process.env // REMARK: restoring process.env
afterEach(() => { afterEach(() => {
process.env = env process.env = env;
}) });
it("displays the menu items", () => { it("displays the menu items", () => {
render( render(
@ -29,21 +29,21 @@ describe("UserDropdownContent", () => {
onSignOut={jest.fn()} onSignOut={jest.fn()}
onPopoverClose={jest.fn()} onPopoverClose={jest.fn()}
/>, />,
) );
expect(screen.getByText(Language.accountLabel)).toBeDefined() expect(screen.getByText(Language.accountLabel)).toBeDefined();
expect(screen.getByText(Language.signOutLabel)).toBeDefined() expect(screen.getByText(Language.signOutLabel)).toBeDefined();
expect(screen.getByText(Language.copyrightText)).toBeDefined() expect(screen.getByText(Language.copyrightText)).toBeDefined();
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined() expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined() expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined();
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined() expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined();
expect( expect(
screen.getByText(MockSupportLinks[2].name).closest("a"), screen.getByText(MockSupportLinks[2].name).closest("a"),
).toHaveAttribute( ).toHaveAttribute(
"href", "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)", "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", () => { it("has the correct link for the account item", () => {
render( render(
@ -52,26 +52,26 @@ describe("UserDropdownContent", () => {
onSignOut={jest.fn()} onSignOut={jest.fn()}
onPopoverClose={jest.fn()} onPopoverClose={jest.fn()}
/>, />,
) );
const link = screen.getByText(Language.accountLabel).closest("a") const link = screen.getByText(Language.accountLabel).closest("a");
if (!link) { 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", () => { it("calls the onSignOut function", () => {
const onSignOut = jest.fn() const onSignOut = jest.fn();
render( render(
<UserDropdownContent <UserDropdownContent
user={MockUser} user={MockUser}
onSignOut={onSignOut} onSignOut={onSignOut}
onPopoverClose={jest.fn()} onPopoverClose={jest.fn()}
/>, />,
) );
screen.getByText(Language.signOutLabel).click() screen.getByText(Language.signOutLabel).click();
expect(onSignOut).toBeCalledTimes(1) expect(onSignOut).toBeCalledTimes(1);
}) });
}) });

View File

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

View File

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

View File

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

View File

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

View File

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

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