mirror of https://github.com/coder/coder.git
chore: format code with semicolons when using prettier (#9555)
This commit is contained in:
parent
bef38b8413
commit
988c9af015
|
@ -1,9 +1,8 @@
|
|||
# This config file is used in conjunction with `.editorconfig` to specify
|
||||
# formatting for prettier-supported files. See `.editorconfig` and
|
||||
# `site/.editorconfig`for whitespace formatting options.
|
||||
# `site/.editorconfig` for whitespace formatting options.
|
||||
printWidth: 80
|
||||
proseWrap: always
|
||||
semi: false
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
tabWidth: 2
|
||||
|
|
|
@ -128,9 +128,9 @@ export const getAgentListeningPorts = async (
|
|||
): Promise<TypesGen.ListeningPortsResponse> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/workspaceagents/${agentID}/listening-ports`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
```
|
||||
|
||||
Sometimes, a FE operation can have multiple API calls so it is ok to wrap it as
|
||||
|
@ -140,9 +140,9 @@ a single function.
|
|||
export const updateWorkspaceVersion = async (
|
||||
workspace: TypesGen.Workspace,
|
||||
): Promise<TypesGen.WorkspaceBuild> => {
|
||||
const template = await getTemplate(workspace.template_id)
|
||||
return startWorkspace(workspace.id, template.active_version_id)
|
||||
}
|
||||
const template = await getTemplate(workspace.template_id);
|
||||
return startWorkspace(workspace.id, template.active_version_id);
|
||||
};
|
||||
```
|
||||
|
||||
If you need more granular errors or control, you may should consider keep them
|
||||
|
@ -242,14 +242,14 @@ instead of using `screen.getByRole("button")` directly we could do
|
|||
slow.
|
||||
|
||||
```tsx
|
||||
user.click(screen.getByRole("button"))
|
||||
user.click(screen.getByRole("button"));
|
||||
```
|
||||
|
||||
✅ Better. We can limit the number of elements we are querying.
|
||||
|
||||
```tsx
|
||||
const form = screen.getByTestId("form")
|
||||
user.click(within(form).getByRole("button"))
|
||||
const form = screen.getByTestId("form");
|
||||
user.click(within(form).getByRole("button"));
|
||||
```
|
||||
|
||||
#### `jest.spyOn` with the API is not working
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig;
|
||||
|
|
|
@ -24,57 +24,57 @@ import {
|
|||
Tr,
|
||||
UnorderedList,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import fm from "front-matter"
|
||||
import { readFileSync } from "fs"
|
||||
import _ from "lodash"
|
||||
import { GetStaticPaths, GetStaticProps, NextPage } from "next"
|
||||
import Head from "next/head"
|
||||
import NextLink from "next/link"
|
||||
import { useRouter } from "next/router"
|
||||
import path from "path"
|
||||
import { MdMenu } from "react-icons/md"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import remarkGfm from "remark-gfm"
|
||||
} from "@chakra-ui/react";
|
||||
import fm from "front-matter";
|
||||
import { readFileSync } from "fs";
|
||||
import _ from "lodash";
|
||||
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import NextLink from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import path from "path";
|
||||
import { MdMenu } from "react-icons/md";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type FilePath = string
|
||||
type UrlPath = string
|
||||
type FilePath = string;
|
||||
type UrlPath = string;
|
||||
type Route = {
|
||||
path: FilePath
|
||||
title: string
|
||||
description?: string
|
||||
children?: Route[]
|
||||
}
|
||||
type Manifest = { versions: string[]; routes: Route[] }
|
||||
type NavItem = { title: string; path: UrlPath; children?: NavItem[] }
|
||||
type Nav = NavItem[]
|
||||
path: FilePath;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: Route[];
|
||||
};
|
||||
type Manifest = { versions: string[]; routes: Route[] };
|
||||
type NavItem = { title: string; path: UrlPath; children?: NavItem[] };
|
||||
type Nav = NavItem[];
|
||||
|
||||
const readContentFile = (filePath: string) => {
|
||||
const baseDir = process.cwd()
|
||||
const docsPath = path.join(baseDir, "..", "docs")
|
||||
return readFileSync(path.join(docsPath, filePath), { encoding: "utf-8" })
|
||||
}
|
||||
const baseDir = process.cwd();
|
||||
const docsPath = path.join(baseDir, "..", "docs");
|
||||
return readFileSync(path.join(docsPath, filePath), { encoding: "utf-8" });
|
||||
};
|
||||
|
||||
const removeTrailingSlash = (path: string) => path.replace(/\/+$/, "")
|
||||
const removeTrailingSlash = (path: string) => path.replace(/\/+$/, "");
|
||||
|
||||
const removeMkdExtension = (path: string) => path.replace(/\.md/g, "")
|
||||
const removeMkdExtension = (path: string) => path.replace(/\.md/g, "");
|
||||
|
||||
const removeIndexFilename = (path: string) => {
|
||||
if (path.endsWith("index")) {
|
||||
path = path.replace("index", "")
|
||||
path = path.replace("index", "");
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const removeREADMEName = (path: string) => {
|
||||
if (path.startsWith("README")) {
|
||||
path = path.replace("README", "")
|
||||
path = path.replace("README", "");
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
// transformLinkUri converts the links in the markdown file to
|
||||
// href html links. All index page routes are the directory name, and all
|
||||
|
@ -87,142 +87,142 @@ const removeREADMEName = (path: string) => {
|
|||
// file.md -> ../file-next-to-file = ../file-next-to-file
|
||||
const transformLinkUriSource = (sourceFile: string) => {
|
||||
return (href = "") => {
|
||||
const isExternal = href.startsWith("http") || href.startsWith("https")
|
||||
const isExternal = href.startsWith("http") || href.startsWith("https");
|
||||
if (!isExternal) {
|
||||
// Remove .md form the path
|
||||
href = removeMkdExtension(href)
|
||||
href = removeMkdExtension(href);
|
||||
|
||||
// Add the extra '..' if not an index file.
|
||||
sourceFile = removeMkdExtension(sourceFile)
|
||||
sourceFile = removeMkdExtension(sourceFile);
|
||||
if (!sourceFile.endsWith("index")) {
|
||||
href = "../" + href
|
||||
href = "../" + href;
|
||||
}
|
||||
|
||||
// Remove the index path
|
||||
href = removeIndexFilename(href)
|
||||
href = removeREADMEName(href)
|
||||
href = removeIndexFilename(href);
|
||||
href = removeREADMEName(href);
|
||||
}
|
||||
return href
|
||||
}
|
||||
}
|
||||
return href;
|
||||
};
|
||||
};
|
||||
|
||||
const transformFilePathToUrlPath = (filePath: string) => {
|
||||
// Remove markdown extension
|
||||
let urlPath = removeMkdExtension(filePath)
|
||||
let urlPath = removeMkdExtension(filePath);
|
||||
|
||||
// Remove relative path
|
||||
if (urlPath.startsWith("./")) {
|
||||
urlPath = urlPath.replace("./", "")
|
||||
urlPath = urlPath.replace("./", "");
|
||||
}
|
||||
|
||||
// Remove index from the root file
|
||||
urlPath = removeIndexFilename(urlPath)
|
||||
urlPath = removeREADMEName(urlPath)
|
||||
urlPath = removeIndexFilename(urlPath);
|
||||
urlPath = removeREADMEName(urlPath);
|
||||
|
||||
// Remove trailing slash
|
||||
if (urlPath.endsWith("/")) {
|
||||
urlPath = removeTrailingSlash(urlPath)
|
||||
urlPath = removeTrailingSlash(urlPath);
|
||||
}
|
||||
|
||||
return urlPath
|
||||
}
|
||||
return urlPath;
|
||||
};
|
||||
|
||||
const mapRoutes = (manifest: Manifest): Record<UrlPath, Route> => {
|
||||
const paths: Record<UrlPath, Route> = {}
|
||||
const paths: Record<UrlPath, Route> = {};
|
||||
|
||||
const addPaths = (routes: Route[]) => {
|
||||
for (const route of routes) {
|
||||
paths[transformFilePathToUrlPath(route.path)] = route
|
||||
paths[transformFilePathToUrlPath(route.path)] = route;
|
||||
|
||||
if (route.children) {
|
||||
addPaths(route.children)
|
||||
addPaths(route.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addPaths(manifest.routes)
|
||||
addPaths(manifest.routes);
|
||||
|
||||
return paths
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
let manifest: Manifest | undefined
|
||||
let manifest: Manifest | undefined;
|
||||
|
||||
const getManifest = () => {
|
||||
if (manifest) {
|
||||
return manifest
|
||||
return manifest;
|
||||
}
|
||||
|
||||
const manifestContent = readContentFile("manifest.json")
|
||||
manifest = JSON.parse(manifestContent) as Manifest
|
||||
return manifest
|
||||
}
|
||||
const manifestContent = readContentFile("manifest.json");
|
||||
manifest = JSON.parse(manifestContent) as Manifest;
|
||||
return manifest;
|
||||
};
|
||||
|
||||
let navigation: Nav | undefined
|
||||
let navigation: Nav | undefined;
|
||||
|
||||
const getNavigation = (manifest: Manifest): Nav => {
|
||||
if (navigation) {
|
||||
return navigation
|
||||
return navigation;
|
||||
}
|
||||
|
||||
const getNavItem = (route: Route, parentPath?: UrlPath): NavItem => {
|
||||
const path = parentPath
|
||||
? `${parentPath}/${transformFilePathToUrlPath(route.path)}`
|
||||
: transformFilePathToUrlPath(route.path)
|
||||
: transformFilePathToUrlPath(route.path);
|
||||
const navItem: NavItem = {
|
||||
title: route.title,
|
||||
path,
|
||||
}
|
||||
};
|
||||
|
||||
if (route.children) {
|
||||
navItem.children = []
|
||||
navItem.children = [];
|
||||
|
||||
for (const childRoute of route.children) {
|
||||
navItem.children.push(getNavItem(childRoute))
|
||||
navItem.children.push(getNavItem(childRoute));
|
||||
}
|
||||
}
|
||||
|
||||
return navItem
|
||||
}
|
||||
return navItem;
|
||||
};
|
||||
|
||||
navigation = []
|
||||
navigation = [];
|
||||
|
||||
for (const route of manifest.routes) {
|
||||
navigation.push(getNavItem(route))
|
||||
navigation.push(getNavItem(route));
|
||||
}
|
||||
|
||||
return navigation
|
||||
}
|
||||
return navigation;
|
||||
};
|
||||
|
||||
const removeHtmlComments = (string: string) => {
|
||||
return string.replace(/<!--[\s\S]*?-->/g, "")
|
||||
}
|
||||
return string.replace(/<!--[\s\S]*?-->/g, "");
|
||||
};
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
const manifest = getManifest()
|
||||
const routes = mapRoutes(manifest)
|
||||
const manifest = getManifest();
|
||||
const routes = mapRoutes(manifest);
|
||||
const paths = Object.keys(routes).map((urlPath) => ({
|
||||
params: { slug: urlPath.split("/") },
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = (context) => {
|
||||
// When it is home page, the slug is undefined because there is no url path
|
||||
// so we make it an empty string to work good with the mapRoutes
|
||||
const { slug = [""] } = context.params as { slug: string[] }
|
||||
const manifest = getManifest()
|
||||
const routes = mapRoutes(manifest)
|
||||
const urlPath = slug.join("/")
|
||||
const route = routes[urlPath]
|
||||
const { body } = fm(readContentFile(route.path))
|
||||
const { slug = [""] } = context.params as { slug: string[] };
|
||||
const manifest = getManifest();
|
||||
const routes = mapRoutes(manifest);
|
||||
const urlPath = slug.join("/");
|
||||
const route = routes[urlPath];
|
||||
const { body } = fm(readContentFile(route.path));
|
||||
// Serialize MDX to support custom components
|
||||
const content = removeHtmlComments(body)
|
||||
const navigation = getNavigation(manifest)
|
||||
const version = manifest.versions[0]
|
||||
const content = removeHtmlComments(body);
|
||||
const navigation = getNavigation(manifest);
|
||||
const version = manifest.versions[0];
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
@ -231,25 +231,26 @@ export const getStaticProps: GetStaticProps = (context) => {
|
|||
route,
|
||||
version,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const SidebarNavItem: React.FC<{ item: NavItem; nav: Nav }> = ({
|
||||
item,
|
||||
nav,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
let isActive = router.asPath.startsWith(`/${item.path}`)
|
||||
const router = useRouter();
|
||||
let isActive = router.asPath.startsWith(`/${item.path}`);
|
||||
|
||||
// Special case to handle the home path
|
||||
if (item.path === "") {
|
||||
isActive = router.asPath === "/"
|
||||
isActive = router.asPath === "/";
|
||||
|
||||
// Special case to handle the home path children
|
||||
const homeNav = nav.find((navItem) => navItem.path === "") as NavItem
|
||||
const homeNavPaths = homeNav.children?.map((item) => `/${item.path}/`) ?? []
|
||||
const homeNav = nav.find((navItem) => navItem.path === "") as NavItem;
|
||||
const homeNavPaths =
|
||||
homeNav.children?.map((item) => `/${item.path}/`) ?? [];
|
||||
if (homeNavPaths.includes(router.asPath)) {
|
||||
isActive = true
|
||||
isActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -280,8 +281,8 @@ const SidebarNavItem: React.FC<{ item: NavItem; nav: Nav }> = ({
|
|||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarNav: React.FC<{ nav: Nav; version: string } & GridProps> = ({
|
||||
nav,
|
||||
|
@ -312,14 +313,14 @@ const SidebarNav: React.FC<{ nav: Nav; version: string } & GridProps> = ({
|
|||
<SidebarNavItem key={navItem.path} item={navItem} nav={nav} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNavbar: React.FC<{ nav: Nav; version: string }> = ({
|
||||
nav,
|
||||
version,
|
||||
}) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -347,26 +348,26 @@ const MobileNavbar: React.FC<{ nav: Nav; version: string }> = ({
|
|||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const slugifyTitle = (title: string) => {
|
||||
return _.kebabCase(title.toLowerCase())
|
||||
}
|
||||
return _.kebabCase(title.toLowerCase());
|
||||
};
|
||||
|
||||
const getImageUrl = (src: string | undefined) => {
|
||||
if (src === undefined) {
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
const assetPath = src.split("images/")[1]
|
||||
return `/images/${assetPath}`
|
||||
}
|
||||
const assetPath = src.split("images/")[1];
|
||||
return `/images/${assetPath}`;
|
||||
};
|
||||
|
||||
const DocsPage: NextPage<{
|
||||
content: string
|
||||
navigation: Nav
|
||||
route: Route
|
||||
version: string
|
||||
content: string;
|
||||
navigation: Nav;
|
||||
route: Route;
|
||||
version: string;
|
||||
}> = ({ content, navigation, route, version }) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -486,7 +487,7 @@ const DocsPage: NextPage<{
|
|||
),
|
||||
a: ({ children, href = "" }) => {
|
||||
const isExternal =
|
||||
href.startsWith("http") || href.startsWith("https")
|
||||
href.startsWith("http") || href.startsWith("https");
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
@ -497,7 +498,7 @@ const DocsPage: NextPage<{
|
|||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
},
|
||||
code: ({ node, ...props }) => (
|
||||
<Code {...props} bgColor="gray.100" />
|
||||
|
@ -538,7 +539,7 @@ const DocsPage: NextPage<{
|
|||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsPage
|
||||
export default DocsPage;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ChakraProvider, extendTheme } from "@chakra-ui/react"
|
||||
import type { AppProps } from "next/app"
|
||||
import Head from "next/head"
|
||||
import { ChakraProvider, extendTheme } from "@chakra-ui/react";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
|
||||
const theme = extendTheme({
|
||||
styles: {
|
||||
|
@ -10,7 +10,7 @@ const theme = extendTheme({
|
|||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
return (
|
||||
|
@ -23,7 +23,7 @@ const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
|
|||
<Component {...pageProps} />
|
||||
</ChakraProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default MyApp
|
||||
export default MyApp;
|
||||
|
|
|
@ -2,10 +2,9 @@
|
|||
|
||||
# This config file is used in conjunction with `.editorconfig` to specify
|
||||
# formatting for prettier-supported files. See `.editorconfig` and
|
||||
# `site/.editorconfig`for whitespace formatting options.
|
||||
# `site/.editorconfig` for whitespace formatting options.
|
||||
printWidth: 80
|
||||
proseWrap: always
|
||||
semi: false
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
tabWidth: 2
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import turbosnap from "vite-plugin-turbosnap"
|
||||
import { mergeConfig } from "vite"
|
||||
import turbosnap from "vite-plugin-turbosnap";
|
||||
import { mergeConfig } from "vite";
|
||||
|
||||
module.exports = {
|
||||
stories: ["../src/**/*.stories.tsx"],
|
||||
|
@ -15,7 +15,7 @@ module.exports = {
|
|||
options: {},
|
||||
},
|
||||
async viteFinal(config, { configType }) {
|
||||
config.plugins = config.plugins || []
|
||||
config.plugins = config.plugins || [];
|
||||
// return the customized config
|
||||
if (configType === "PRODUCTION") {
|
||||
// ignore @ts-ignore because it's not in the vite types yet
|
||||
|
@ -23,8 +23,8 @@ module.exports = {
|
|||
turbosnap({
|
||||
rootDir: config.root || "",
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
return config
|
||||
return config;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import CssBaseline from "@mui/material/CssBaseline"
|
||||
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
|
||||
import { withRouter } from "storybook-addon-react-router-v6"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
import { dark } from "../src/theme"
|
||||
import "../src/theme/globalFonts"
|
||||
import "../src/i18n"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
|
||||
import { withRouter } from "storybook-addon-react-router-v6";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { dark } from "../src/theme";
|
||||
import "../src/theme/globalFonts";
|
||||
import "../src/i18n";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
|
@ -22,16 +22,16 @@ export const decorators = [
|
|||
<HelmetProvider>
|
||||
<Story />
|
||||
</HelmetProvider>
|
||||
)
|
||||
);
|
||||
},
|
||||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
);
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const parameters = {
|
||||
actions: {
|
||||
|
@ -44,4 +44,4 @@ export const parameters = {
|
|||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
// Default port from the server
|
||||
export const defaultPort = 3000
|
||||
export const prometheusPort = 2114
|
||||
export const pprofPort = 6061
|
||||
export const defaultPort = 3000;
|
||||
export const prometheusPort = 2114;
|
||||
export const pprofPort = 6061;
|
||||
|
||||
// Credentials for the first user
|
||||
export const username = "admin"
|
||||
export const password = "SomeSecurePassword!"
|
||||
export const email = "admin@coder.com"
|
||||
export const username = "admin";
|
||||
export const password = "SomeSecurePassword!";
|
||||
export const email = "admin@coder.com";
|
||||
|
||||
export const gitAuth = {
|
||||
deviceProvider: "device",
|
||||
|
@ -22,4 +22,4 @@ export const gitAuth = {
|
|||
codePath: "/code",
|
||||
validatePath: "/validate",
|
||||
installationsPath: "/installations",
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { test, expect } from "@playwright/test"
|
||||
import * as constants from "./constants"
|
||||
import { STORAGE_STATE } from "./playwright.config"
|
||||
import { Language } from "../src/pages/CreateUserPage/CreateUserForm"
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as constants from "./constants";
|
||||
import { STORAGE_STATE } from "./playwright.config";
|
||||
import { Language } from "../src/pages/CreateUserPage/CreateUserForm";
|
||||
|
||||
test("create first user", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" })
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByLabel(Language.usernameLabel).fill(constants.username)
|
||||
await page.getByLabel(Language.emailLabel).fill(constants.email)
|
||||
await page.getByLabel(Language.passwordLabel).fill(constants.password)
|
||||
await page.getByTestId("trial").click()
|
||||
await page.getByTestId("create").click()
|
||||
await page.getByLabel(Language.usernameLabel).fill(constants.username);
|
||||
await page.getByLabel(Language.emailLabel).fill(constants.email);
|
||||
await page.getByLabel(Language.passwordLabel).fill(constants.password);
|
||||
await page.getByTestId("trial").click();
|
||||
await page.getByTestId("create").click();
|
||||
|
||||
await expect(page).toHaveURL("/workspaces")
|
||||
await expect(page).toHaveURL("/workspaces");
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE })
|
||||
})
|
||||
await page.context().storageState({ path: STORAGE_STATE });
|
||||
});
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { expect, Page } from "@playwright/test"
|
||||
import { ChildProcess, exec, spawn } from "child_process"
|
||||
import { randomUUID } from "crypto"
|
||||
import path from "path"
|
||||
import express from "express"
|
||||
import { TarWriter } from "utils/tar"
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import { ChildProcess, exec, spawn } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import path from "path";
|
||||
import express from "express";
|
||||
import { TarWriter } from "utils/tar";
|
||||
import {
|
||||
Agent,
|
||||
App,
|
||||
|
@ -14,13 +14,13 @@ import {
|
|||
ApplyComplete,
|
||||
Resource,
|
||||
RichParameter,
|
||||
} from "./provisionerGenerated"
|
||||
import { prometheusPort, pprofPort } from "./constants"
|
||||
import { port } from "./playwright.config"
|
||||
import * as ssh from "ssh2"
|
||||
import { Duplex } from "stream"
|
||||
import { WorkspaceBuildParameter } from "api/typesGenerated"
|
||||
import axios from "axios"
|
||||
} from "./provisionerGenerated";
|
||||
import { prometheusPort, pprofPort } from "./constants";
|
||||
import { port } from "./playwright.config";
|
||||
import * as ssh from "ssh2";
|
||||
import { Duplex } from "stream";
|
||||
import { WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import axios from "axios";
|
||||
|
||||
// createWorkspace creates a workspace for a template.
|
||||
// It does not wait for it to be running, but it does navigate to the page.
|
||||
|
@ -32,25 +32,25 @@ export const createWorkspace = async (
|
|||
): Promise<string> => {
|
||||
await page.goto("/templates/" + templateName + "/workspace", {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
await expect(page).toHaveURL("/templates/" + templateName + "/workspace")
|
||||
});
|
||||
await expect(page).toHaveURL("/templates/" + templateName + "/workspace");
|
||||
|
||||
const name = randomName()
|
||||
await page.getByLabel("name").fill(name)
|
||||
const name = randomName();
|
||||
await page.getByLabel("name").fill(name);
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters)
|
||||
await page.getByTestId("form-submit").click()
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("form-submit").click();
|
||||
|
||||
await expect(page).toHaveURL("/@admin/" + name)
|
||||
await expect(page).toHaveURL("/@admin/" + name);
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
)
|
||||
return name
|
||||
}
|
||||
);
|
||||
return name;
|
||||
};
|
||||
|
||||
export const verifyParameters = async (
|
||||
page: Page,
|
||||
|
@ -60,56 +60,56 @@ export const verifyParameters = async (
|
|||
) => {
|
||||
await page.goto("/@admin/" + workspaceName + "/settings/parameters", {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
});
|
||||
await expect(page).toHaveURL(
|
||||
"/@admin/" + workspaceName + "/settings/parameters",
|
||||
)
|
||||
);
|
||||
|
||||
for (const buildParameter of expectedBuildParameters) {
|
||||
const richParameter = richParameters.find(
|
||||
(richParam) => richParam.name === buildParameter.name,
|
||||
)
|
||||
);
|
||||
if (!richParameter) {
|
||||
throw new Error(
|
||||
"build parameter is expected to be present in rich parameter schema",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parameterLabel = await page.waitForSelector(
|
||||
"[data-testid='parameter-field-" + richParameter.name + "']",
|
||||
{ state: "visible" },
|
||||
)
|
||||
);
|
||||
|
||||
const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled"
|
||||
const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled";
|
||||
|
||||
if (richParameter.type === "bool") {
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked" +
|
||||
muiDisabled +
|
||||
" input",
|
||||
)
|
||||
const value = await parameterField.inputValue()
|
||||
expect(value).toEqual(buildParameter.value)
|
||||
);
|
||||
const value = await parameterField.inputValue();
|
||||
expect(value).toEqual(buildParameter.value);
|
||||
} else if (richParameter.options.length > 0) {
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked" +
|
||||
muiDisabled +
|
||||
" input",
|
||||
)
|
||||
const value = await parameterField.inputValue()
|
||||
expect(value).toEqual(buildParameter.value)
|
||||
);
|
||||
const value = await parameterField.inputValue();
|
||||
expect(value).toEqual(buildParameter.value);
|
||||
} else if (richParameter.type === "list(string)") {
|
||||
throw new Error("not implemented yet") // FIXME
|
||||
throw new Error("not implemented yet"); // FIXME
|
||||
} else {
|
||||
// text or number
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-text'] input" + muiDisabled,
|
||||
)
|
||||
const value = await parameterField.inputValue()
|
||||
expect(value).toEqual(buildParameter.value)
|
||||
);
|
||||
const value = await parameterField.inputValue();
|
||||
expect(value).toEqual(buildParameter.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// createTemplate navigates to the /templates/new page and uploads a template
|
||||
// with the resources provided in the responses argument.
|
||||
|
@ -120,24 +120,24 @@ export const createTemplate = async (
|
|||
// Required to have templates submit their provisioner type as echo!
|
||||
await page.addInitScript({
|
||||
content: "window.playwright = true",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/templates/new", { waitUntil: "domcontentloaded" })
|
||||
await expect(page).toHaveURL("/templates/new")
|
||||
await page.goto("/templates/new", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL("/templates/new");
|
||||
|
||||
await page.getByTestId("file-upload").setInputFiles({
|
||||
buffer: await createTemplateVersionTar(responses),
|
||||
mimeType: "application/x-tar",
|
||||
name: "template.tar",
|
||||
})
|
||||
const name = randomName()
|
||||
await page.getByLabel("Name *").fill(name)
|
||||
await page.getByTestId("form-submit").click()
|
||||
});
|
||||
const name = randomName();
|
||||
await page.getByLabel("Name *").fill(name);
|
||||
await page.getByTestId("form-submit").click();
|
||||
await expect(page).toHaveURL("/templates/" + name, {
|
||||
timeout: 30000,
|
||||
})
|
||||
return name
|
||||
}
|
||||
});
|
||||
return name;
|
||||
};
|
||||
|
||||
// sshIntoWorkspace spawns a Coder SSH process and a client connected to it.
|
||||
export const sshIntoWorkspace = async (
|
||||
|
@ -147,9 +147,9 @@ export const sshIntoWorkspace = async (
|
|||
binaryArgs: string[] = [],
|
||||
): Promise<ssh.Client> => {
|
||||
if (binaryPath === "go") {
|
||||
binaryArgs = ["run", coderMainPath()]
|
||||
binaryArgs = ["run", coderMainPath()];
|
||||
}
|
||||
const sessionToken = await findSessionToken(page)
|
||||
const sessionToken = await findSessionToken(page);
|
||||
return new Promise<ssh.Client>((resolve, reject) => {
|
||||
const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], {
|
||||
env: {
|
||||
|
@ -157,49 +157,49 @@ export const sshIntoWorkspace = async (
|
|||
CODER_SESSION_TOKEN: sessionToken,
|
||||
CODER_URL: "http://localhost:3000",
|
||||
},
|
||||
})
|
||||
cp.on("error", (err) => reject(err))
|
||||
});
|
||||
cp.on("error", (err) => reject(err));
|
||||
const proxyStream = new Duplex({
|
||||
read: (size) => {
|
||||
return cp.stdout.read(Math.min(size, cp.stdout.readableLength))
|
||||
return cp.stdout.read(Math.min(size, cp.stdout.readableLength));
|
||||
},
|
||||
write: cp.stdin.write.bind(cp.stdin),
|
||||
})
|
||||
});
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
cp.stderr.on("data", (data) => console.log(data.toString()))
|
||||
cp.stderr.on("data", (data) => console.log(data.toString()));
|
||||
cp.stdout.on("readable", (...args) => {
|
||||
proxyStream.emit("readable", ...args)
|
||||
proxyStream.emit("readable", ...args);
|
||||
if (cp.stdout.readableLength > 0) {
|
||||
proxyStream.emit("data", cp.stdout.read())
|
||||
proxyStream.emit("data", cp.stdout.read());
|
||||
}
|
||||
})
|
||||
const client = new ssh.Client()
|
||||
});
|
||||
const client = new ssh.Client();
|
||||
client.connect({
|
||||
sock: proxyStream,
|
||||
username: "coder",
|
||||
})
|
||||
client.on("error", (err) => reject(err))
|
||||
});
|
||||
client.on("error", (err) => reject(err));
|
||||
client.on("ready", () => {
|
||||
resolve(client)
|
||||
})
|
||||
})
|
||||
}
|
||||
resolve(client);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const stopWorkspace = async (page: Page, workspaceName: string) => {
|
||||
await page.goto("/@admin/" + workspaceName, {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName)
|
||||
});
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName);
|
||||
|
||||
await page.getByTestId("workspace-stop-button").click()
|
||||
await page.getByTestId("workspace-stop-button").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Stopped",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const buildWorkspaceWithParameters = async (
|
||||
page: Page,
|
||||
|
@ -210,15 +210,15 @@ export const buildWorkspaceWithParameters = async (
|
|||
) => {
|
||||
await page.goto("/@admin/" + workspaceName, {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName)
|
||||
});
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName);
|
||||
|
||||
await page.getByTestId("build-parameters-button").click()
|
||||
await page.getByTestId("build-parameters-button").click();
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters)
|
||||
await page.getByTestId("build-parameters-submit").click()
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("build-parameters-submit").click();
|
||||
if (confirm) {
|
||||
await page.getByTestId("confirm-button").click()
|
||||
await page.getByTestId("confirm-button").click();
|
||||
}
|
||||
|
||||
await page.waitForSelector(
|
||||
|
@ -226,8 +226,8 @@ export const buildWorkspaceWithParameters = async (
|
|||
{
|
||||
state: "visible",
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// startAgent runs the coder agent with the provided token.
|
||||
// It awaits the agent to be ready before returning.
|
||||
|
@ -235,8 +235,8 @@ export const startAgent = async (
|
|||
page: Page,
|
||||
token: string,
|
||||
): Promise<ChildProcess> => {
|
||||
return startAgentWithCommand(page, token, "go", "run", coderMainPath())
|
||||
}
|
||||
return startAgentWithCommand(page, token, "go", "run", coderMainPath());
|
||||
};
|
||||
|
||||
// downloadCoderVersion downloads the version provided into a temporary dir and
|
||||
// caches it so subsequent calls are fast.
|
||||
|
@ -244,23 +244,23 @@ export const downloadCoderVersion = async (
|
|||
version: string,
|
||||
): Promise<string> => {
|
||||
if (version.startsWith("v")) {
|
||||
version = version.slice(1)
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
const binaryName = "coder-e2e-" + version
|
||||
const tempDir = "/tmp/coder-e2e-cache"
|
||||
const binaryName = "coder-e2e-" + version;
|
||||
const tempDir = "/tmp/coder-e2e-cache";
|
||||
// The install script adds `./bin` automatically to the path :shrug:
|
||||
const binaryPath = path.join(tempDir, "bin", binaryName)
|
||||
const binaryPath = path.join(tempDir, "bin", binaryName);
|
||||
|
||||
const exists = await new Promise<boolean>((resolve) => {
|
||||
const cp = spawn(binaryPath, ["version"])
|
||||
const cp = spawn(binaryPath, ["version"]);
|
||||
cp.on("close", (code) => {
|
||||
resolve(code === 0)
|
||||
})
|
||||
cp.on("error", () => resolve(false))
|
||||
})
|
||||
resolve(code === 0);
|
||||
});
|
||||
cp.on("error", () => resolve(false));
|
||||
});
|
||||
if (exists) {
|
||||
return binaryPath
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Runs our public install script using our options to
|
||||
|
@ -294,19 +294,19 @@ export const downloadCoderVersion = async (
|
|||
XDG_CACHE_HOME: "/tmp/coder-e2e-cache",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line no-console -- Needed for debugging
|
||||
cp.stderr.on("data", (data) => console.log(data.toString()))
|
||||
cp.stderr.on("data", (data) => console.log(data.toString()));
|
||||
cp.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("curl failed with code " + code))
|
||||
reject(new Error("curl failed with code " + code));
|
||||
}
|
||||
})
|
||||
})
|
||||
return binaryPath
|
||||
}
|
||||
});
|
||||
});
|
||||
return binaryPath;
|
||||
};
|
||||
|
||||
export const startAgentWithCommand = async (
|
||||
page: Page,
|
||||
|
@ -322,23 +322,23 @@ export const startAgentWithCommand = async (
|
|||
CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + pprofPort,
|
||||
CODER_AGENT_PROMETHEUS_ADDRESS: "127.0.0.1:" + prometheusPort,
|
||||
},
|
||||
})
|
||||
});
|
||||
cp.stdout.on("data", (data: Buffer) => {
|
||||
// eslint-disable-next-line no-console -- Log agent activity
|
||||
console.log(
|
||||
`[agent] [stdout] [onData] ${data.toString().replace(/\n$/g, "")}`,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
cp.stderr.on("data", (data: Buffer) => {
|
||||
// eslint-disable-next-line no-console -- Log agent activity
|
||||
console.log(
|
||||
`[agent] [stderr] [onData] ${data.toString().replace(/\n$/g, "")}`,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.getByTestId("agent-status-ready").waitFor({ state: "visible" })
|
||||
return cp
|
||||
}
|
||||
await page.getByTestId("agent-status-ready").waitFor({ state: "visible" });
|
||||
return cp;
|
||||
};
|
||||
|
||||
export const stopAgent = async (cp: ChildProcess, goRun: boolean = true) => {
|
||||
// When the web server is started with `go run`, it spawns a child process with coder server.
|
||||
|
@ -346,31 +346,31 @@ export const stopAgent = async (cp: ChildProcess, goRun: boolean = true) => {
|
|||
// The command `kill` is used to terminate a web server started as a standalone binary.
|
||||
exec(goRun ? `pkill -P ${cp.pid}` : `kill ${cp.pid}`, (error) => {
|
||||
if (error) {
|
||||
throw new Error(`exec error: ${JSON.stringify(error)}`)
|
||||
throw new Error(`exec error: ${JSON.stringify(error)}`);
|
||||
}
|
||||
})
|
||||
await waitUntilUrlIsNotResponding("http://localhost:" + prometheusPort)
|
||||
}
|
||||
});
|
||||
await waitUntilUrlIsNotResponding("http://localhost:" + prometheusPort);
|
||||
};
|
||||
|
||||
const waitUntilUrlIsNotResponding = async (url: string) => {
|
||||
const maxRetries = 30
|
||||
const retryIntervalMs = 1000
|
||||
let retries = 0
|
||||
const maxRetries = 30;
|
||||
const retryIntervalMs = 1000;
|
||||
let retries = 0;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
await axios.get(url)
|
||||
await axios.get(url);
|
||||
} catch (error) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
retries++
|
||||
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs))
|
||||
retries++;
|
||||
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`URL ${url} is still responding after ${maxRetries * retryIntervalMs}ms`,
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const coderMainPath = (): string => {
|
||||
return path.join(
|
||||
|
@ -381,8 +381,8 @@ const coderMainPath = (): string => {
|
|||
"cmd",
|
||||
"coder",
|
||||
"main.go",
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Allows users to more easily define properties they want for agents and resources!
|
||||
type RecursivePartial<T> = {
|
||||
|
@ -390,16 +390,16 @@ type RecursivePartial<T> = {
|
|||
? RecursivePartial<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? RecursivePartial<T[P]>
|
||||
: T[P]
|
||||
}
|
||||
: T[P];
|
||||
};
|
||||
|
||||
interface EchoProvisionerResponses {
|
||||
// parse is for observing any Terraform variables
|
||||
parse?: RecursivePartial<Response>[]
|
||||
parse?: RecursivePartial<Response>[];
|
||||
// plan occurs when the template is imported
|
||||
plan?: RecursivePartial<Response>[]
|
||||
plan?: RecursivePartial<Response>[];
|
||||
// apply occurs when the workspace is built
|
||||
apply?: RecursivePartial<Response>[]
|
||||
apply?: RecursivePartial<Response>[];
|
||||
}
|
||||
|
||||
// createTemplateVersionTar consumes a series of echo provisioner protobufs and
|
||||
|
@ -408,26 +408,26 @@ const createTemplateVersionTar = async (
|
|||
responses?: EchoProvisionerResponses,
|
||||
): Promise<Buffer> => {
|
||||
if (!responses) {
|
||||
responses = {}
|
||||
responses = {};
|
||||
}
|
||||
if (!responses.parse) {
|
||||
responses.parse = [
|
||||
{
|
||||
parse: {},
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
if (!responses.apply) {
|
||||
responses.apply = [
|
||||
{
|
||||
apply: {},
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
if (!responses.plan) {
|
||||
responses.plan = responses.apply.map((response) => {
|
||||
if (response.log) {
|
||||
return response
|
||||
return response;
|
||||
}
|
||||
return {
|
||||
plan: {
|
||||
|
@ -436,23 +436,23 @@ const createTemplateVersionTar = async (
|
|||
parameters: response.apply?.parameters ?? [],
|
||||
gitAuthProviders: response.apply?.gitAuthProviders ?? [],
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const tar = new TarWriter()
|
||||
const tar = new TarWriter();
|
||||
responses.parse.forEach((response, index) => {
|
||||
response.parse = {
|
||||
templateVariables: [],
|
||||
error: "",
|
||||
readme: new Uint8Array(),
|
||||
...response.parse,
|
||||
} as ParseComplete
|
||||
} as ParseComplete;
|
||||
tar.addFile(
|
||||
`${index}.parse.protobuf`,
|
||||
Response.encode(response as Response).finish(),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const fillResource = (resource: RecursivePartial<Resource>) => {
|
||||
if (resource.agents) {
|
||||
|
@ -470,8 +470,8 @@ const createTemplateVersionTar = async (
|
|||
subdomain: false,
|
||||
url: "",
|
||||
...app,
|
||||
} as App
|
||||
})
|
||||
} as App;
|
||||
});
|
||||
}
|
||||
return {
|
||||
apps: [],
|
||||
|
@ -492,9 +492,9 @@ const createTemplateVersionTar = async (
|
|||
troubleshootingUrl: "",
|
||||
token: randomUUID(),
|
||||
...agent,
|
||||
} as Agent
|
||||
} as Agent;
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
agents: [],
|
||||
|
@ -506,8 +506,8 @@ const createTemplateVersionTar = async (
|
|||
name: "dev",
|
||||
type: "echo",
|
||||
...resource,
|
||||
} as Resource
|
||||
}
|
||||
} as Resource;
|
||||
};
|
||||
|
||||
responses.apply.forEach((response, index) => {
|
||||
response.apply = {
|
||||
|
@ -517,14 +517,14 @@ const createTemplateVersionTar = async (
|
|||
parameters: [],
|
||||
gitAuthProviders: [],
|
||||
...response.apply,
|
||||
} as ApplyComplete
|
||||
response.apply.resources = response.apply.resources?.map(fillResource)
|
||||
} as ApplyComplete;
|
||||
response.apply.resources = response.apply.resources?.map(fillResource);
|
||||
|
||||
tar.addFile(
|
||||
`${index}.apply.protobuf`,
|
||||
Response.encode(response as Response).finish(),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
responses.plan.forEach((response, index) => {
|
||||
response.plan = {
|
||||
error: "",
|
||||
|
@ -532,63 +532,63 @@ const createTemplateVersionTar = async (
|
|||
parameters: [],
|
||||
gitAuthProviders: [],
|
||||
...response.plan,
|
||||
} as PlanComplete
|
||||
response.plan.resources = response.plan.resources?.map(fillResource)
|
||||
} as PlanComplete;
|
||||
response.plan.resources = response.plan.resources?.map(fillResource);
|
||||
|
||||
tar.addFile(
|
||||
`${index}.plan.protobuf`,
|
||||
Response.encode(response as Response).finish(),
|
||||
)
|
||||
})
|
||||
const tarFile = await tar.write()
|
||||
);
|
||||
});
|
||||
const tarFile = await tar.write();
|
||||
return Buffer.from(
|
||||
tarFile instanceof Blob ? await tarFile.arrayBuffer() : tarFile,
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const randomName = () => {
|
||||
return randomUUID().slice(0, 8)
|
||||
}
|
||||
return randomUUID().slice(0, 8);
|
||||
};
|
||||
|
||||
// Awaiter is a helper that allows you to wait for a callback to be called.
|
||||
// It is useful for waiting for events to occur.
|
||||
export class Awaiter {
|
||||
private promise: Promise<void>
|
||||
private callback?: () => void
|
||||
private promise: Promise<void>;
|
||||
private callback?: () => void;
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise((r) => (this.callback = r))
|
||||
this.promise = new Promise((r) => (this.callback = r));
|
||||
}
|
||||
|
||||
public done(): void {
|
||||
if (this.callback) {
|
||||
this.callback()
|
||||
this.callback();
|
||||
} else {
|
||||
this.promise = Promise.resolve()
|
||||
this.promise = Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
public wait(): Promise<void> {
|
||||
return this.promise
|
||||
return this.promise;
|
||||
}
|
||||
}
|
||||
|
||||
export const createServer = async (
|
||||
port: number,
|
||||
): Promise<ReturnType<typeof express>> => {
|
||||
const e = express()
|
||||
await new Promise<void>((r) => e.listen(port, r))
|
||||
return e
|
||||
}
|
||||
const e = express();
|
||||
await new Promise<void>((r) => e.listen(port, r));
|
||||
return e;
|
||||
};
|
||||
|
||||
const findSessionToken = async (page: Page): Promise<string> => {
|
||||
const cookies = await page.context().cookies()
|
||||
const sessionCookie = cookies.find((c) => c.name === "coder_session_token")
|
||||
const cookies = await page.context().cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name === "coder_session_token");
|
||||
if (!sessionCookie) {
|
||||
throw new Error("session token not found")
|
||||
throw new Error("session token not found");
|
||||
}
|
||||
return sessionCookie.value
|
||||
}
|
||||
return sessionCookie.value;
|
||||
};
|
||||
|
||||
export const echoResponsesWithParameters = (
|
||||
richParameters: RichParameter[],
|
||||
|
@ -617,8 +617,8 @@ export const echoResponsesWithParameters = (
|
|||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const fillParameters = async (
|
||||
page: Page,
|
||||
|
@ -628,52 +628,52 @@ export const fillParameters = async (
|
|||
for (const buildParameter of buildParameters) {
|
||||
const richParameter = richParameters.find(
|
||||
(richParam) => richParam.name === buildParameter.name,
|
||||
)
|
||||
);
|
||||
if (!richParameter) {
|
||||
throw new Error(
|
||||
"build parameter is expected to be present in rich parameter schema",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parameterLabel = await page.waitForSelector(
|
||||
"[data-testid='parameter-field-" + richParameter.name + "']",
|
||||
{ state: "visible" },
|
||||
)
|
||||
);
|
||||
|
||||
if (richParameter.type === "bool") {
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" +
|
||||
buildParameter.value +
|
||||
"']",
|
||||
)
|
||||
await parameterField.check()
|
||||
);
|
||||
await parameterField.check();
|
||||
} else if (richParameter.options.length > 0) {
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-options'] .MuiRadio-root input[value='" +
|
||||
buildParameter.value +
|
||||
"']",
|
||||
)
|
||||
await parameterField.check()
|
||||
);
|
||||
await parameterField.check();
|
||||
} else if (richParameter.type === "list(string)") {
|
||||
throw new Error("not implemented yet") // FIXME
|
||||
throw new Error("not implemented yet"); // FIXME
|
||||
} else {
|
||||
// text or number
|
||||
const parameterField = await parameterLabel.waitForSelector(
|
||||
"[data-testid='parameter-field-text'] input",
|
||||
)
|
||||
await parameterField.fill(buildParameter.value)
|
||||
);
|
||||
await parameterField.fill(buildParameter.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTemplate = async (
|
||||
page: Page,
|
||||
templateName: string,
|
||||
responses?: EchoProvisionerResponses,
|
||||
) => {
|
||||
const tarball = await createTemplateVersionTar(responses)
|
||||
const tarball = await createTemplateVersionTar(responses);
|
||||
|
||||
const sessionToken = await findSessionToken(page)
|
||||
const sessionToken = await findSessionToken(page);
|
||||
const child = spawn(
|
||||
"go",
|
||||
[
|
||||
|
@ -695,23 +695,23 @@ export const updateTemplate = async (
|
|||
CODER_URL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const uploaded = new Awaiter()
|
||||
const uploaded = new Awaiter();
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
uploaded.done()
|
||||
return
|
||||
uploaded.done();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`coder templates push failed with code ${code}`)
|
||||
})
|
||||
throw new Error(`coder templates push failed with code ${code}`);
|
||||
});
|
||||
|
||||
child.stdin.write(tarball)
|
||||
child.stdin.end()
|
||||
child.stdin.write(tarball);
|
||||
child.stdin.end();
|
||||
|
||||
await uploaded.wait()
|
||||
}
|
||||
await uploaded.wait();
|
||||
};
|
||||
|
||||
export const updateWorkspace = async (
|
||||
page: Page,
|
||||
|
@ -721,22 +721,22 @@ export const updateWorkspace = async (
|
|||
) => {
|
||||
await page.goto("/@admin/" + workspaceName, {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName)
|
||||
});
|
||||
await expect(page).toHaveURL("/@admin/" + workspaceName);
|
||||
|
||||
await page.getByTestId("workspace-update-button").click()
|
||||
await page.getByTestId("confirm-button").click()
|
||||
await page.getByTestId("workspace-update-button").click();
|
||||
await page.getByTestId("confirm-button").click();
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters)
|
||||
await page.getByTestId("form-submit").click()
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("form-submit").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const updateWorkspaceParameters = async (
|
||||
page: Page,
|
||||
|
@ -746,18 +746,18 @@ export const updateWorkspaceParameters = async (
|
|||
) => {
|
||||
await page.goto("/@admin/" + workspaceName + "/settings/parameters", {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
});
|
||||
await expect(page).toHaveURL(
|
||||
"/@admin/" + workspaceName + "/settings/parameters",
|
||||
)
|
||||
);
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters)
|
||||
await page.getByTestId("form-submit").click()
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("form-submit").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Page } from "@playwright/test"
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const beforeCoderTest = async (page: Page) => {
|
||||
// eslint-disable-next-line no-console -- Show everything that was printed with console.log()
|
||||
page.on("console", (msg) => console.log("[onConsole] " + msg.text()))
|
||||
page.on("console", (msg) => console.log("[onConsole] " + msg.text()));
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (!isApiCall(request.url())) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes
|
||||
|
@ -14,40 +14,40 @@ export const beforeCoderTest = async (page: Page) => {
|
|||
`[onRequest] method=${request.method()} url=${request.url()} postData=${
|
||||
request.postData() ? request.postData() : ""
|
||||
}`,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
page.on("response", async (response) => {
|
||||
if (!isApiCall(response.url())) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldLogResponse =
|
||||
!response.url().endsWith("/api/v2/deployment/config") &&
|
||||
!response.url().endsWith("/api/v2/debug/health")
|
||||
!response.url().endsWith("/api/v2/debug/health");
|
||||
|
||||
let responseText = ""
|
||||
let responseText = "";
|
||||
try {
|
||||
if (shouldLogResponse) {
|
||||
const buffer = await response.body()
|
||||
responseText = buffer.toString("utf-8")
|
||||
responseText = responseText.replace(/\n$/g, "")
|
||||
const buffer = await response.body();
|
||||
responseText = buffer.toString("utf-8");
|
||||
responseText = responseText.replace(/\n$/g, "");
|
||||
} else {
|
||||
responseText = "skipped..."
|
||||
responseText = "skipped...";
|
||||
}
|
||||
} catch (error) {
|
||||
responseText = "not_available"
|
||||
responseText = "not_available";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- Log HTTP requests for debugging purposes
|
||||
console.log(
|
||||
`[onResponse] url=${response.url()} status=${response.status()} body=${responseText}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isApiCall = (urlString: string): boolean => {
|
||||
const url = new URL(urlString)
|
||||
const apiPath = "/api/v2"
|
||||
const url = new URL(urlString);
|
||||
const apiPath = "/api/v2";
|
||||
|
||||
return url.pathname.startsWith(apiPath)
|
||||
}
|
||||
return url.pathname.startsWith(apiPath);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RichParameter } from "./provisionerGenerated"
|
||||
import { RichParameter } from "./provisionerGenerated";
|
||||
|
||||
// Rich parameters
|
||||
|
||||
|
@ -19,7 +19,7 @@ const emptyParameter: RichParameter = {
|
|||
displayName: "",
|
||||
order: 0,
|
||||
ephemeral: false,
|
||||
}
|
||||
};
|
||||
|
||||
// firstParameter is mutable string with a default value (parameter value not required).
|
||||
export const firstParameter: RichParameter = {
|
||||
|
@ -33,7 +33,7 @@ export const firstParameter: RichParameter = {
|
|||
defaultValue: "123",
|
||||
mutable: true,
|
||||
order: 1,
|
||||
}
|
||||
};
|
||||
|
||||
// secondParameter is immutable string with a default value (parameter value not required).
|
||||
export const secondParameter: RichParameter = {
|
||||
|
@ -45,7 +45,7 @@ export const secondParameter: RichParameter = {
|
|||
description: "This is second parameter.",
|
||||
defaultValue: "abc",
|
||||
order: 2,
|
||||
}
|
||||
};
|
||||
|
||||
// thirdParameter is mutable string with an empty default value (parameter value not required).
|
||||
export const thirdParameter: RichParameter = {
|
||||
|
@ -57,7 +57,7 @@ export const thirdParameter: RichParameter = {
|
|||
defaultValue: "",
|
||||
mutable: true,
|
||||
order: 3,
|
||||
}
|
||||
};
|
||||
|
||||
// fourthParameter is immutable boolean with a default "true" value (parameter value not required).
|
||||
export const fourthParameter: RichParameter = {
|
||||
|
@ -68,7 +68,7 @@ export const fourthParameter: RichParameter = {
|
|||
description: "This is fourth parameter.",
|
||||
defaultValue: "true",
|
||||
order: 3,
|
||||
}
|
||||
};
|
||||
|
||||
// fifthParameter is immutable "string with options", with a default option selected (parameter value not required).
|
||||
export const fifthParameter: RichParameter = {
|
||||
|
@ -100,7 +100,7 @@ export const fifthParameter: RichParameter = {
|
|||
description: "This is fifth parameter.",
|
||||
defaultValue: "def",
|
||||
order: 3,
|
||||
}
|
||||
};
|
||||
|
||||
// sixthParameter is mutable string without a default value (parameter value is required).
|
||||
export const sixthParameter: RichParameter = {
|
||||
|
@ -114,7 +114,7 @@ export const sixthParameter: RichParameter = {
|
|||
required: true,
|
||||
mutable: true,
|
||||
order: 1,
|
||||
}
|
||||
};
|
||||
|
||||
// seventhParameter is immutable string without a default value (parameter value is required).
|
||||
export const seventhParameter: RichParameter = {
|
||||
|
@ -126,7 +126,7 @@ export const seventhParameter: RichParameter = {
|
|||
description: "This is seventh parameter.",
|
||||
required: true,
|
||||
order: 1,
|
||||
}
|
||||
};
|
||||
|
||||
// Build options
|
||||
|
||||
|
@ -141,7 +141,7 @@ export const firstBuildOption: RichParameter = {
|
|||
defaultValue: "ABCDEF",
|
||||
mutable: true,
|
||||
ephemeral: true,
|
||||
}
|
||||
};
|
||||
|
||||
export const secondBuildOption: RichParameter = {
|
||||
...emptyParameter,
|
||||
|
@ -153,4 +153,4 @@ export const secondBuildOption: RichParameter = {
|
|||
defaultValue: "false",
|
||||
mutable: true,
|
||||
ephemeral: true,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { defineConfig } from "@playwright/test"
|
||||
import path from "path"
|
||||
import { defaultPort, gitAuth } from "./constants"
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { defaultPort, gitAuth } from "./constants";
|
||||
|
||||
export const port = process.env.CODER_E2E_PORT
|
||||
? Number(process.env.CODER_E2E_PORT)
|
||||
: defaultPort
|
||||
: defaultPort;
|
||||
|
||||
const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go")
|
||||
const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go");
|
||||
|
||||
export const STORAGE_STATE = path.join(__dirname, ".auth.json")
|
||||
export const STORAGE_STATE = path.join(__dirname, ".auth.json");
|
||||
|
||||
const localURL = (port: number, path: string): string => {
|
||||
return `http://localhost:${port}${path}`
|
||||
}
|
||||
return `http://localhost:${port}${path}`;
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
|
@ -92,4 +92,4 @@ export default defineConfig({
|
|||
},
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { Page } from "@playwright/test"
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export abstract class BasePom {
|
||||
protected readonly baseURL: string | undefined
|
||||
protected readonly path: string
|
||||
protected readonly page: Page
|
||||
protected readonly baseURL: string | undefined;
|
||||
protected readonly path: string;
|
||||
protected readonly page: Page;
|
||||
|
||||
constructor(baseURL: string | undefined, path: string, page: Page) {
|
||||
this.baseURL = baseURL
|
||||
this.path = path
|
||||
this.page = page
|
||||
this.baseURL = baseURL;
|
||||
this.path = path;
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.baseURL + this.path
|
||||
return this.baseURL + this.path;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { Page } from "@playwright/test"
|
||||
import { BasePom } from "./BasePom"
|
||||
import { Page } from "@playwright/test";
|
||||
import { BasePom } from "./BasePom";
|
||||
|
||||
export class SignInPage extends BasePom {
|
||||
constructor(baseURL: string | undefined, page: Page) {
|
||||
super(baseURL, "/login", page)
|
||||
super(baseURL, "/login", page);
|
||||
}
|
||||
|
||||
async submitBuiltInAuthentication(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await this.page.fill("text=Email", email)
|
||||
await this.page.fill("text=Password", password)
|
||||
await this.page.click('button:has-text("Sign In")')
|
||||
await this.page.fill("text=Email", email);
|
||||
await this.page.fill("text=Password", password);
|
||||
await this.page.click('button:has-text("Sign In")');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Page } from "@playwright/test"
|
||||
import { BasePom } from "./BasePom"
|
||||
import { Page } from "@playwright/test";
|
||||
import { BasePom } from "./BasePom";
|
||||
|
||||
export class WorkspacesPage extends BasePom {
|
||||
constructor(baseURL: string | undefined, page: Page, params?: string) {
|
||||
super(baseURL, `/workspaces${params && params}`, page)
|
||||
super(baseURL, `/workspaces${params && params}`, page);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export * from "./SignInPage"
|
||||
export * from "./WorkspacesPage"
|
||||
export * from "./SignInPage";
|
||||
export * from "./WorkspacesPage";
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,4 +1,4 @@
|
|||
import fs from "fs"
|
||||
import fs from "fs";
|
||||
import type {
|
||||
FullConfig,
|
||||
Suite,
|
||||
|
@ -6,18 +6,18 @@ import type {
|
|||
TestResult,
|
||||
FullResult,
|
||||
Reporter,
|
||||
} from "@playwright/test/reporter"
|
||||
import axios from "axios"
|
||||
} from "@playwright/test/reporter";
|
||||
import axios from "axios";
|
||||
|
||||
class CoderReporter implements Reporter {
|
||||
onBegin(config: FullConfig, suite: Suite) {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log(`Starting the run with ${suite.allTests().length} tests`)
|
||||
console.log(`Starting the run with ${suite.allTests().length} tests`);
|
||||
}
|
||||
|
||||
onTestBegin(test: TestCase) {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log(`Starting test ${test.title}`)
|
||||
console.log(`Starting test ${test.title}`);
|
||||
}
|
||||
|
||||
onStdOut(chunk: string, test: TestCase, _: TestResult): void {
|
||||
|
@ -27,7 +27,7 @@ class CoderReporter implements Reporter {
|
|||
/\n$/g,
|
||||
"",
|
||||
)}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
onStdErr(chunk: string, test: TestCase, _: TestResult): void {
|
||||
|
@ -37,50 +37,50 @@ class CoderReporter implements Reporter {
|
|||
/\n$/g,
|
||||
"",
|
||||
)}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async onTestEnd(test: TestCase, result: TestResult) {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log(`Finished test ${test.title}: ${result.status}`)
|
||||
console.log(`Finished test ${test.title}: ${result.status}`);
|
||||
|
||||
if (result.status !== "passed") {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log("errors", result.errors, "attachments", result.attachments)
|
||||
console.log("errors", result.errors, "attachments", result.attachments);
|
||||
}
|
||||
await exportDebugPprof(test.title)
|
||||
await exportDebugPprof(test.title);
|
||||
}
|
||||
|
||||
onEnd(result: FullResult) {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log(`Finished the run: ${result.status}`)
|
||||
console.log(`Finished the run: ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const exportDebugPprof = async (testName: string) => {
|
||||
const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1"
|
||||
const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`
|
||||
const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1";
|
||||
const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`;
|
||||
|
||||
await axios
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Error: Received status code ${response.status}`)
|
||||
throw new Error(`Error: Received status code ${response.status}`);
|
||||
}
|
||||
|
||||
fs.writeFile(outputFile, response.data, (err) => {
|
||||
if (err) {
|
||||
throw new Error(`Error writing to ${outputFile}: ${err.message}`)
|
||||
throw new Error(`Error writing to ${outputFile}: ${err.message}`);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console -- Helpful for debugging
|
||||
console.log(`Data from ${url} has been saved to ${outputFile}`)
|
||||
console.log(`Data from ${url} has been saved to ${outputFile}`);
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
throw new Error(`Error: ${error.message}`)
|
||||
})
|
||||
}
|
||||
throw new Error(`Error: ${error.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars -- Playwright config uses it
|
||||
export default CoderReporter
|
||||
export default CoderReporter;
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { randomUUID } from "crypto"
|
||||
import * as http from "http"
|
||||
import { test } from "@playwright/test";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as http from "http";
|
||||
import {
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
stopWorkspace,
|
||||
} from "../helpers"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("app", async ({ context, page }) => {
|
||||
const appContent = "Hello World"
|
||||
const token = randomUUID()
|
||||
const appContent = "Hello World";
|
||||
const token = randomUUID();
|
||||
const srv = http
|
||||
.createServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" })
|
||||
res.end(appContent)
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end(appContent);
|
||||
})
|
||||
.listen(0)
|
||||
const addr = srv.address()
|
||||
.listen(0);
|
||||
const addr = srv.address();
|
||||
if (typeof addr !== "object" || !addr) {
|
||||
throw new Error("Expected addr to be an object")
|
||||
throw new Error("Expected addr to be an object");
|
||||
}
|
||||
const appName = "test-app"
|
||||
const appName = "test-app";
|
||||
const template = await createTemplate(page, {
|
||||
apply: [
|
||||
{
|
||||
|
@ -48,17 +48,17 @@ test("app", async ({ context, page }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const agent = await startAgent(page, token)
|
||||
});
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
const agent = await startAgent(page, token);
|
||||
|
||||
// Wait for the web terminal to open in a new tab
|
||||
const pagePromise = context.waitForEvent("page")
|
||||
await page.getByText(appName).click()
|
||||
const app = await pagePromise
|
||||
await app.waitForLoadState("domcontentloaded")
|
||||
await app.getByText(appContent).isVisible()
|
||||
const pagePromise = context.waitForEvent("page");
|
||||
await page.getByText(appName).click();
|
||||
const app = await pagePromise;
|
||||
await app.waitForLoadState("domcontentloaded");
|
||||
await app.getByText(appContent).isVisible();
|
||||
|
||||
await stopWorkspace(page, workspaceName)
|
||||
await stopAgent(agent)
|
||||
})
|
||||
await stopWorkspace(page, workspaceName);
|
||||
await stopAgent(agent);
|
||||
});
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { test } from "@playwright/test";
|
||||
import {
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
echoResponsesWithParameters,
|
||||
verifyParameters,
|
||||
} from "../helpers"
|
||||
} from "../helpers";
|
||||
|
||||
import {
|
||||
secondParameter,
|
||||
|
@ -14,11 +14,11 @@ import {
|
|||
thirdParameter,
|
||||
seventhParameter,
|
||||
sixthParameter,
|
||||
} from "../parameters"
|
||||
import { RichParameter } from "../provisionerGenerated"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../parameters";
|
||||
import { RichParameter } from "../provisionerGenerated";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("create workspace", async ({ page }) => {
|
||||
const template = await createTemplate(page, {
|
||||
|
@ -33,40 +33,40 @@ test("create workspace", async ({ page }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await createWorkspace(page, template)
|
||||
})
|
||||
});
|
||||
await createWorkspace(page, template);
|
||||
});
|
||||
|
||||
test("create workspace with default immutable parameters", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [
|
||||
secondParameter,
|
||||
fourthParameter,
|
||||
fifthParameter,
|
||||
]
|
||||
];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
);
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
{ name: fourthParameter.name, value: fourthParameter.defaultValue },
|
||||
{ name: fifthParameter.name, value: fifthParameter.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("create workspace with default mutable parameters", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [firstParameter, thirdParameter]
|
||||
const richParameters: RichParameter[] = [firstParameter, thirdParameter];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
);
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: thirdParameter.name, value: thirdParameter.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("create workspace with default and required parameters", async ({
|
||||
page,
|
||||
|
@ -76,46 +76,46 @@ test("create workspace with default and required parameters", async ({
|
|||
fourthParameter,
|
||||
sixthParameter,
|
||||
seventhParameter,
|
||||
]
|
||||
];
|
||||
const buildParameters = [
|
||||
{ name: sixthParameter.name, value: "12345" },
|
||||
{ name: seventhParameter.name, value: "abcdef" },
|
||||
]
|
||||
];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
);
|
||||
const workspaceName = await createWorkspace(
|
||||
page,
|
||||
template,
|
||||
richParameters,
|
||||
buildParameters,
|
||||
)
|
||||
);
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
// user values:
|
||||
...buildParameters,
|
||||
// default values:
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
{ name: fourthParameter.name, value: fourthParameter.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("create workspace and overwrite default parameters", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [secondParameter, fourthParameter]
|
||||
const richParameters: RichParameter[] = [secondParameter, fourthParameter];
|
||||
const buildParameters = [
|
||||
{ name: secondParameter.name, value: "AAAAA" },
|
||||
{ name: fourthParameter.name, value: "false" },
|
||||
]
|
||||
];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceName = await createWorkspace(
|
||||
page,
|
||||
template,
|
||||
richParameters,
|
||||
buildParameters,
|
||||
)
|
||||
await verifyParameters(page, workspaceName, richParameters, buildParameters)
|
||||
})
|
||||
);
|
||||
await verifyParameters(page, workspaceName, richParameters, buildParameters);
|
||||
});
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { gitAuth } from "../constants"
|
||||
import { Endpoints } from "@octokit/types"
|
||||
import { GitAuthDevice } from "api/typesGenerated"
|
||||
import { Awaiter, createServer } from "../helpers"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
import { test } from "@playwright/test";
|
||||
import { gitAuth } from "../constants";
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import { GitAuthDevice } from "api/typesGenerated";
|
||||
import { Awaiter, createServer } from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
// Ensures that a Git auth provider with the device flow functions and completes!
|
||||
test("git auth device", async ({ page }) => {
|
||||
|
@ -15,71 +15,71 @@ test("git auth device", async ({ page }) => {
|
|||
expires_in: 900,
|
||||
interval: 1,
|
||||
verification_uri: "",
|
||||
}
|
||||
};
|
||||
|
||||
// Start a server to mock the GitHub API.
|
||||
const srv = await createServer(gitAuth.devicePort)
|
||||
const srv = await createServer(gitAuth.devicePort);
|
||||
srv.use(gitAuth.validatePath, (req, res) => {
|
||||
res.write(JSON.stringify(ghUser))
|
||||
res.end()
|
||||
})
|
||||
res.write(JSON.stringify(ghUser));
|
||||
res.end();
|
||||
});
|
||||
srv.use(gitAuth.codePath, (req, res) => {
|
||||
res.write(JSON.stringify(device))
|
||||
res.end()
|
||||
})
|
||||
res.write(JSON.stringify(device));
|
||||
res.end();
|
||||
});
|
||||
srv.use(gitAuth.installationsPath, (req, res) => {
|
||||
res.write(JSON.stringify(ghInstall))
|
||||
res.end()
|
||||
})
|
||||
res.write(JSON.stringify(ghInstall));
|
||||
res.end();
|
||||
});
|
||||
|
||||
const token = {
|
||||
access_token: "",
|
||||
error: "authorization_pending",
|
||||
error_description: "",
|
||||
}
|
||||
};
|
||||
// First we send a result from the API that the token hasn't been
|
||||
// authorized yet to ensure the UI reacts properly.
|
||||
const sentPending = new Awaiter()
|
||||
const sentPending = new Awaiter();
|
||||
srv.use(gitAuth.tokenPath, (req, res) => {
|
||||
res.write(JSON.stringify(token))
|
||||
res.end()
|
||||
sentPending.done()
|
||||
})
|
||||
res.write(JSON.stringify(token));
|
||||
res.end();
|
||||
sentPending.done();
|
||||
});
|
||||
|
||||
await page.goto(`/gitauth/${gitAuth.deviceProvider}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
await page.getByText(device.user_code).isVisible()
|
||||
await sentPending.wait()
|
||||
});
|
||||
await page.getByText(device.user_code).isVisible();
|
||||
await sentPending.wait();
|
||||
// Update the token to be valid and ensure the UI updates!
|
||||
token.error = ""
|
||||
token.access_token = "hello-world"
|
||||
await page.waitForSelector("text=1 organization authorized")
|
||||
})
|
||||
token.error = "";
|
||||
token.access_token = "hello-world";
|
||||
await page.waitForSelector("text=1 organization authorized");
|
||||
});
|
||||
|
||||
test("git auth web", async ({ baseURL, page }) => {
|
||||
const srv = await createServer(gitAuth.webPort)
|
||||
const srv = await createServer(gitAuth.webPort);
|
||||
// The GitHub validate endpoint returns the currently authenticated user!
|
||||
srv.use(gitAuth.validatePath, (req, res) => {
|
||||
res.write(JSON.stringify(ghUser))
|
||||
res.end()
|
||||
})
|
||||
res.write(JSON.stringify(ghUser));
|
||||
res.end();
|
||||
});
|
||||
srv.use(gitAuth.tokenPath, (req, res) => {
|
||||
res.write(JSON.stringify({ access_token: "hello-world" }))
|
||||
res.end()
|
||||
})
|
||||
res.write(JSON.stringify({ access_token: "hello-world" }));
|
||||
res.end();
|
||||
});
|
||||
srv.use(gitAuth.authPath, (req, res) => {
|
||||
res.redirect(
|
||||
`${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` +
|
||||
req.query.state,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
await page.goto(`/gitauth/${gitAuth.webProvider}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
});
|
||||
// This endpoint doesn't have the installations URL set intentionally!
|
||||
await page.waitForSelector("text=You've authenticated with GitHub!")
|
||||
})
|
||||
await page.waitForSelector("text=You've authenticated with GitHub!");
|
||||
});
|
||||
|
||||
const ghUser: Endpoints["GET /user"]["response"]["data"] = {
|
||||
login: "kylecarbs",
|
||||
|
@ -115,7 +115,7 @@ const ghUser: Endpoints["GET /user"]["response"]["data"] = {
|
|||
following: 31,
|
||||
created_at: "2014-04-01T02:24:41Z",
|
||||
updated_at: "2023-06-26T13:03:09Z",
|
||||
}
|
||||
};
|
||||
|
||||
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
|
||||
installations: [
|
||||
|
@ -140,4 +140,4 @@ const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
|
|||
},
|
||||
],
|
||||
total_count: 1,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { test, expect } from "@playwright/test"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("list templates", async ({ page, baseURL }) => {
|
||||
await page.goto(`${baseURL}/templates`, { waitUntil: "domcontentloaded" })
|
||||
await expect(page).toHaveTitle("Templates - Coder")
|
||||
})
|
||||
await page.goto(`${baseURL}/templates`, { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveTitle("Templates - Coder");
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { randomUUID } from "crypto"
|
||||
import { test } from "@playwright/test";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
|
@ -8,15 +8,15 @@ import {
|
|||
startAgentWithCommand,
|
||||
stopAgent,
|
||||
stopWorkspace,
|
||||
} from "../helpers"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
const agentVersion = "v0.14.0"
|
||||
const agentVersion = "v0.14.0";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("ssh with agent " + agentVersion, async ({ page }) => {
|
||||
const token = randomUUID()
|
||||
const token = randomUUID();
|
||||
const template = await createTemplate(page, {
|
||||
apply: [
|
||||
{
|
||||
|
@ -33,28 +33,28 @@ test("ssh with agent " + agentVersion, async ({ page }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const binaryPath = await downloadCoderVersion(agentVersion)
|
||||
const agent = await startAgentWithCommand(page, token, binaryPath)
|
||||
});
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
const binaryPath = await downloadCoderVersion(agentVersion);
|
||||
const agent = await startAgentWithCommand(page, token, binaryPath);
|
||||
|
||||
const client = await sshIntoWorkspace(page, workspaceName)
|
||||
const client = await sshIntoWorkspace(page, workspaceName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// We just exec a command to be certain the agent is running!
|
||||
client.exec("exit 0", (err, stream) => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
return reject(err);
|
||||
}
|
||||
stream.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`Command exited with code ${code}`))
|
||||
return reject(new Error(`Command exited with code ${code}`));
|
||||
}
|
||||
client.end()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await stopWorkspace(page, workspaceName)
|
||||
await stopAgent(agent, false)
|
||||
})
|
||||
await stopWorkspace(page, workspaceName);
|
||||
await stopAgent(agent, false);
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { randomUUID } from "crypto"
|
||||
import { test } from "@playwright/test";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
|
@ -8,15 +8,15 @@ import {
|
|||
startAgent,
|
||||
stopAgent,
|
||||
stopWorkspace,
|
||||
} from "../helpers"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
const clientVersion = "v0.14.0"
|
||||
const clientVersion = "v0.14.0";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("ssh with client " + clientVersion, async ({ page }) => {
|
||||
const token = randomUUID()
|
||||
const token = randomUUID();
|
||||
const template = await createTemplate(page, {
|
||||
apply: [
|
||||
{
|
||||
|
@ -33,28 +33,28 @@ test("ssh with client " + clientVersion, async ({ page }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const agent = await startAgent(page, token)
|
||||
const binaryPath = await downloadCoderVersion(clientVersion)
|
||||
});
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
const agent = await startAgent(page, token);
|
||||
const binaryPath = await downloadCoderVersion(clientVersion);
|
||||
|
||||
const client = await sshIntoWorkspace(page, workspaceName, binaryPath)
|
||||
const client = await sshIntoWorkspace(page, workspaceName, binaryPath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// We just exec a command to be certain the agent is running!
|
||||
client.exec("exit 0", (err, stream) => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
return reject(err);
|
||||
}
|
||||
stream.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`Command exited with code ${code}`))
|
||||
return reject(new Error(`Command exited with code ${code}`));
|
||||
}
|
||||
client.end()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await stopWorkspace(page, workspaceName)
|
||||
await stopAgent(agent)
|
||||
})
|
||||
await stopWorkspace(page, workspaceName);
|
||||
await stopAgent(agent);
|
||||
});
|
||||
|
|
|
@ -1,48 +1,48 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { test } from "@playwright/test";
|
||||
import {
|
||||
buildWorkspaceWithParameters,
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
echoResponsesWithParameters,
|
||||
verifyParameters,
|
||||
} from "../helpers"
|
||||
} from "../helpers";
|
||||
|
||||
import { firstBuildOption, secondBuildOption } from "../parameters"
|
||||
import { RichParameter } from "../provisionerGenerated"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
import { firstBuildOption, secondBuildOption } from "../parameters";
|
||||
import { RichParameter } from "../provisionerGenerated";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("restart workspace with ephemeral parameters", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]
|
||||
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
);
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
|
||||
// Verify that build options are default (not selected).
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
]);
|
||||
|
||||
// Now, restart the workspace with ephemeral parameters selected.
|
||||
const buildParameters = [
|
||||
{ name: firstBuildOption.name, value: "AAAAA" },
|
||||
{ name: secondBuildOption.name, value: "true" },
|
||||
]
|
||||
];
|
||||
await buildWorkspaceWithParameters(
|
||||
page,
|
||||
workspaceName,
|
||||
richParameters,
|
||||
buildParameters,
|
||||
true,
|
||||
)
|
||||
);
|
||||
|
||||
// Verify that build options are default (not selected).
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { test } from "@playwright/test";
|
||||
import {
|
||||
buildWorkspaceWithParameters,
|
||||
createTemplate,
|
||||
|
@ -6,44 +6,44 @@ import {
|
|||
echoResponsesWithParameters,
|
||||
stopWorkspace,
|
||||
verifyParameters,
|
||||
} from "../helpers"
|
||||
} from "../helpers";
|
||||
|
||||
import { firstBuildOption, secondBuildOption } from "../parameters"
|
||||
import { RichParameter } from "../provisionerGenerated"
|
||||
import { firstBuildOption, secondBuildOption } from "../parameters";
|
||||
import { RichParameter } from "../provisionerGenerated";
|
||||
|
||||
test("start workspace with ephemeral parameters", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]
|
||||
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
);
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
|
||||
// Verify that build options are default (not selected).
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
]);
|
||||
|
||||
// Stop the workspace
|
||||
await stopWorkspace(page, workspaceName)
|
||||
await stopWorkspace(page, workspaceName);
|
||||
|
||||
// Now, start the workspace with ephemeral parameters selected.
|
||||
const buildParameters = [
|
||||
{ name: firstBuildOption.name, value: "AAAAA" },
|
||||
{ name: secondBuildOption.name, value: "true" },
|
||||
]
|
||||
];
|
||||
|
||||
await buildWorkspaceWithParameters(
|
||||
page,
|
||||
workspaceName,
|
||||
richParameters,
|
||||
buildParameters,
|
||||
)
|
||||
);
|
||||
|
||||
// Verify that build options are default (not selected).
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
createTemplate,
|
||||
|
@ -8,7 +8,7 @@ import {
|
|||
updateWorkspace,
|
||||
updateWorkspaceParameters,
|
||||
verifyParameters,
|
||||
} from "../helpers"
|
||||
} from "../helpers";
|
||||
|
||||
import {
|
||||
fifthParameter,
|
||||
|
@ -16,119 +16,119 @@ import {
|
|||
secondParameter,
|
||||
sixthParameter,
|
||||
secondBuildOption,
|
||||
} from "../parameters"
|
||||
import { RichParameter } from "../provisionerGenerated"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../parameters";
|
||||
import { RichParameter } from "../provisionerGenerated";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("update workspace, new optional, immutable parameter added", async ({
|
||||
page,
|
||||
}) => {
|
||||
const richParameters: RichParameter[] = [firstParameter, secondParameter]
|
||||
const richParameters: RichParameter[] = [firstParameter, secondParameter];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
|
||||
// Verify that parameter values are default.
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
])
|
||||
]);
|
||||
|
||||
// Push updated template.
|
||||
const updatedRichParameters = [...richParameters, fifthParameter]
|
||||
const updatedRichParameters = [...richParameters, fifthParameter];
|
||||
await updateTemplate(
|
||||
page,
|
||||
template,
|
||||
echoResponsesWithParameters(updatedRichParameters),
|
||||
)
|
||||
);
|
||||
|
||||
// Now, update the workspace, and select the value for immutable parameter.
|
||||
await updateWorkspace(page, workspaceName, updatedRichParameters, [
|
||||
{ name: fifthParameter.name, value: fifthParameter.options[0].value },
|
||||
])
|
||||
]);
|
||||
|
||||
// Verify parameter values.
|
||||
await verifyParameters(page, workspaceName, updatedRichParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
{ name: fifthParameter.name, value: fifthParameter.options[0].value },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("update workspace, new required, mutable parameter added", async ({
|
||||
page,
|
||||
}) => {
|
||||
const richParameters: RichParameter[] = [firstParameter, secondParameter]
|
||||
const richParameters: RichParameter[] = [firstParameter, secondParameter];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
|
||||
// Verify that parameter values are default.
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
])
|
||||
]);
|
||||
|
||||
// Push updated template.
|
||||
const updatedRichParameters = [...richParameters, sixthParameter]
|
||||
const updatedRichParameters = [...richParameters, sixthParameter];
|
||||
await updateTemplate(
|
||||
page,
|
||||
template,
|
||||
echoResponsesWithParameters(updatedRichParameters),
|
||||
)
|
||||
);
|
||||
|
||||
// Now, update the workspace, and provide the parameter value.
|
||||
const buildParameters = [{ name: sixthParameter.name, value: "99" }]
|
||||
const buildParameters = [{ name: sixthParameter.name, value: "99" }];
|
||||
await updateWorkspace(
|
||||
page,
|
||||
workspaceName,
|
||||
updatedRichParameters,
|
||||
buildParameters,
|
||||
)
|
||||
);
|
||||
|
||||
// Verify parameter values.
|
||||
await verifyParameters(page, workspaceName, updatedRichParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondParameter.name, value: secondParameter.defaultValue },
|
||||
...buildParameters,
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("update workspace with ephemeral parameter enabled", async ({ page }) => {
|
||||
const richParameters: RichParameter[] = [firstParameter, secondBuildOption]
|
||||
const richParameters: RichParameter[] = [firstParameter, secondBuildOption];
|
||||
const template = await createTemplate(
|
||||
page,
|
||||
echoResponsesWithParameters(richParameters),
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceName = await createWorkspace(page, template)
|
||||
const workspaceName = await createWorkspace(page, template);
|
||||
|
||||
// Verify that parameter values are default.
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
]);
|
||||
|
||||
// Now, update the workspace, and select the value for ephemeral parameter.
|
||||
const buildParameters = [{ name: secondBuildOption.name, value: "true" }]
|
||||
const buildParameters = [{ name: secondBuildOption.name, value: "true" }];
|
||||
await updateWorkspaceParameters(
|
||||
page,
|
||||
workspaceName,
|
||||
richParameters,
|
||||
buildParameters,
|
||||
)
|
||||
);
|
||||
|
||||
// Verify that parameter values are default.
|
||||
await verifyParameters(page, workspaceName, richParameters, [
|
||||
{ name: firstParameter.name, value: firstParameter.defaultValue },
|
||||
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { test } from "@playwright/test"
|
||||
import { test } from "@playwright/test";
|
||||
import {
|
||||
createTemplate,
|
||||
createWorkspace,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
} from "../helpers"
|
||||
import { randomUUID } from "crypto"
|
||||
import { beforeCoderTest } from "../hooks"
|
||||
} from "../helpers";
|
||||
import { randomUUID } from "crypto";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page))
|
||||
test.beforeEach(async ({ page }) => await beforeCoderTest(page));
|
||||
|
||||
test("web terminal", async ({ context, page }) => {
|
||||
const token = randomUUID()
|
||||
const token = randomUUID();
|
||||
const template = await createTemplate(page, {
|
||||
apply: [
|
||||
{
|
||||
|
@ -31,29 +31,29 @@ test("web terminal", async ({ context, page }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await createWorkspace(page, template)
|
||||
const agent = await startAgent(page, token)
|
||||
});
|
||||
await createWorkspace(page, template);
|
||||
const agent = await startAgent(page, token);
|
||||
|
||||
// Wait for the web terminal to open in a new tab
|
||||
const pagePromise = context.waitForEvent("page")
|
||||
await page.getByTestId("terminal").click()
|
||||
const terminal = await pagePromise
|
||||
await terminal.waitForLoadState("domcontentloaded")
|
||||
const pagePromise = context.waitForEvent("page");
|
||||
await page.getByTestId("terminal").click();
|
||||
const terminal = await pagePromise;
|
||||
await terminal.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Ensure that we can type in it
|
||||
await terminal.keyboard.type("echo hello")
|
||||
await terminal.keyboard.press("Enter")
|
||||
await terminal.keyboard.type("echo hello");
|
||||
await terminal.keyboard.press("Enter");
|
||||
|
||||
const locator = terminal.locator("text=hello")
|
||||
const locator = terminal.locator("text=hello");
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const items = await locator.all()
|
||||
const items = await locator.all();
|
||||
// Make sure the text came back
|
||||
if (items.length === 2) {
|
||||
break
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
await stopAgent(agent)
|
||||
})
|
||||
await stopAgent(agent);
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Toggle eslint --fix by specifying the `FIX` env.
|
||||
const fix = !!process.env.FIX
|
||||
const fix = !!process.env.FIX;
|
||||
|
||||
module.exports = {
|
||||
cliOptions: {
|
||||
|
@ -10,4 +10,4 @@ module.exports = {
|
|||
resolvePluginsRelativeTo: ".",
|
||||
maxWarnings: 0,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -67,4 +67,4 @@ module.exports = {
|
|||
"!<rootDir>/out/**/*.*",
|
||||
"!<rootDir>/storybook-static/**/*.*",
|
||||
],
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import "@testing-library/jest-dom"
|
||||
import { cleanup } from "@testing-library/react"
|
||||
import crypto from "crypto"
|
||||
import { server } from "./src/testHelpers/server"
|
||||
import "jest-location-mock"
|
||||
import { TextEncoder, TextDecoder } from "util"
|
||||
import { Blob } from "buffer"
|
||||
import jestFetchMock from "jest-fetch-mock"
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency"
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { useMemo } from "react"
|
||||
import "@testing-library/jest-dom";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import crypto from "crypto";
|
||||
import { server } from "./src/testHelpers/server";
|
||||
import "jest-location-mock";
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
import { Blob } from "buffer";
|
||||
import jestFetchMock from "jest-fetch-mock";
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency";
|
||||
import { Region } from "api/typesGenerated";
|
||||
import { useMemo } from "react";
|
||||
|
||||
jestFetchMock.enableMocks()
|
||||
jestFetchMock.enableMocks();
|
||||
|
||||
// useProxyLatency does some http requests to determine latency.
|
||||
// This would fail unit testing, or at least make it very slow with
|
||||
|
@ -21,7 +21,7 @@ jest.mock("contexts/useProxyLatency", () => ({
|
|||
// Mocking the hook with a hook.
|
||||
const proxyLatencies = useMemo(() => {
|
||||
if (!proxies) {
|
||||
return {} as Record<string, ProxyLatencyReport>
|
||||
return {} as Record<string, ProxyLatencyReport>;
|
||||
}
|
||||
return proxies.reduce(
|
||||
(acc, proxy) => {
|
||||
|
@ -31,49 +31,49 @@ jest.mock("contexts/useProxyLatency", () => ({
|
|||
// If you make this random it could break stories.
|
||||
latencyMS: 8,
|
||||
at: new Date(),
|
||||
}
|
||||
return acc
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ProxyLatencyReport>,
|
||||
)
|
||||
}, [proxies])
|
||||
);
|
||||
}, [proxies]);
|
||||
|
||||
return { proxyLatencies, refetch: jest.fn() }
|
||||
return { proxyLatencies, refetch: jest.fn() };
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
global.TextEncoder = TextEncoder
|
||||
global.TextEncoder = TextEncoder;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
|
||||
global.TextDecoder = TextDecoder as any
|
||||
global.TextDecoder = TextDecoder as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
|
||||
global.Blob = Blob as any
|
||||
global.Blob = Blob as any;
|
||||
|
||||
// Polyfill the getRandomValues that is used on utils/random.ts
|
||||
Object.defineProperty(global.self, "crypto", {
|
||||
value: {
|
||||
getRandomValues: function (buffer: Buffer) {
|
||||
return crypto.randomFillSync(buffer)
|
||||
return crypto.randomFillSync(buffer);
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Establish API mocking before all tests through MSW.
|
||||
beforeAll(() =>
|
||||
server.listen({
|
||||
onUnhandledRequest: "warn",
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
// Reset any request handlers that we may add during the tests,
|
||||
// so they don't affect other tests.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
server.resetHandlers()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after the tests are finished.
|
||||
afterAll(() => server.close())
|
||||
afterAll(() => server.close());
|
||||
|
||||
// This is needed because we are compiling under `--isolatedModules`
|
||||
export {}
|
||||
export {};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
declare module "@emoji-mart/react" {
|
||||
const Picker: React.FC<{
|
||||
theme: "dark" | "light"
|
||||
data: Record<string, unknown>
|
||||
onEmojiSelect: (emojiData: { unified: string }) => void
|
||||
}>
|
||||
theme: "dark" | "light";
|
||||
data: Record<string, unknown>;
|
||||
onEmojiSelect: (emojiData: { unified: string }) => void;
|
||||
}>;
|
||||
|
||||
export default Picker
|
||||
export default Picker;
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
declare module "eventsourcemock"
|
||||
declare module "eventsourcemock";
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import "i18next"
|
||||
import "i18next";
|
||||
|
||||
// https://github.com/i18next/react-i18next/issues/1543#issuecomment-1528679591
|
||||
declare module "i18next" {
|
||||
interface TypeOptions {
|
||||
returnNull: false
|
||||
allowObjectInHTMLChildren: false
|
||||
returnNull: false;
|
||||
allowObjectInHTMLChildren: false;
|
||||
}
|
||||
export function t<T>(s: string): T
|
||||
export function t<T>(s: string): T;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { PaletteColor, PaletteColorOptions, Theme } from "@mui/material/styles"
|
||||
import { PaletteColor, PaletteColorOptions, Theme } from "@mui/material/styles";
|
||||
|
||||
declare module "@mui/styles/defaultTheme" {
|
||||
interface DefaultTheme extends Theme {}
|
||||
|
@ -6,20 +6,20 @@ declare module "@mui/styles/defaultTheme" {
|
|||
|
||||
declare module "@mui/material/styles" {
|
||||
interface TypeBackground {
|
||||
paperLight: string
|
||||
paperLight: string;
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
neutral: PaletteColor
|
||||
neutral: PaletteColor;
|
||||
}
|
||||
|
||||
interface PaletteOptions {
|
||||
neutral?: PaletteColorOptions
|
||||
neutral?: PaletteColorOptions;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@mui/material/Button" {
|
||||
interface ButtonPropsColorOverrides {
|
||||
neutral: true
|
||||
neutral: true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,186 +1,188 @@
|
|||
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
|
||||
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
|
||||
import { UsersLayout } from "components/UsersLayout/UsersLayout"
|
||||
import IndexPage from "pages"
|
||||
import AuditPage from "pages/AuditPage/AuditPage"
|
||||
import GroupsPage from "pages/GroupsPage/GroupsPage"
|
||||
import LoginPage from "pages/LoginPage/LoginPage"
|
||||
import { SetupPage } from "pages/SetupPage/SetupPage"
|
||||
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage"
|
||||
import TemplatesPage from "pages/TemplatesPage/TemplatesPage"
|
||||
import UsersPage from "pages/UsersPage/UsersPage"
|
||||
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
|
||||
import { FC, lazy, Suspense } from "react"
|
||||
import { Route, Routes, BrowserRouter as Router } from "react-router-dom"
|
||||
import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
|
||||
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
|
||||
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
|
||||
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
|
||||
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout";
|
||||
import { UsersLayout } from "components/UsersLayout/UsersLayout";
|
||||
import IndexPage from "pages";
|
||||
import AuditPage from "pages/AuditPage/AuditPage";
|
||||
import GroupsPage from "pages/GroupsPage/GroupsPage";
|
||||
import LoginPage from "pages/LoginPage/LoginPage";
|
||||
import { SetupPage } from "pages/SetupPage/SetupPage";
|
||||
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage";
|
||||
import TemplatesPage from "pages/TemplatesPage/TemplatesPage";
|
||||
import UsersPage from "pages/UsersPage/UsersPage";
|
||||
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage";
|
||||
import { FC, lazy, Suspense } from "react";
|
||||
import { Route, Routes, BrowserRouter as Router } from "react-router-dom";
|
||||
import { DashboardLayout } from "./components/Dashboard/DashboardLayout";
|
||||
import { RequireAuth } from "./components/RequireAuth/RequireAuth";
|
||||
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout";
|
||||
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout";
|
||||
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
|
||||
// Lazy load pages
|
||||
// - Pages that are secondary, not in the main navigation or not usually accessed
|
||||
// - Pages that use heavy dependencies like charts or time libraries
|
||||
const NotFoundPage = lazy(() => import("./pages/404Page/404Page"))
|
||||
const NotFoundPage = lazy(() => import("./pages/404Page/404Page"));
|
||||
const CliAuthenticationPage = lazy(
|
||||
() => import("./pages/CliAuthPage/CliAuthPage"),
|
||||
)
|
||||
);
|
||||
const AccountPage = lazy(
|
||||
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
|
||||
)
|
||||
);
|
||||
const SecurityPage = lazy(
|
||||
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
|
||||
)
|
||||
);
|
||||
const SSHKeysPage = lazy(
|
||||
() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"),
|
||||
)
|
||||
);
|
||||
const TokensPage = lazy(
|
||||
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
|
||||
)
|
||||
);
|
||||
const WorkspaceProxyPage = lazy(
|
||||
() =>
|
||||
import("./pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage"),
|
||||
)
|
||||
);
|
||||
const CreateUserPage = lazy(
|
||||
() => import("./pages/CreateUserPage/CreateUserPage"),
|
||||
)
|
||||
);
|
||||
const WorkspaceBuildPage = lazy(
|
||||
() => import("./pages/WorkspaceBuildPage/WorkspaceBuildPage"),
|
||||
)
|
||||
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
|
||||
);
|
||||
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"));
|
||||
const WorkspaceSchedulePage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const WorkspaceParametersPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage"
|
||||
),
|
||||
)
|
||||
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
|
||||
);
|
||||
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"));
|
||||
const TemplatePermissionsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const TemplateSummaryPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"),
|
||||
)
|
||||
);
|
||||
const CreateWorkspacePage = lazy(
|
||||
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
|
||||
)
|
||||
const CreateGroupPage = lazy(() => import("./pages/GroupsPage/CreateGroupPage"))
|
||||
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"))
|
||||
);
|
||||
const CreateGroupPage = lazy(
|
||||
() => import("./pages/GroupsPage/CreateGroupPage"),
|
||||
);
|
||||
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"));
|
||||
const SettingsGroupPage = lazy(
|
||||
() => import("./pages/GroupsPage/SettingsGroupPage"),
|
||||
)
|
||||
);
|
||||
const GeneralSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const SecuritySettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const AppearanceSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const UserAuthSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const GitAuthSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/GitAuthSettingsPage/GitAuthSettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const NetworkSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage"
|
||||
),
|
||||
)
|
||||
const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
|
||||
);
|
||||
const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"));
|
||||
const TemplateVersionPage = lazy(
|
||||
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
|
||||
)
|
||||
);
|
||||
const TemplateVersionEditorPage = lazy(
|
||||
() => import("./pages/TemplateVersionEditorPage/TemplateVersionEditorPage"),
|
||||
)
|
||||
);
|
||||
const StarterTemplatesPage = lazy(
|
||||
() => import("./pages/StarterTemplatesPage/StarterTemplatesPage"),
|
||||
)
|
||||
);
|
||||
const StarterTemplatePage = lazy(
|
||||
() => import("pages/StarterTemplatePage/StarterTemplatePage"),
|
||||
)
|
||||
);
|
||||
const CreateTemplatePage = lazy(
|
||||
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
|
||||
)
|
||||
);
|
||||
const TemplateVariablesPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const WorkspaceSettingsPage = lazy(
|
||||
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
|
||||
)
|
||||
);
|
||||
const CreateTokenPage = lazy(
|
||||
() => import("./pages/CreateTokenPage/CreateTokenPage"),
|
||||
)
|
||||
);
|
||||
|
||||
const TemplateDocsPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateDocsPage/TemplateDocsPage"),
|
||||
)
|
||||
);
|
||||
|
||||
const TemplateFilesPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"),
|
||||
)
|
||||
);
|
||||
|
||||
const TemplateVersionsPage = lazy(
|
||||
() =>
|
||||
import("./pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage"),
|
||||
)
|
||||
);
|
||||
const TemplateSchedulePage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
const LicensesSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage"
|
||||
),
|
||||
)
|
||||
);
|
||||
const AddNewLicensePage = lazy(
|
||||
() =>
|
||||
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
|
||||
)
|
||||
);
|
||||
const TemplateEmbedPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
|
||||
)
|
||||
);
|
||||
const TemplateInsightsPage = lazy(
|
||||
() =>
|
||||
import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"),
|
||||
)
|
||||
const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"))
|
||||
);
|
||||
const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"));
|
||||
|
||||
export const AppRouter: FC = () => {
|
||||
return (
|
||||
|
@ -331,5 +333,5 @@ export const AppRouter: FC = () => {
|
|||
</Routes>
|
||||
</Router>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { inspect } from "@xstate/inspect"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { Interpreter } from "xstate"
|
||||
import { App } from "./app"
|
||||
import "./i18n"
|
||||
import { inspect } from "@xstate/inspect";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Interpreter } from "xstate";
|
||||
import { App } from "./app";
|
||||
import "./i18n";
|
||||
|
||||
// if this is a development build and the developer wants to inspect
|
||||
// helpful to see realtime changes on the services
|
||||
|
@ -14,9 +14,9 @@ if (
|
|||
inspect({
|
||||
url: "https://stately.ai/viz?inspect",
|
||||
iframe: false,
|
||||
})
|
||||
});
|
||||
// configure all XServices to use the inspector
|
||||
Interpreter.defaultOptions.devTools = true
|
||||
Interpreter.defaultOptions.devTools = true;
|
||||
}
|
||||
|
||||
// This is the entry point for the app - where everything start.
|
||||
|
@ -28,13 +28,13 @@ const main = () => {
|
|||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
`)
|
||||
const element = document.getElementById("root")
|
||||
`);
|
||||
const element = document.getElementById("root");
|
||||
if (element === null) {
|
||||
throw new Error("root element is null")
|
||||
throw new Error("root element is null");
|
||||
}
|
||||
const root = createRoot(element)
|
||||
root.render(<App />)
|
||||
}
|
||||
const root = createRoot(element);
|
||||
root.render(<App />);
|
||||
};
|
||||
|
||||
main()
|
||||
main();
|
||||
|
|
|
@ -1 +1 @@
|
|||
export default jest.fn()
|
||||
export default jest.fn();
|
||||
|
|
|
@ -7,14 +7,14 @@ const editor = {
|
|||
dispose: () => {
|
||||
//
|
||||
},
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const monaco = {
|
||||
editor,
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = monaco
|
||||
module.exports = monaco;
|
||||
|
||||
export {}
|
||||
export {};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { FC, PropsWithChildren } from "react"
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
|
||||
const ReactMarkdown: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
return <div data-testid="markdown">{children}</div>
|
||||
}
|
||||
return <div data-testid="markdown">{children}</div>;
|
||||
};
|
||||
|
||||
export default ReactMarkdown
|
||||
export default ReactMarkdown;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import axios from "axios"
|
||||
import axios from "axios";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockTemplateVersionParameter1,
|
||||
|
@ -6,9 +6,9 @@ import {
|
|||
MockWorkspace,
|
||||
MockWorkspaceBuild,
|
||||
MockWorkspaceBuildParameter1,
|
||||
} from "testHelpers/entities"
|
||||
import * as api from "./api"
|
||||
import * as TypesGen from "./typesGenerated"
|
||||
} from "testHelpers/entities";
|
||||
import * as api from "./api";
|
||||
import * as TypesGen from "./typesGenerated";
|
||||
|
||||
describe("api.ts", () => {
|
||||
describe("login", () => {
|
||||
|
@ -16,111 +16,111 @@ describe("api.ts", () => {
|
|||
// given
|
||||
const loginResponse: TypesGen.LoginWithPasswordResponse = {
|
||||
session_token: "abc_123_test",
|
||||
}
|
||||
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse })
|
||||
};
|
||||
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse });
|
||||
|
||||
// when
|
||||
const result = await api.login("test", "123")
|
||||
const result = await api.login("test", "123");
|
||||
|
||||
// then
|
||||
expect(axios.post).toHaveBeenCalled()
|
||||
expect(result).toStrictEqual(loginResponse)
|
||||
})
|
||||
expect(axios.post).toHaveBeenCalled();
|
||||
expect(result).toStrictEqual(loginResponse);
|
||||
});
|
||||
|
||||
it("should throw an error on 401", async () => {
|
||||
// given
|
||||
// ..ensure that we await our expect assertion in async/await test
|
||||
expect.assertions(1)
|
||||
expect.assertions(1);
|
||||
const expectedError = {
|
||||
message: "Validation failed",
|
||||
errors: [{ field: "email", code: "email" }],
|
||||
}
|
||||
};
|
||||
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.reject(expectedError)
|
||||
})
|
||||
axios.post = axiosMockPost
|
||||
return Promise.reject(expectedError);
|
||||
});
|
||||
axios.post = axiosMockPost;
|
||||
|
||||
try {
|
||||
await api.login("test", "123")
|
||||
await api.login("test", "123");
|
||||
} catch (error) {
|
||||
expect(error).toStrictEqual(expectedError)
|
||||
expect(error).toStrictEqual(expectedError);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("should return without erroring", async () => {
|
||||
// given
|
||||
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
axios.post = axiosMockPost
|
||||
return Promise.resolve();
|
||||
});
|
||||
axios.post = axiosMockPost;
|
||||
|
||||
// when
|
||||
await api.logout()
|
||||
await api.logout();
|
||||
|
||||
// then
|
||||
expect(axiosMockPost).toHaveBeenCalled()
|
||||
})
|
||||
expect(axiosMockPost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw an error on 500", async () => {
|
||||
// given
|
||||
// ..ensure that we await our expect assertion in async/await test
|
||||
expect.assertions(1)
|
||||
expect.assertions(1);
|
||||
const expectedError = {
|
||||
message: "Failed to logout.",
|
||||
}
|
||||
};
|
||||
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.reject(expectedError)
|
||||
})
|
||||
axios.post = axiosMockPost
|
||||
return Promise.reject(expectedError);
|
||||
});
|
||||
axios.post = axiosMockPost;
|
||||
|
||||
try {
|
||||
await api.logout()
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
expect(error).toStrictEqual(expectedError)
|
||||
expect(error).toStrictEqual(expectedError);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe("getApiKey", () => {
|
||||
it("should return APIKeyResponse", async () => {
|
||||
// given
|
||||
const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = {
|
||||
key: "abc_123_test",
|
||||
}
|
||||
};
|
||||
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.resolve({ data: apiKeyResponse })
|
||||
})
|
||||
axios.post = axiosMockPost
|
||||
return Promise.resolve({ data: apiKeyResponse });
|
||||
});
|
||||
axios.post = axiosMockPost;
|
||||
|
||||
// when
|
||||
const result = await api.getApiKey()
|
||||
const result = await api.getApiKey();
|
||||
|
||||
// then
|
||||
expect(axiosMockPost).toHaveBeenCalled()
|
||||
expect(result).toStrictEqual(apiKeyResponse)
|
||||
})
|
||||
expect(axiosMockPost).toHaveBeenCalled();
|
||||
expect(result).toStrictEqual(apiKeyResponse);
|
||||
});
|
||||
|
||||
it("should throw an error on 401", async () => {
|
||||
// given
|
||||
// ..ensure that we await our expect assertion in async/await test
|
||||
expect.assertions(1)
|
||||
expect.assertions(1);
|
||||
const expectedError = {
|
||||
message: "No Cookie!",
|
||||
}
|
||||
};
|
||||
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.reject(expectedError)
|
||||
})
|
||||
axios.post = axiosMockPost
|
||||
return Promise.reject(expectedError);
|
||||
});
|
||||
axios.post = axiosMockPost;
|
||||
|
||||
try {
|
||||
await api.getApiKey()
|
||||
await api.getApiKey();
|
||||
} catch (error) {
|
||||
expect(error).toStrictEqual(expectedError)
|
||||
expect(error).toStrictEqual(expectedError);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe("getURLWithSearchParams - workspaces", () => {
|
||||
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
|
||||
|
@ -141,10 +141,10 @@ describe("api.ts", () => {
|
|||
])(
|
||||
`Workspaces - getURLWithSearchParams(%p, %p) returns %p`,
|
||||
(basePath, filter, expected) => {
|
||||
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
|
||||
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
|
||||
},
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("getURLWithSearchParams - users", () => {
|
||||
it.each<[string, TypesGen.UsersRequest | undefined, string]>([
|
||||
|
@ -158,72 +158,72 @@ describe("api.ts", () => {
|
|||
])(
|
||||
`Users - getURLWithSearchParams(%p, %p) returns %p`,
|
||||
(basePath, filter, expected) => {
|
||||
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
|
||||
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected);
|
||||
},
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("creates a build with start and the latest template", async () => {
|
||||
jest
|
||||
.spyOn(api, "postWorkspaceBuild")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild)
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
|
||||
await api.updateWorkspace(MockWorkspace)
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
|
||||
await api.updateWorkspace(MockWorkspace);
|
||||
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
||||
transition: "start",
|
||||
template_version_id: MockTemplate.active_version_id,
|
||||
rich_parameter_values: [],
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when having missing parameters", async () => {
|
||||
jest
|
||||
.spyOn(api, "postWorkspaceBuild")
|
||||
.mockResolvedValue(MockWorkspaceBuild)
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate)
|
||||
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([])
|
||||
.mockResolvedValue(MockWorkspaceBuild);
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValue(MockTemplate);
|
||||
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValue([]);
|
||||
jest
|
||||
.spyOn(api, "getTemplateVersionRichParameters")
|
||||
.mockResolvedValue([
|
||||
MockTemplateVersionParameter1,
|
||||
{ ...MockTemplateVersionParameter2, mutable: false },
|
||||
])
|
||||
]);
|
||||
|
||||
let error = new Error()
|
||||
let error = new Error();
|
||||
try {
|
||||
await api.updateWorkspace(MockWorkspace)
|
||||
await api.updateWorkspace(MockWorkspace);
|
||||
} catch (e) {
|
||||
error = e as Error
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(api.MissingBuildParameters)
|
||||
expect(error).toBeInstanceOf(api.MissingBuildParameters);
|
||||
// Verify if the correct missing parameters are being passed
|
||||
expect((error as api.MissingBuildParameters).parameters).toEqual([
|
||||
MockTemplateVersionParameter1,
|
||||
{ ...MockTemplateVersionParameter2, mutable: false },
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a build with the no parameters if it is already filled", async () => {
|
||||
jest
|
||||
.spyOn(api, "postWorkspaceBuild")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild)
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
|
||||
jest
|
||||
.spyOn(api, "getWorkspaceBuildParameters")
|
||||
.mockResolvedValue([MockWorkspaceBuildParameter1])
|
||||
.mockResolvedValue([MockWorkspaceBuildParameter1]);
|
||||
jest
|
||||
.spyOn(api, "getTemplateVersionRichParameters")
|
||||
.mockResolvedValue([
|
||||
{ ...MockTemplateVersionParameter1, required: true, mutable: false },
|
||||
])
|
||||
await api.updateWorkspace(MockWorkspace)
|
||||
]);
|
||||
await api.updateWorkspace(MockWorkspace);
|
||||
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
||||
transition: "start",
|
||||
template_version_id: MockTemplate.active_version_id,
|
||||
rich_parameter_values: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,9 +1,9 @@
|
|||
import { mockApiError } from "testHelpers/entities"
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
import {
|
||||
getValidationErrorMessage,
|
||||
isApiError,
|
||||
mapApiErrorToFieldErrors,
|
||||
} from "./errors"
|
||||
} from "./errors";
|
||||
|
||||
describe("isApiError", () => {
|
||||
it("returns true when the object is an API Error", () => {
|
||||
|
@ -16,17 +16,17 @@ describe("isApiError", () => {
|
|||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the object is Error", () => {
|
||||
expect(isApiError(new Error())).toBe(false)
|
||||
})
|
||||
expect(isApiError(new Error())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the object is undefined", () => {
|
||||
expect(isApiError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
expect(isApiError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapApiErrorToFieldErrors", () => {
|
||||
it("returns correct field errors", () => {
|
||||
|
@ -39,9 +39,9 @@ describe("mapApiErrorToFieldErrors", () => {
|
|||
}),
|
||||
).toEqual({
|
||||
username: "Username is already in use",
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValidationErrorMessage", () => {
|
||||
it("returns multiple validation messages", () => {
|
||||
|
@ -63,14 +63,14 @@ describe("getValidationErrorMessage", () => {
|
|||
),
|
||||
).toEqual(
|
||||
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("non-API error returns empty validation message", () => {
|
||||
expect(
|
||||
getValidationErrorMessage(new Error("Invalid user search query.")),
|
||||
).toEqual("")
|
||||
})
|
||||
).toEqual("");
|
||||
});
|
||||
|
||||
it("no validations field returns empty validation message", () => {
|
||||
expect(
|
||||
|
@ -80,6 +80,6 @@ describe("getValidationErrorMessage", () => {
|
|||
detail: `Query element "role:a:e" can only contain 1 ':'`,
|
||||
}),
|
||||
),
|
||||
).toEqual("")
|
||||
})
|
||||
})
|
||||
).toEqual("");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,56 +1,56 @@
|
|||
import axios, { AxiosError, AxiosResponse } from "axios"
|
||||
import axios, { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
const Language = {
|
||||
errorsByCode: {
|
||||
defaultErrorCode: "Invalid value",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export interface FieldError {
|
||||
field: string
|
||||
detail: string
|
||||
field: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
|
||||
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>;
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
message: string
|
||||
detail?: string
|
||||
validations?: FieldError[]
|
||||
message: string;
|
||||
detail?: string;
|
||||
validations?: FieldError[];
|
||||
}
|
||||
|
||||
export type ApiError = AxiosError<ApiErrorResponse> & {
|
||||
response: AxiosResponse<ApiErrorResponse>
|
||||
}
|
||||
response: AxiosResponse<ApiErrorResponse>;
|
||||
};
|
||||
|
||||
export const isApiError = (err: unknown): err is ApiError => {
|
||||
return axios.isAxiosError(err) && err.response !== undefined
|
||||
}
|
||||
return axios.isAxiosError(err) && err.response !== undefined;
|
||||
};
|
||||
|
||||
export const hasApiFieldErrors = (error: ApiError): boolean =>
|
||||
Array.isArray(error.response.data.validations)
|
||||
Array.isArray(error.response.data.validations);
|
||||
|
||||
export const isApiValidationError = (error: unknown): error is ApiError => {
|
||||
return isApiError(error) && hasApiFieldErrors(error)
|
||||
}
|
||||
return isApiError(error) && hasApiFieldErrors(error);
|
||||
};
|
||||
|
||||
export const hasError = (error: unknown) =>
|
||||
error !== undefined && error !== null
|
||||
error !== undefined && error !== null;
|
||||
|
||||
export const mapApiErrorToFieldErrors = (
|
||||
apiErrorResponse: ApiErrorResponse,
|
||||
): FieldErrors => {
|
||||
const result: FieldErrors = {}
|
||||
const result: FieldErrors = {};
|
||||
|
||||
if (apiErrorResponse.validations) {
|
||||
for (const error of apiErrorResponse.validations) {
|
||||
result[error.field] =
|
||||
error.detail || Language.errorsByCode.defaultErrorCode
|
||||
error.detail || Language.errorsByCode.defaultErrorCode;
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -66,7 +66,7 @@ export const getErrorMessage = (
|
|||
? error.response.data.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: defaultMessage
|
||||
: defaultMessage;
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -78,13 +78,13 @@ export const getValidationErrorMessage = (error: unknown): string => {
|
|||
const validationErrors =
|
||||
isApiError(error) && error.response.data.validations
|
||||
? error.response.data.validations
|
||||
: []
|
||||
return validationErrors.map((error) => error.detail).join("\n")
|
||||
}
|
||||
: [];
|
||||
return validationErrors.map((error) => error.detail).join("\n");
|
||||
};
|
||||
|
||||
export const getErrorDetail = (error: unknown): string | undefined | null =>
|
||||
isApiError(error)
|
||||
? error.response.data.detail
|
||||
: error instanceof Error
|
||||
? `Please check the developer console for more details.`
|
||||
: null
|
||||
: null;
|
||||
|
|
|
@ -1,40 +1,40 @@
|
|||
import { DeploymentValues } from "./typesGenerated"
|
||||
import { DeploymentValues } from "./typesGenerated";
|
||||
|
||||
export interface UserAgent {
|
||||
readonly browser: string
|
||||
readonly device: string
|
||||
readonly ip_address: string
|
||||
readonly os: string
|
||||
readonly browser: string;
|
||||
readonly device: string;
|
||||
readonly ip_address: string;
|
||||
readonly os: string;
|
||||
}
|
||||
|
||||
export interface ReconnectingPTYRequest {
|
||||
readonly data?: string
|
||||
readonly height?: number
|
||||
readonly width?: number
|
||||
readonly data?: string;
|
||||
readonly height?: number;
|
||||
readonly width?: number;
|
||||
}
|
||||
|
||||
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
|
||||
export type WorkspaceBuildTransition = "start" | "stop" | "delete";
|
||||
|
||||
export type Message = { message: string }
|
||||
export type Message = { message: string };
|
||||
|
||||
export interface DeploymentGroup {
|
||||
readonly name: string
|
||||
readonly parent?: DeploymentGroup
|
||||
readonly description: string
|
||||
readonly children: DeploymentGroup[]
|
||||
readonly name: string;
|
||||
readonly parent?: DeploymentGroup;
|
||||
readonly description: string;
|
||||
readonly children: DeploymentGroup[];
|
||||
}
|
||||
|
||||
export interface DeploymentOption {
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly flag: string
|
||||
readonly flag_shorthand: string
|
||||
readonly value: unknown
|
||||
readonly hidden: boolean
|
||||
readonly group?: DeploymentGroup
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly flag: string;
|
||||
readonly flag_shorthand: string;
|
||||
readonly value: unknown;
|
||||
readonly hidden: boolean;
|
||||
readonly group?: DeploymentGroup;
|
||||
}
|
||||
|
||||
export type DeploymentConfig = {
|
||||
readonly config: DeploymentValues
|
||||
readonly options: DeploymentOption[]
|
||||
}
|
||||
readonly config: DeploymentValues;
|
||||
readonly options: DeploymentOption[];
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,14 +1,14 @@
|
|||
import CssBaseline from "@mui/material/CssBaseline"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { AuthProvider } from "components/AuthProvider/AuthProvider"
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
import { AppRouter } from "./AppRouter"
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary"
|
||||
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
|
||||
import { dark } from "./theme"
|
||||
import "./theme/globalFonts"
|
||||
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider } from "components/AuthProvider/AuthProvider";
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { AppRouter } from "./AppRouter";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
|
||||
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
|
||||
import { dark } from "./theme";
|
||||
import "./theme/globalFonts";
|
||||
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
@ -19,7 +19,7 @@ const queryClient = new QueryClient({
|
|||
networkMode: "offlineFirst",
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
|
@ -38,13 +38,13 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
|
|||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
</HelmetProvider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const App: FC = () => {
|
||||
return (
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { Alert } from "./Alert"
|
||||
import Button from "@mui/material/Button"
|
||||
import Link from "@mui/material/Link"
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import { Alert } from "./Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Link from "@mui/material/Link";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: "components/Alert",
|
||||
component: Alert,
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Alert>
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Alert>;
|
||||
|
||||
const ExampleAction = (
|
||||
<Button onClick={() => null} size="small" variant="text">
|
||||
Button
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
|
@ -23,14 +23,14 @@ export const Success: Story = {
|
|||
severity: "success",
|
||||
onRetry: undefined,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
children: "This is a warning",
|
||||
severity: "warning",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WarningWithDismiss: Story = {
|
||||
args: {
|
||||
|
@ -38,7 +38,7 @@ export const WarningWithDismiss: Story = {
|
|||
dismissible: true,
|
||||
severity: "warning",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WarningWithAction: Story = {
|
||||
args: {
|
||||
|
@ -46,7 +46,7 @@ export const WarningWithAction: Story = {
|
|||
actions: [ExampleAction],
|
||||
severity: "warning",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WarningWithActionAndDismiss: Story = {
|
||||
args: {
|
||||
|
@ -55,7 +55,7 @@ export const WarningWithActionAndDismiss: Story = {
|
|||
dismissible: true,
|
||||
severity: "warning",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithChildren: Story = {
|
||||
args: {
|
||||
|
@ -66,4 +66,4 @@ export const WithChildren: Story = {
|
|||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { useState, FC, ReactNode } from "react"
|
||||
import Collapse from "@mui/material/Collapse"
|
||||
import { useState, FC, ReactNode } from "react";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
// eslint-disable-next-line no-restricted-imports -- It is the base component
|
||||
import MuiAlert, { AlertProps as MuiAlertProps } from "@mui/material/Alert"
|
||||
import Button from "@mui/material/Button"
|
||||
import Box from "@mui/material/Box"
|
||||
import MuiAlert, { AlertProps as MuiAlertProps } from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
export type AlertProps = MuiAlertProps & {
|
||||
actions?: ReactNode
|
||||
dismissible?: boolean
|
||||
onRetry?: () => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
actions?: ReactNode;
|
||||
dismissible?: boolean;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export const Alert: FC<AlertProps> = ({
|
||||
children,
|
||||
|
@ -21,7 +21,7 @@ export const Alert: FC<AlertProps> = ({
|
|||
onDismiss,
|
||||
...alertProps
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapse in={open}>
|
||||
|
@ -47,8 +47,8 @@ export const Alert: FC<AlertProps> = ({
|
|||
variant="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onDismiss && onDismiss()
|
||||
setOpen(false);
|
||||
onDismiss && onDismiss();
|
||||
}}
|
||||
data-testid="dismiss-banner-btn"
|
||||
>
|
||||
|
@ -61,8 +61,8 @@ export const Alert: FC<AlertProps> = ({
|
|||
{children}
|
||||
</MuiAlert>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertDetail = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
|
@ -74,5 +74,5 @@ export const AlertDetail = ({ children }: { children: ReactNode }) => {
|
|||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import Button from "@mui/material/Button"
|
||||
import { mockApiError } from "testHelpers/entities"
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import { action } from "@storybook/addon-actions"
|
||||
import { ErrorAlert } from "./ErrorAlert"
|
||||
import Button from "@mui/material/Button";
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { ErrorAlert } from "./ErrorAlert";
|
||||
|
||||
const mockError = mockApiError({
|
||||
message: "Email or password was invalid",
|
||||
detail: "Password is invalid",
|
||||
})
|
||||
});
|
||||
|
||||
const meta: Meta<typeof ErrorAlert> = {
|
||||
title: "components/ErrorAlert",
|
||||
|
@ -17,16 +17,16 @@ const meta: Meta<typeof ErrorAlert> = {
|
|||
dismissible: false,
|
||||
onRetry: undefined,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ErrorAlert>
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ErrorAlert>;
|
||||
|
||||
const ExampleAction = (
|
||||
<Button onClick={() => null} size="small" variant="text">
|
||||
Button
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
export const WithOnlyMessage: Story = {
|
||||
args: {
|
||||
|
@ -34,33 +34,33 @@ export const WithOnlyMessage: Story = {
|
|||
message: "Email or password was invalid",
|
||||
}),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithDismiss: Story = {
|
||||
args: {
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
actions: [ExampleAction],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithActionAndDismiss: Story = {
|
||||
args: {
|
||||
actions: [ExampleAction],
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithRetry: Story = {
|
||||
args: {
|
||||
onRetry: action("retry"),
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithActionRetryAndDismiss: Story = {
|
||||
args: {
|
||||
|
@ -68,10 +68,10 @@ export const WithActionRetryAndDismiss: Story = {
|
|||
onRetry: action("retry"),
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const WithNonApiError: Story = {
|
||||
args: {
|
||||
error: new Error("Non API error here"),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { AlertProps, Alert, AlertDetail } from "./Alert"
|
||||
import AlertTitle from "@mui/material/AlertTitle"
|
||||
import { getErrorMessage, getErrorDetail } from "api/errors"
|
||||
import { FC } from "react"
|
||||
import { AlertProps, Alert, AlertDetail } from "./Alert";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import { getErrorMessage, getErrorDetail } from "api/errors";
|
||||
import { FC } from "react";
|
||||
|
||||
export const ErrorAlert: FC<
|
||||
Omit<AlertProps, "severity" | "children"> & { error: unknown }
|
||||
> = ({ error, ...alertProps }) => {
|
||||
const message = getErrorMessage(error, "Something went wrong.")
|
||||
const detail = getErrorDetail(error)
|
||||
const message = getErrorMessage(error, "Something went wrong.");
|
||||
const detail = getErrorDetail(error);
|
||||
|
||||
// For some reason, the message and detail can be the same on the BE, but does
|
||||
// not make sense in the FE to showing them duplicated
|
||||
const shouldDisplayDetail = message !== detail
|
||||
const shouldDisplayDetail = message !== detail;
|
||||
|
||||
return (
|
||||
<Alert severity="error" {...alertProps}>
|
||||
|
@ -24,5 +24,5 @@ export const ErrorAlert: FC<
|
|||
message
|
||||
)}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
import { useActor, useInterpret } from "@xstate/react"
|
||||
import { createContext, FC, PropsWithChildren, useContext } from "react"
|
||||
import { authMachine } from "xServices/auth/authXService"
|
||||
import { ActorRefFrom } from "xstate"
|
||||
import { useActor, useInterpret } from "@xstate/react";
|
||||
import { createContext, FC, PropsWithChildren, useContext } from "react";
|
||||
import { authMachine } from "xServices/auth/authXService";
|
||||
import { ActorRefFrom } from "xstate";
|
||||
|
||||
interface AuthContextValue {
|
||||
authService: ActorRefFrom<typeof authMachine>
|
||||
authService: ActorRefFrom<typeof authMachine>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const authService = useInterpret(authMachine)
|
||||
const authService = useInterpret(authMachine);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ authService }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
type UseAuthReturnType = ReturnType<
|
||||
typeof useActor<AuthContextValue["authService"]>
|
||||
>
|
||||
>;
|
||||
|
||||
export const useAuth = (): UseAuthReturnType => {
|
||||
const context = useContext(AuthContext)
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth should be used inside of <AuthProvider />")
|
||||
throw new Error("useAuth should be used inside of <AuthProvider />");
|
||||
}
|
||||
|
||||
const auth = useActor(context.authService)
|
||||
const auth = useActor(context.authService);
|
||||
|
||||
return auth
|
||||
}
|
||||
return auth;
|
||||
};
|
||||
|
|
|
@ -1,61 +1,63 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { Avatar, AvatarIcon, AvatarProps } from "./Avatar"
|
||||
import PauseIcon from "@mui/icons-material/PauseOutlined"
|
||||
import { Story } from "@storybook/react";
|
||||
import { Avatar, AvatarIcon, AvatarProps } from "./Avatar";
|
||||
import PauseIcon from "@mui/icons-material/PauseOutlined";
|
||||
|
||||
export default {
|
||||
title: "components/Avatar",
|
||||
component: Avatar,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<AvatarProps> = (args: AvatarProps) => <Avatar {...args} />
|
||||
const Template: Story<AvatarProps> = (args: AvatarProps) => (
|
||||
<Avatar {...args} />
|
||||
);
|
||||
|
||||
export const Letter = Template.bind({})
|
||||
export const Letter = Template.bind({});
|
||||
Letter.args = {
|
||||
children: "Coder",
|
||||
}
|
||||
};
|
||||
|
||||
export const LetterXL = Template.bind({})
|
||||
export const LetterXL = Template.bind({});
|
||||
LetterXL.args = {
|
||||
children: "Coder",
|
||||
size: "xl",
|
||||
}
|
||||
};
|
||||
|
||||
export const LetterDarken = Template.bind({})
|
||||
export const LetterDarken = Template.bind({});
|
||||
LetterDarken.args = {
|
||||
children: "Coder",
|
||||
colorScheme: "darken",
|
||||
}
|
||||
};
|
||||
|
||||
export const Image = Template.bind({})
|
||||
export const Image = Template.bind({});
|
||||
Image.args = {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
}
|
||||
};
|
||||
|
||||
export const ImageXL = Template.bind({})
|
||||
export const ImageXL = Template.bind({});
|
||||
ImageXL.args = {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
size: "xl",
|
||||
}
|
||||
};
|
||||
|
||||
export const MuiIcon = Template.bind({})
|
||||
export const MuiIcon = Template.bind({});
|
||||
MuiIcon.args = {
|
||||
children: <PauseIcon />,
|
||||
}
|
||||
};
|
||||
|
||||
export const MuiIconDarken = Template.bind({})
|
||||
export const MuiIconDarken = Template.bind({});
|
||||
MuiIconDarken.args = {
|
||||
children: <PauseIcon />,
|
||||
colorScheme: "darken",
|
||||
}
|
||||
};
|
||||
|
||||
export const MuiIconXL = Template.bind({})
|
||||
export const MuiIconXL = Template.bind({});
|
||||
MuiIconXL.args = {
|
||||
children: <PauseIcon />,
|
||||
size: "xl",
|
||||
}
|
||||
};
|
||||
|
||||
export const AvatarIconDarken = Template.bind({})
|
||||
export const AvatarIconDarken = Template.bind({});
|
||||
AvatarIconDarken.args = {
|
||||
children: <AvatarIcon src="/icon/database.svg" />,
|
||||
colorScheme: "darken",
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
// This is the only place MuiAvatar can be used
|
||||
// eslint-disable-next-line no-restricted-imports -- Read above
|
||||
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { FC } from "react"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import { firstLetter } from "./firstLetter"
|
||||
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { FC } from "react";
|
||||
import { combineClasses } from "utils/combineClasses";
|
||||
import { firstLetter } from "./firstLetter";
|
||||
|
||||
export type AvatarProps = MuiAvatarProps & {
|
||||
size?: "sm" | "md" | "xl"
|
||||
colorScheme?: "light" | "darken"
|
||||
fitImage?: boolean
|
||||
}
|
||||
size?: "sm" | "md" | "xl";
|
||||
colorScheme?: "light" | "darken";
|
||||
fitImage?: boolean;
|
||||
};
|
||||
|
||||
export const Avatar: FC<AvatarProps> = ({
|
||||
size = "md",
|
||||
|
@ -20,7 +20,7 @@ export const Avatar: FC<AvatarProps> = ({
|
|||
children,
|
||||
...muiProps
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
|
@ -35,16 +35,16 @@ export const Avatar: FC<AvatarProps> = ({
|
|||
{/* If the children is a string, we always want to render the first letter */}
|
||||
{typeof children === "string" ? firstLetter(children) : children}
|
||||
</MuiAvatar>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use it to make an img element behaves like a MaterialUI Icon component
|
||||
*/
|
||||
export const AvatarIcon: FC<{ src: string }> = ({ src }) => {
|
||||
const styles = useStyles()
|
||||
return <img src={src} alt="" className={styles.avatarIcon} />
|
||||
}
|
||||
const styles = useStyles();
|
||||
return <img src={src} alt="" className={styles.avatarIcon} />;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
// Size styles
|
||||
|
@ -77,4 +77,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
objectFit: "contain",
|
||||
},
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { firstLetter } from "./firstLetter"
|
||||
import { firstLetter } from "./firstLetter";
|
||||
|
||||
describe("first-letter", () => {
|
||||
it.each<[string, string]>([
|
||||
|
@ -6,6 +6,6 @@ describe("first-letter", () => {
|
|||
["User", "U"],
|
||||
["test", "T"],
|
||||
])(`firstLetter(%p) returns %p`, (input, expected) => {
|
||||
expect(firstLetter(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
expect(firstLetter(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
*/
|
||||
export const firstLetter = (str: string): string => {
|
||||
if (str.length > 0) {
|
||||
return str[0].toLocaleUpperCase()
|
||||
return str[0].toLocaleUpperCase();
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { AvatarData, AvatarDataProps } from "./AvatarData"
|
||||
import { Story } from "@storybook/react";
|
||||
import { AvatarData, AvatarDataProps } from "./AvatarData";
|
||||
|
||||
export default {
|
||||
title: "components/AvatarData",
|
||||
component: AvatarData,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => (
|
||||
<AvatarData {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const Example = Template.bind({})
|
||||
export const Example = Template.bind({});
|
||||
Example.args = {
|
||||
title: "coder",
|
||||
subtitle: "coder@coder.com",
|
||||
}
|
||||
};
|
||||
|
||||
export const WithImage = Template.bind({})
|
||||
export const WithImage = Template.bind({});
|
||||
WithImage.args = {
|
||||
title: "coder",
|
||||
subtitle: "coder@coder.com",
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { Avatar } from "components/Avatar/Avatar"
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
|
||||
export interface AvatarDataProps {
|
||||
title: string | JSX.Element
|
||||
subtitle?: string
|
||||
src?: string
|
||||
avatar?: React.ReactNode
|
||||
title: string | JSX.Element;
|
||||
subtitle?: string;
|
||||
src?: string;
|
||||
avatar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
|
||||
|
@ -16,10 +16,10 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
|
|||
src,
|
||||
avatar,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
|
||||
if (!avatar) {
|
||||
avatar = <Avatar src={src}>{title}</Avatar>
|
||||
avatar = <Avatar src={src}>{title}</Avatar>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -36,8 +36,8 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
|
|||
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
@ -61,4 +61,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
lineHeight: "150%",
|
||||
maxWidth: 540,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FC } from "react"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import Skeleton from "@mui/material/Skeleton"
|
||||
import { FC } from "react";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
|
||||
export const AvatarDataSkeleton: FC = () => {
|
||||
return (
|
||||
|
@ -12,5 +12,5 @@ export const AvatarDataSkeleton: FC = () => {
|
|||
<Skeleton variant="text" width={60} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import Badge from "@mui/material/Badge"
|
||||
import { useTheme, withStyles } from "@mui/styles"
|
||||
import { FC } from "react"
|
||||
import { WorkspaceBuild } from "api/typesGenerated"
|
||||
import { getDisplayWorkspaceBuildStatus } from "utils/workspace"
|
||||
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
|
||||
import { PaletteIndex } from "theme/theme"
|
||||
import { Theme } from "@mui/material/styles"
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon"
|
||||
import Badge from "@mui/material/Badge";
|
||||
import { useTheme, withStyles } from "@mui/styles";
|
||||
import { FC } from "react";
|
||||
import { WorkspaceBuild } from "api/typesGenerated";
|
||||
import { getDisplayWorkspaceBuildStatus } from "utils/workspace";
|
||||
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
|
||||
import { PaletteIndex } from "theme/theme";
|
||||
import { Theme } from "@mui/material/styles";
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon";
|
||||
|
||||
interface StylesBadgeProps {
|
||||
type: PaletteIndex
|
||||
type: PaletteIndex;
|
||||
}
|
||||
|
||||
const StyledBadge = withStyles((theme) => ({
|
||||
|
@ -22,16 +22,16 @@ const StyledBadge = withStyles((theme) => ({
|
|||
display: "block",
|
||||
padding: 0,
|
||||
},
|
||||
}))(Badge)
|
||||
}))(Badge);
|
||||
|
||||
export interface BuildAvatarProps {
|
||||
build: WorkspaceBuild
|
||||
size?: AvatarProps["size"]
|
||||
build: WorkspaceBuild;
|
||||
size?: AvatarProps["size"];
|
||||
}
|
||||
|
||||
export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
|
||||
const theme = useTheme<Theme>()
|
||||
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build)
|
||||
const theme = useTheme<Theme>();
|
||||
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build);
|
||||
|
||||
return (
|
||||
<StyledBadge
|
||||
|
@ -50,5 +50,5 @@ export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
|
|||
<BuildIcon transition={build.transition} />
|
||||
</Avatar>
|
||||
</StyledBadge>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"
|
||||
import StopOutlined from "@mui/icons-material/StopOutlined"
|
||||
import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
|
||||
import { WorkspaceTransition } from "api/typesGenerated"
|
||||
import { ComponentProps } from "react"
|
||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||
import StopOutlined from "@mui/icons-material/StopOutlined";
|
||||
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
|
||||
import { WorkspaceTransition } from "api/typesGenerated";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
type SVGIcon = typeof PlayArrowOutlined
|
||||
type SVGIcon = typeof PlayArrowOutlined;
|
||||
|
||||
type SVGIconProps = ComponentProps<SVGIcon>
|
||||
type SVGIconProps = ComponentProps<SVGIcon>;
|
||||
|
||||
const iconByTransition: Record<WorkspaceTransition, SVGIcon> = {
|
||||
start: PlayArrowOutlined,
|
||||
stop: StopOutlined,
|
||||
delete: DeleteOutlined,
|
||||
}
|
||||
};
|
||||
|
||||
export const BuildIcon = (
|
||||
props: SVGIconProps & { transition: WorkspaceTransition },
|
||||
) => {
|
||||
const Icon = iconByTransition[props.transition]
|
||||
return <Icon {...props} />
|
||||
}
|
||||
const Icon = iconByTransition[props.transition];
|
||||
return <Icon {...props} />;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { CodeExample, CodeExampleProps } from "./CodeExample"
|
||||
import { Story } from "@storybook/react";
|
||||
import { CodeExample, CodeExampleProps } from "./CodeExample";
|
||||
|
||||
const sampleCode = `echo "Hello, world"`
|
||||
const sampleCode = `echo "Hello, world"`;
|
||||
|
||||
export default {
|
||||
title: "components/CodeExample",
|
||||
|
@ -9,18 +9,18 @@ export default {
|
|||
argTypes: {
|
||||
code: { control: "string", defaultValue: sampleCode },
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<CodeExampleProps> = (args: CodeExampleProps) => (
|
||||
<CodeExample {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const Example = Template.bind({})
|
||||
export const Example = Template.bind({});
|
||||
Example.args = {
|
||||
code: sampleCode,
|
||||
}
|
||||
};
|
||||
|
||||
export const LongCode = Template.bind({})
|
||||
export const LongCode = Template.bind({});
|
||||
LongCode.args = {
|
||||
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { screen } from "@testing-library/react"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { CodeExample } from "./CodeExample"
|
||||
import { screen } from "@testing-library/react";
|
||||
import { render } from "../../testHelpers/renderHelpers";
|
||||
import { CodeExample } from "./CodeExample";
|
||||
|
||||
describe("CodeExample", () => {
|
||||
it("renders code", async () => {
|
||||
// When
|
||||
render(<CodeExample code="echo hello" />)
|
||||
render(<CodeExample code="echo hello" />);
|
||||
|
||||
// Then
|
||||
// Both lines should be rendered
|
||||
await screen.findByText("echo hello")
|
||||
})
|
||||
})
|
||||
await screen.findByText("echo hello");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { makeStyles } from "@mui/styles"
|
||||
import { FC } from "react"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
import { combineClasses } from "../../utils/combineClasses"
|
||||
import { CopyButton } from "../CopyButton/CopyButton"
|
||||
import { Theme } from "@mui/material/styles"
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { FC } from "react";
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants";
|
||||
import { combineClasses } from "../../utils/combineClasses";
|
||||
import { CopyButton } from "../CopyButton/CopyButton";
|
||||
import { Theme } from "@mui/material/styles";
|
||||
|
||||
export interface CodeExampleProps {
|
||||
code: string
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
tooltipTitle?: string
|
||||
inline?: boolean
|
||||
password?: boolean
|
||||
code: string;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
tooltipTitle?: string;
|
||||
inline?: boolean;
|
||||
password?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,7 +24,7 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
|
|||
tooltipTitle,
|
||||
inline,
|
||||
}) => {
|
||||
const styles = useStyles({ inline: inline })
|
||||
const styles = useStyles({ inline: inline });
|
||||
|
||||
return (
|
||||
<div className={combineClasses([styles.root, className])}>
|
||||
|
@ -35,12 +35,12 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
|
|||
buttonClassName={buttonClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
interface styleProps {
|
||||
inline?: boolean
|
||||
password?: boolean
|
||||
inline?: boolean;
|
||||
password?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme, styleProps>((theme) => ({
|
||||
|
@ -65,4 +65,4 @@ const useStyles = makeStyles<Theme, styleProps>((theme) => ({
|
|||
wordBreak: "break-all",
|
||||
"-webkit-text-security": (props) => (props.password ? "disc" : undefined),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { ChooseOne, Cond } from "./ChooseOne"
|
||||
import { Story } from "@storybook/react";
|
||||
import { ChooseOne, Cond } from "./ChooseOne";
|
||||
|
||||
export default {
|
||||
title: "components/Conditionals/ChooseOne",
|
||||
component: ChooseOne,
|
||||
subcomponents: { Cond },
|
||||
}
|
||||
};
|
||||
|
||||
export const FirstIsTrue: Story = () => (
|
||||
<ChooseOne>
|
||||
|
@ -13,7 +13,7 @@ export const FirstIsTrue: Story = () => (
|
|||
<Cond condition={false}>The second one does not show.</Cond>
|
||||
<Cond>The default does not show.</Cond>
|
||||
</ChooseOne>
|
||||
)
|
||||
);
|
||||
|
||||
export const SecondIsTrue: Story = () => (
|
||||
<ChooseOne>
|
||||
|
@ -21,7 +21,7 @@ export const SecondIsTrue: Story = () => (
|
|||
<Cond condition>The second one shows.</Cond>
|
||||
<Cond>The default does not show.</Cond>
|
||||
</ChooseOne>
|
||||
)
|
||||
);
|
||||
|
||||
export const AllAreTrue: Story = () => (
|
||||
<ChooseOne>
|
||||
|
@ -29,7 +29,7 @@ export const AllAreTrue: Story = () => (
|
|||
<Cond condition>The second one does not show.</Cond>
|
||||
<Cond>The default does not show.</Cond>
|
||||
</ChooseOne>
|
||||
)
|
||||
);
|
||||
|
||||
export const NoneAreTrue: Story = () => (
|
||||
<ChooseOne>
|
||||
|
@ -37,10 +37,10 @@ export const NoneAreTrue: Story = () => (
|
|||
<Cond condition={false}>The second one does not show.</Cond>
|
||||
<Cond>The default shows.</Cond>
|
||||
</ChooseOne>
|
||||
)
|
||||
);
|
||||
|
||||
export const OneCond: Story = () => (
|
||||
<ChooseOne>
|
||||
<Cond>An only child renders.</Cond>
|
||||
</ChooseOne>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Children, PropsWithChildren } from "react"
|
||||
import { Children, PropsWithChildren } from "react";
|
||||
|
||||
export interface CondProps {
|
||||
condition?: boolean
|
||||
condition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -14,8 +14,8 @@ export interface CondProps {
|
|||
export const Cond = ({
|
||||
children,
|
||||
}: PropsWithChildren<CondProps>): JSX.Element => {
|
||||
return <>{children}</>
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper component for rendering exactly one of its children. Wrap each child in Cond to associate it
|
||||
|
@ -27,22 +27,22 @@ export const Cond = ({
|
|||
export const ChooseOne = ({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element | null => {
|
||||
const childArray = Children.toArray(children) as JSX.Element[]
|
||||
const childArray = Children.toArray(children) as JSX.Element[];
|
||||
if (childArray.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const conditionedOptions = childArray.slice(0, childArray.length - 1)
|
||||
const defaultCase = childArray[childArray.length - 1]
|
||||
const conditionedOptions = childArray.slice(0, childArray.length - 1);
|
||||
const defaultCase = childArray[childArray.length - 1];
|
||||
if (defaultCase.props.condition !== undefined) {
|
||||
throw new Error(
|
||||
"The last Cond in a ChooseOne was given a condition prop, but it is the default case.",
|
||||
)
|
||||
);
|
||||
}
|
||||
if (conditionedOptions.some((cond) => cond.props.condition === undefined)) {
|
||||
throw new Error(
|
||||
"A non-final Cond in a ChooseOne does not have a condition prop or the prop is undefined.",
|
||||
)
|
||||
);
|
||||
}
|
||||
const chosen = conditionedOptions.find((child) => child.props.condition)
|
||||
return chosen ?? defaultCase
|
||||
}
|
||||
const chosen = conditionedOptions.find((child) => child.props.condition);
|
||||
return chosen ?? defaultCase;
|
||||
};
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { Maybe, MaybeProps } from "./Maybe"
|
||||
import { Story } from "@storybook/react";
|
||||
import { Maybe, MaybeProps } from "./Maybe";
|
||||
|
||||
export default {
|
||||
title: "components/Conditionals/Maybe",
|
||||
component: Maybe,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<MaybeProps> = (args: MaybeProps) => (
|
||||
<Maybe {...args}>Now you see me</Maybe>
|
||||
)
|
||||
);
|
||||
|
||||
export const ConditionIsTrue = Template.bind({})
|
||||
export const ConditionIsTrue = Template.bind({});
|
||||
ConditionIsTrue.args = {
|
||||
condition: true,
|
||||
}
|
||||
};
|
||||
|
||||
export const ConditionIsFalse = Template.bind({})
|
||||
export const ConditionIsFalse = Template.bind({});
|
||||
ConditionIsFalse.args = {
|
||||
condition: false,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PropsWithChildren } from "react"
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export interface MaybeProps {
|
||||
condition: boolean
|
||||
condition: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -13,5 +13,5 @@ export const Maybe = ({
|
|||
children,
|
||||
condition,
|
||||
}: PropsWithChildren<MaybeProps>): JSX.Element | null => {
|
||||
return condition ? <>{children}</> : null
|
||||
}
|
||||
return condition ? <>{children}</> : null;
|
||||
};
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import IconButton from "@mui/material/Button"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import Check from "@mui/icons-material/Check"
|
||||
import { useClipboard } from "hooks/useClipboard"
|
||||
import { combineClasses } from "../../utils/combineClasses"
|
||||
import { FileCopyIcon } from "../Icons/FileCopyIcon"
|
||||
import IconButton from "@mui/material/Button";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Check from "@mui/icons-material/Check";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { combineClasses } from "../../utils/combineClasses";
|
||||
import { FileCopyIcon } from "../Icons/FileCopyIcon";
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
ctaCopy?: string
|
||||
wrapperClassName?: string
|
||||
buttonClassName?: string
|
||||
tooltipTitle?: string
|
||||
text: string;
|
||||
ctaCopy?: string;
|
||||
wrapperClassName?: string;
|
||||
buttonClassName?: string;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
|
||||
export const Language = {
|
||||
tooltipTitle: "Copy to clipboard",
|
||||
ariaLabel: "Copy to clipboard",
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy button used inside the CodeBlock component internally
|
||||
|
@ -29,8 +29,8 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
|
|||
buttonClassName = "",
|
||||
tooltipTitle = Language.tooltipTitle,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const { isCopied, copy: copyToClipboard } = useClipboard(text)
|
||||
const styles = useStyles();
|
||||
const { isCopied, copy: copyToClipboard } = useClipboard(text);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipTitle} placement="top">
|
||||
|
@ -53,8 +53,8 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
|
|||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
copyButtonWrapper: {
|
||||
|
@ -76,4 +76,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
buttonCopy: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { makeStyles } from "@mui/styles"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import { useClickable } from "hooks/useClickable"
|
||||
import { useClipboard } from "hooks/useClipboard"
|
||||
import { FC, HTMLProps } from "react"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { useClickable } from "hooks/useClickable";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { FC, HTMLProps } from "react";
|
||||
import { combineClasses } from "utils/combineClasses";
|
||||
|
||||
interface CopyableValueProps extends HTMLProps<HTMLDivElement> {
|
||||
value: string
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const CopyableValue: FC<CopyableValueProps> = ({
|
||||
|
@ -14,9 +14,9 @@ export const CopyableValue: FC<CopyableValueProps> = ({
|
|||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { isCopied, copy } = useClipboard(value)
|
||||
const clickableProps = useClickable(copy)
|
||||
const styles = useStyles()
|
||||
const { isCopied, copy } = useClipboard(value);
|
||||
const clickableProps = useClickable(copy);
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
|
@ -29,11 +29,11 @@ export const CopyableValue: FC<CopyableValueProps> = ({
|
|||
className={combineClasses([styles.value, className])}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
value: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Box from "@mui/material/Box"
|
||||
import { Theme } from "@mui/material/styles"
|
||||
import useTheme from "@mui/styles/useTheme"
|
||||
import * as TypesGen from "api/typesGenerated"
|
||||
import Box from "@mui/material/Box";
|
||||
import { Theme } from "@mui/material/styles";
|
||||
import useTheme from "@mui/styles/useTheme";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
|
@ -13,16 +13,16 @@ import {
|
|||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import "chartjs-adapter-date-fns"
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipText,
|
||||
} from "components/HelpTooltip/HelpTooltip"
|
||||
import dayjs from "dayjs"
|
||||
import { FC } from "react"
|
||||
import { Bar } from "react-chartjs-2"
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import dayjs from "dayjs";
|
||||
import { FC } from "react";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
|
@ -32,25 +32,25 @@ ChartJS.register(
|
|||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
);
|
||||
|
||||
export interface DAUChartProps {
|
||||
daus: TypesGen.DAUsResponse
|
||||
daus: TypesGen.DAUsResponse;
|
||||
}
|
||||
|
||||
export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
|
||||
const theme: Theme = useTheme()
|
||||
const theme: Theme = useTheme();
|
||||
|
||||
const labels = daus.entries.map((val) => {
|
||||
return dayjs(val.date).format("YYYY-MM-DD")
|
||||
})
|
||||
return dayjs(val.date).format("YYYY-MM-DD");
|
||||
});
|
||||
|
||||
const data = daus.entries.map((val) => {
|
||||
return val.amount
|
||||
})
|
||||
return val.amount;
|
||||
});
|
||||
|
||||
defaults.font.family = theme.typography.fontFamily as string
|
||||
defaults.color = theme.palette.text.secondary
|
||||
defaults.font.family = theme.typography.fontFamily as string;
|
||||
defaults.color = theme.palette.text.secondary;
|
||||
|
||||
const options: ChartOptions<"bar"> = {
|
||||
responsive: true,
|
||||
|
@ -62,8 +62,8 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
|
|||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const date = new Date(context[0].parsed.x)
|
||||
return date.toLocaleDateString()
|
||||
const date = new Date(context[0].parsed.x);
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -86,7 +86,7 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
|
|||
},
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Bar
|
||||
|
@ -108,8 +108,8 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
|
|||
}}
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const DAUTitle = () => {
|
||||
return (
|
||||
|
@ -125,5 +125,5 @@ export const DAUTitle = () => {
|
|||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { renderWithAuth } from "testHelpers/renderHelpers"
|
||||
import { DashboardLayout } from "./DashboardLayout"
|
||||
import * as API from "api/api"
|
||||
import { screen } from "@testing-library/react"
|
||||
import { renderWithAuth } from "testHelpers/renderHelpers";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import * as API from "api/api";
|
||||
import { screen } from "@testing-library/react";
|
||||
|
||||
test("Show the new Coder version notification", async () => {
|
||||
jest.spyOn(API, "getUpdateCheck").mockResolvedValue({
|
||||
current: false,
|
||||
version: "v0.12.9",
|
||||
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
|
||||
})
|
||||
});
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
})
|
||||
await screen.findByTestId("update-check-snackbar")
|
||||
})
|
||||
});
|
||||
await screen.findByTestId("update-check-snackbar");
|
||||
});
|
||||
|
|
|
@ -1,33 +1,33 @@
|
|||
import { makeStyles } from "@mui/styles"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner"
|
||||
import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import { FC, Suspense } from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { dashboardContentBottomPadding } from "theme/constants"
|
||||
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
|
||||
import { Navbar } from "./Navbar/Navbar"
|
||||
import Snackbar from "@mui/material/Snackbar"
|
||||
import Link from "@mui/material/Link"
|
||||
import Box, { BoxProps } from "@mui/material/Box"
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined"
|
||||
import Button from "@mui/material/Button"
|
||||
import { docs } from "utils/docs"
|
||||
import { HealthBanner } from "./HealthBanner"
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
|
||||
import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { ServiceBanner } from "components/Dashboard/ServiceBanner/ServiceBanner";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { FC, Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { dashboardContentBottomPadding } from "theme/constants";
|
||||
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService";
|
||||
import { Navbar } from "./Navbar/Navbar";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import Link from "@mui/material/Link";
|
||||
import Box, { BoxProps } from "@mui/material/Box";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { docs } from "utils/docs";
|
||||
import { HealthBanner } from "./HealthBanner";
|
||||
|
||||
export const DashboardLayout: FC = () => {
|
||||
const styles = useStyles()
|
||||
const permissions = usePermissions()
|
||||
const styles = useStyles();
|
||||
const permissions = usePermissions();
|
||||
const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, {
|
||||
context: {
|
||||
permissions,
|
||||
},
|
||||
})
|
||||
const { updateCheck } = updateCheckState.context
|
||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
|
||||
});
|
||||
const { updateCheck } = updateCheckState.context;
|
||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -99,8 +99,8 @@ export const DashboardLayout: FC = () => {
|
|||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardFullPage = (props: BoxProps) => {
|
||||
return (
|
||||
|
@ -116,8 +116,8 @@ export const DashboardFullPage = (props: BoxProps) => {
|
|||
minHeight: "100%",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
site: {
|
||||
|
@ -131,4 +131,4 @@ const useStyles = makeStyles({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
|
|
@ -1,62 +1,62 @@
|
|||
import { useMachine } from "@xstate/react"
|
||||
import { useMachine } from "@xstate/react";
|
||||
import {
|
||||
AppearanceConfig,
|
||||
BuildInfoResponse,
|
||||
Entitlements,
|
||||
Experiments,
|
||||
} from "api/typesGenerated"
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
|
||||
import { createContext, FC, PropsWithChildren, useContext } from "react"
|
||||
import { appearanceMachine } from "xServices/appearance/appearanceXService"
|
||||
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService"
|
||||
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"
|
||||
import { experimentsMachine } from "xServices/experiments/experimentsMachine"
|
||||
} from "api/typesGenerated";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { createContext, FC, PropsWithChildren, useContext } from "react";
|
||||
import { appearanceMachine } from "xServices/appearance/appearanceXService";
|
||||
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService";
|
||||
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService";
|
||||
import { experimentsMachine } from "xServices/experiments/experimentsMachine";
|
||||
|
||||
interface Appearance {
|
||||
config: AppearanceConfig
|
||||
preview: boolean
|
||||
setPreview: (config: AppearanceConfig) => void
|
||||
save: (config: AppearanceConfig) => void
|
||||
config: AppearanceConfig;
|
||||
preview: boolean;
|
||||
setPreview: (config: AppearanceConfig) => void;
|
||||
save: (config: AppearanceConfig) => void;
|
||||
}
|
||||
|
||||
interface DashboardProviderValue {
|
||||
buildInfo: BuildInfoResponse
|
||||
entitlements: Entitlements
|
||||
appearance: Appearance
|
||||
experiments: Experiments
|
||||
buildInfo: BuildInfoResponse;
|
||||
entitlements: Entitlements;
|
||||
appearance: Appearance;
|
||||
experiments: Experiments;
|
||||
}
|
||||
|
||||
export const DashboardProviderContext = createContext<
|
||||
DashboardProviderValue | undefined
|
||||
>(undefined)
|
||||
>(undefined);
|
||||
|
||||
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [buildInfoState] = useMachine(buildInfoMachine)
|
||||
const [entitlementsState] = useMachine(entitlementsMachine)
|
||||
const [appearanceState, appearanceSend] = useMachine(appearanceMachine)
|
||||
const [experimentsState] = useMachine(experimentsMachine)
|
||||
const { buildInfo } = buildInfoState.context
|
||||
const { entitlements } = entitlementsState.context
|
||||
const { appearance, preview } = appearanceState.context
|
||||
const { experiments } = experimentsState.context
|
||||
const isLoading = !buildInfo || !entitlements || !appearance || !experiments
|
||||
const [buildInfoState] = useMachine(buildInfoMachine);
|
||||
const [entitlementsState] = useMachine(entitlementsMachine);
|
||||
const [appearanceState, appearanceSend] = useMachine(appearanceMachine);
|
||||
const [experimentsState] = useMachine(experimentsMachine);
|
||||
const { buildInfo } = buildInfoState.context;
|
||||
const { entitlements } = entitlementsState.context;
|
||||
const { appearance, preview } = appearanceState.context;
|
||||
const { experiments } = experimentsState.context;
|
||||
const isLoading = !buildInfo || !entitlements || !appearance || !experiments;
|
||||
|
||||
const setAppearancePreview = (config: AppearanceConfig) => {
|
||||
appearanceSend({
|
||||
type: "SET_PREVIEW_APPEARANCE",
|
||||
appearance: config,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveAppearance = (config: AppearanceConfig) => {
|
||||
appearanceSend({
|
||||
type: "SAVE_APPEARANCE",
|
||||
appearance: config,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <FullScreenLoader />
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -75,25 +75,27 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
>
|
||||
{children}
|
||||
</DashboardProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useDashboard = (): DashboardProviderValue => {
|
||||
const context = useContext(DashboardProviderContext)
|
||||
const context = useContext(DashboardProviderContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useDashboard only can be used inside of DashboardProvider")
|
||||
throw new Error(
|
||||
"useDashboard only can be used inside of DashboardProvider",
|
||||
);
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useIsWorkspaceActionsEnabled = (): boolean => {
|
||||
const { entitlements, experiments } = useDashboard()
|
||||
const { entitlements, experiments } = useDashboard();
|
||||
const allowAdvancedScheduling =
|
||||
entitlements.features["advanced_template_scheduling"].enabled
|
||||
entitlements.features["advanced_template_scheduling"].enabled;
|
||||
// This check can be removed when https://github.com/coder/coder/milestone/19
|
||||
// is merged up
|
||||
const allowWorkspaceActions = experiments.includes("workspace_actions")
|
||||
return allowWorkspaceActions && allowAdvancedScheduling
|
||||
}
|
||||
const allowWorkspaceActions = experiments.includes("workspace_actions");
|
||||
return allowWorkspaceActions && allowAdvancedScheduling;
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useMachine } from "@xstate/react"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import { DeploymentBannerView } from "./DeploymentBannerView"
|
||||
import { deploymentStatsMachine } from "xServices/deploymentStats/deploymentStatsMachine"
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { DeploymentBannerView } from "./DeploymentBannerView";
|
||||
import { deploymentStatsMachine } from "xServices/deploymentStats/deploymentStatsMachine";
|
||||
|
||||
export const DeploymentBanner: React.FC = () => {
|
||||
const permissions = usePermissions()
|
||||
const [state, sendEvent] = useMachine(deploymentStatsMachine)
|
||||
const permissions = usePermissions();
|
||||
const [state, sendEvent] = useMachine(deploymentStatsMachine);
|
||||
|
||||
if (!permissions.viewDeploymentValues || !state.context.deploymentStats) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -16,5 +16,5 @@ export const DeploymentBanner: React.FC = () => {
|
|||
stats={state.context.deploymentStats}
|
||||
fetchStats={() => sendEvent("RELOAD")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { MockDeploymentStats } from "testHelpers/entities"
|
||||
import { Story } from "@storybook/react";
|
||||
import { MockDeploymentStats } from "testHelpers/entities";
|
||||
import {
|
||||
DeploymentBannerView,
|
||||
DeploymentBannerViewProps,
|
||||
} from "./DeploymentBannerView"
|
||||
} from "./DeploymentBannerView";
|
||||
|
||||
export default {
|
||||
title: "components/DeploymentBannerView",
|
||||
component: DeploymentBannerView,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<DeploymentBannerViewProps> = (args) => (
|
||||
<DeploymentBannerView {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const Preview = Template.bind({})
|
||||
export const Preview = Template.bind({});
|
||||
Preview.args = {
|
||||
stats: MockDeploymentStats,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,83 +1,83 @@
|
|||
import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated"
|
||||
import { FC, useMemo, useEffect, useState } from "react"
|
||||
import prettyBytes from "pretty-bytes"
|
||||
import BuildingIcon from "@mui/icons-material/Build"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { RocketIcon } from "components/Icons/RocketIcon"
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import Link from "@mui/material/Link"
|
||||
import { VSCodeIcon } from "components/Icons/VSCodeIcon"
|
||||
import DownloadIcon from "@mui/icons-material/CloudDownload"
|
||||
import UploadIcon from "@mui/icons-material/CloudUpload"
|
||||
import LatencyIcon from "@mui/icons-material/SettingsEthernet"
|
||||
import WebTerminalIcon from "@mui/icons-material/WebAsset"
|
||||
import { TerminalIcon } from "components/Icons/TerminalIcon"
|
||||
import dayjs from "dayjs"
|
||||
import CollectedIcon from "@mui/icons-material/Compare"
|
||||
import RefreshIcon from "@mui/icons-material/Refresh"
|
||||
import Button from "@mui/material/Button"
|
||||
import { getDisplayWorkspaceStatus } from "utils/workspace"
|
||||
import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated";
|
||||
import { FC, useMemo, useEffect, useState } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import BuildingIcon from "@mui/icons-material/Build";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { RocketIcon } from "components/Icons/RocketIcon";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Link from "@mui/material/Link";
|
||||
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
|
||||
import DownloadIcon from "@mui/icons-material/CloudDownload";
|
||||
import UploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LatencyIcon from "@mui/icons-material/SettingsEthernet";
|
||||
import WebTerminalIcon from "@mui/icons-material/WebAsset";
|
||||
import { TerminalIcon } from "components/Icons/TerminalIcon";
|
||||
import dayjs from "dayjs";
|
||||
import CollectedIcon from "@mui/icons-material/Compare";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import Button from "@mui/material/Button";
|
||||
import { getDisplayWorkspaceStatus } from "utils/workspace";
|
||||
|
||||
export const bannerHeight = 36
|
||||
export const bannerHeight = 36;
|
||||
|
||||
export interface DeploymentBannerViewProps {
|
||||
fetchStats?: () => void
|
||||
stats?: DeploymentStats
|
||||
fetchStats?: () => void;
|
||||
stats?: DeploymentStats;
|
||||
}
|
||||
|
||||
export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
||||
stats,
|
||||
fetchStats,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
const aggregatedMinutes = useMemo(() => {
|
||||
if (!stats) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes")
|
||||
}, [stats])
|
||||
const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1
|
||||
const [timeUntilRefresh, setTimeUntilRefresh] = useState(0)
|
||||
return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes");
|
||||
}, [stats]);
|
||||
const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1;
|
||||
const [timeUntilRefresh, setTimeUntilRefresh] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!stats || !fetchStats) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let timeUntilRefresh = dayjs(stats.next_update_at).diff(
|
||||
stats.collected_at,
|
||||
"seconds",
|
||||
)
|
||||
setTimeUntilRefresh(timeUntilRefresh)
|
||||
let canceled = false
|
||||
);
|
||||
setTimeUntilRefresh(timeUntilRefresh);
|
||||
let canceled = false;
|
||||
const loop = () => {
|
||||
if (canceled) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
setTimeUntilRefresh(timeUntilRefresh--)
|
||||
setTimeUntilRefresh(timeUntilRefresh--);
|
||||
if (timeUntilRefresh > 0) {
|
||||
return window.setTimeout(loop, 1000)
|
||||
return window.setTimeout(loop, 1000);
|
||||
}
|
||||
fetchStats()
|
||||
}
|
||||
const timeout = setTimeout(loop, 1000)
|
||||
fetchStats();
|
||||
};
|
||||
const timeout = setTimeout(loop, 1000);
|
||||
return () => {
|
||||
canceled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [fetchStats, stats])
|
||||
canceled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [fetchStats, stats]);
|
||||
const lastAggregated = useMemo(() => {
|
||||
if (!stats) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (!fetchStats) {
|
||||
// Storybook!
|
||||
return "just now"
|
||||
return "just now";
|
||||
}
|
||||
return dayjs().to(dayjs(stats.collected_at))
|
||||
return dayjs().to(dayjs(stats.collected_at));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- We want this to periodically update!
|
||||
}, [timeUntilRefresh, stats])
|
||||
}, [timeUntilRefresh, stats]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
@ -194,7 +194,7 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
|||
className={`${styles.value} ${styles.refreshButton}`}
|
||||
onClick={() => {
|
||||
if (fetchStats) {
|
||||
fetchStats()
|
||||
fetchStats();
|
||||
}
|
||||
}}
|
||||
variant="text"
|
||||
|
@ -205,25 +205,25 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const ValueSeparator: FC = () => {
|
||||
const styles = useStyles()
|
||||
return <div className={styles.valueSeparator}>/</div>
|
||||
}
|
||||
const styles = useStyles();
|
||||
return <div className={styles.valueSeparator}>/</div>;
|
||||
};
|
||||
|
||||
const WorkspaceBuildValue: FC<{
|
||||
status: WorkspaceStatus
|
||||
count?: number
|
||||
status: WorkspaceStatus;
|
||||
count?: number;
|
||||
}> = ({ status, count }) => {
|
||||
const styles = useStyles()
|
||||
const displayStatus = getDisplayWorkspaceStatus(status)
|
||||
let statusText = displayStatus.text
|
||||
let icon = displayStatus.icon
|
||||
const styles = useStyles();
|
||||
const displayStatus = getDisplayWorkspaceStatus(status);
|
||||
let statusText = displayStatus.text;
|
||||
let icon = displayStatus.icon;
|
||||
if (status === "starting") {
|
||||
icon = <BuildingIcon />
|
||||
statusText = "Building"
|
||||
icon = <BuildingIcon />;
|
||||
statusText = "Building";
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -238,8 +238,8 @@ const WorkspaceBuildValue: FC<{
|
|||
</div>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
rocket: {
|
||||
|
@ -324,4 +324,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
marginRight: theme.spacing(0.5),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { Alert } from "components/Alert/Alert"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import Link from "@mui/material/Link"
|
||||
import { colors } from "theme/colors"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getHealth } from "api/api"
|
||||
import { useDashboard } from "./DashboardProvider"
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Link from "@mui/material/Link";
|
||||
import { colors } from "theme/colors";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHealth } from "api/api";
|
||||
import { useDashboard } from "./DashboardProvider";
|
||||
|
||||
export const HealthBanner = () => {
|
||||
const { data: healthStatus } = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => getHealth(),
|
||||
})
|
||||
const dashboard = useDashboard()
|
||||
const hasHealthIssues = healthStatus && !healthStatus.data.healthy
|
||||
});
|
||||
const dashboard = useDashboard();
|
||||
const hasHealthIssues = healthStatus && !healthStatus.data.healthy;
|
||||
|
||||
if (
|
||||
dashboard.experiments.includes("deployment_health_page") &&
|
||||
|
@ -38,8 +38,8 @@ export const HealthBanner = () => {
|
|||
</Link>
|
||||
.
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { LicenseBannerView } from "./LicenseBannerView"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { LicenseBannerView } from "./LicenseBannerView";
|
||||
|
||||
export const LicenseBanner: React.FC = () => {
|
||||
const { entitlements } = useDashboard()
|
||||
const { errors, warnings } = entitlements
|
||||
const { entitlements } = useDashboard();
|
||||
const { errors, warnings } = entitlements;
|
||||
|
||||
if (errors.length > 0 || warnings.length > 0) {
|
||||
return <LicenseBannerView errors={errors} warnings={warnings} />
|
||||
return <LicenseBannerView errors={errors} warnings={warnings} />;
|
||||
} else {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,34 +1,34 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView"
|
||||
import { Story } from "@storybook/react";
|
||||
import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView";
|
||||
|
||||
export default {
|
||||
title: "components/LicenseBannerView",
|
||||
component: LicenseBannerView,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<LicenseBannerViewProps> = (args) => (
|
||||
<LicenseBannerView {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const OneWarning = Template.bind({})
|
||||
export const OneWarning = Template.bind({});
|
||||
OneWarning.args = {
|
||||
errors: [],
|
||||
warnings: ["You have exceeded the number of seats in your license."],
|
||||
}
|
||||
};
|
||||
|
||||
export const TwoWarnings = Template.bind({})
|
||||
export const TwoWarnings = Template.bind({});
|
||||
TwoWarnings.args = {
|
||||
errors: [],
|
||||
warnings: [
|
||||
"You have exceeded the number of seats in your license.",
|
||||
"You are flying too close to the sun.",
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export const OneError = Template.bind({})
|
||||
export const OneError = Template.bind({});
|
||||
OneError.args = {
|
||||
errors: [
|
||||
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import Link from "@mui/material/Link"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { Expander } from "components/Expander/Expander"
|
||||
import { Pill } from "components/Pill/Pill"
|
||||
import { useState } from "react"
|
||||
import { colors } from "theme/colors"
|
||||
import Link from "@mui/material/Link";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Expander } from "components/Expander/Expander";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { useState } from "react";
|
||||
import { colors } from "theme/colors";
|
||||
|
||||
export const Language = {
|
||||
licenseIssue: "License Issue",
|
||||
|
@ -12,22 +12,22 @@ export const Language = {
|
|||
exceeded: "It looks like you've exceeded some limits of your license.",
|
||||
lessDetails: "Less",
|
||||
moreDetails: "More",
|
||||
}
|
||||
};
|
||||
|
||||
export interface LicenseBannerViewProps {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
|
||||
errors,
|
||||
warnings,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const isError = errors.length > 0
|
||||
const messages = [...errors, ...warnings]
|
||||
const type = isError ? "error" : "warning"
|
||||
const styles = useStyles();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const isError = errors.length > 0;
|
||||
const messages = [...errors, ...warnings];
|
||||
const type = isError ? "error" : "warning";
|
||||
|
||||
if (messages.length === 1) {
|
||||
return (
|
||||
|
@ -41,7 +41,7 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={`${styles.container} ${type}`}>
|
||||
|
@ -73,9 +73,9 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
|
|||
</Expander>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
|
@ -103,4 +103,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
listItem: {
|
||||
margin: theme.spacing(0.5),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { App } from "app"
|
||||
import { Language } from "./NavbarView"
|
||||
import { rest } from "msw"
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { App } from "app";
|
||||
import { Language } from "./NavbarView";
|
||||
import { rest } from "msw";
|
||||
import {
|
||||
MockEntitlementsWithAuditLog,
|
||||
MockMemberPermissions,
|
||||
} from "testHelpers/entities"
|
||||
import { server } from "testHelpers/server"
|
||||
} from "testHelpers/entities";
|
||||
import { server } from "testHelpers/server";
|
||||
|
||||
/**
|
||||
* The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their
|
||||
|
@ -17,52 +17,52 @@ describe("Navbar", () => {
|
|||
// set entitlements to allow audit log
|
||||
server.use(
|
||||
rest.get("/api/v2/entitlements", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
|
||||
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog));
|
||||
}),
|
||||
)
|
||||
render(<App />)
|
||||
);
|
||||
render(<App />);
|
||||
await waitFor(
|
||||
() => {
|
||||
const link = screen.getByText(Language.audit)
|
||||
expect(link).toBeDefined()
|
||||
const link = screen.getByText(Language.audit);
|
||||
expect(link).toBeDefined();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show Audit Log link when not entitled", async () => {
|
||||
// by default, user is an Admin with permission to see the audit log,
|
||||
// but is unlicensed so not entitled to see the audit log
|
||||
render(<App />)
|
||||
render(<App />);
|
||||
await waitFor(
|
||||
() => {
|
||||
const link = screen.queryByText(Language.audit)
|
||||
expect(link).toBe(null)
|
||||
const link = screen.queryByText(Language.audit);
|
||||
expect(link).toBe(null);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show Audit Log link when not permitted via role", async () => {
|
||||
// set permissions to Member (can't audit)
|
||||
server.use(
|
||||
rest.post("/api/v2/authcheck", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockMemberPermissions))
|
||||
return res(ctx.status(200), ctx.json(MockMemberPermissions));
|
||||
}),
|
||||
)
|
||||
);
|
||||
// set entitlements to allow audit log
|
||||
server.use(
|
||||
rest.get("/api/v2/entitlements", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
|
||||
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog));
|
||||
}),
|
||||
)
|
||||
render(<App />)
|
||||
);
|
||||
render(<App />);
|
||||
await waitFor(
|
||||
() => {
|
||||
const link = screen.queryByText(Language.audit)
|
||||
expect(link).toBe(null)
|
||||
const link = screen.queryByText(Language.audit);
|
||||
expect(link).toBe(null);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import { useAuth } from "components/AuthProvider/AuthProvider"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
|
||||
import { useMe } from "hooks/useMe"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import { FC } from "react"
|
||||
import { NavbarView } from "./NavbarView"
|
||||
import { useProxy } from "contexts/ProxyContext"
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { FC } from "react";
|
||||
import { NavbarView } from "./NavbarView";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
|
||||
export const Navbar: FC = () => {
|
||||
const { appearance, buildInfo } = useDashboard()
|
||||
const [_, authSend] = useAuth()
|
||||
const me = useMe()
|
||||
const permissions = usePermissions()
|
||||
const featureVisibility = useFeatureVisibility()
|
||||
const { appearance, buildInfo } = useDashboard();
|
||||
const [_, authSend] = useAuth();
|
||||
const me = useMe();
|
||||
const permissions = usePermissions();
|
||||
const featureVisibility = useFeatureVisibility();
|
||||
const canViewAuditLog =
|
||||
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
|
||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
|
||||
const canViewAllUsers = Boolean(permissions.readAllUsers)
|
||||
const onSignOut = () => authSend("SIGN_OUT")
|
||||
const proxyContextValue = useProxy()
|
||||
const dashboard = useDashboard()
|
||||
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog);
|
||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
|
||||
const canViewAllUsers = Boolean(permissions.readAllUsers);
|
||||
const onSignOut = () => authSend("SIGN_OUT");
|
||||
const proxyContextValue = useProxy();
|
||||
const dashboard = useDashboard();
|
||||
|
||||
return (
|
||||
<NavbarView
|
||||
|
@ -35,5 +35,5 @@ export const Navbar: FC = () => {
|
|||
dashboard.experiments.includes("moons") ? proxyContextValue : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { MockUser, MockUser2 } from "../../../testHelpers/entities"
|
||||
import { NavbarView, NavbarViewProps } from "./NavbarView"
|
||||
import { Story } from "@storybook/react";
|
||||
import { MockUser, MockUser2 } from "../../../testHelpers/entities";
|
||||
import { NavbarView, NavbarViewProps } from "./NavbarView";
|
||||
|
||||
export default {
|
||||
title: "components/NavbarView",
|
||||
|
@ -8,38 +8,38 @@ export default {
|
|||
argTypes: {
|
||||
onSignOut: { action: "Sign Out" },
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => (
|
||||
<NavbarView {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const ForAdmin = Template.bind({})
|
||||
export const ForAdmin = Template.bind({});
|
||||
ForAdmin.args = {
|
||||
user: MockUser,
|
||||
onSignOut: () => {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const ForMember = Template.bind({})
|
||||
export const ForMember = Template.bind({});
|
||||
ForMember.args = {
|
||||
user: MockUser2,
|
||||
onSignOut: () => {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const SmallViewport = Template.bind({})
|
||||
export const SmallViewport = Template.bind({});
|
||||
SmallViewport.args = {
|
||||
user: MockUser,
|
||||
onSignOut: () => {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
},
|
||||
}
|
||||
};
|
||||
SmallViewport.parameters = {
|
||||
viewport: {
|
||||
defaultViewport: "tablet",
|
||||
},
|
||||
chromatic: { viewports: [420] },
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { screen } from "@testing-library/react"
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
MockPrimaryWorkspaceProxy,
|
||||
MockUser,
|
||||
MockUser2,
|
||||
} from "../../../testHelpers/entities"
|
||||
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
|
||||
import { Language as navLanguage, NavbarView } from "./NavbarView"
|
||||
import { ProxyContextValue } from "contexts/ProxyContext"
|
||||
import { action } from "@storybook/addon-actions"
|
||||
} from "../../../testHelpers/entities";
|
||||
import { renderWithAuth } from "../../../testHelpers/renderHelpers";
|
||||
import { Language as navLanguage, NavbarView } from "./NavbarView";
|
||||
import { ProxyContextValue } from "contexts/ProxyContext";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
|
||||
const proxyContextValue: ProxyContextValue = {
|
||||
proxy: {
|
||||
|
@ -21,24 +21,24 @@ const proxyContextValue: ProxyContextValue = {
|
|||
clearProxy: action("clearProxy"),
|
||||
refetchProxyLatencies: jest.fn(),
|
||||
proxyLatencies: {},
|
||||
}
|
||||
};
|
||||
|
||||
describe("NavbarView", () => {
|
||||
const noop = () => {
|
||||
return
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const env = process.env
|
||||
const env = process.env;
|
||||
|
||||
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
|
||||
beforeEach(() => {
|
||||
process.env = { ...env }
|
||||
})
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
// REMARK: restoring process.env
|
||||
afterEach(() => {
|
||||
process.env = env
|
||||
})
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it("workspaces nav link has the correct href", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -50,10 +50,10 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const workspacesLink = await screen.findByText(navLanguage.workspaces)
|
||||
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
|
||||
})
|
||||
);
|
||||
const workspacesLink = await screen.findByText(navLanguage.workspaces);
|
||||
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces");
|
||||
});
|
||||
|
||||
it("templates nav link has the correct href", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -65,10 +65,10 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const templatesLink = await screen.findByText(navLanguage.templates)
|
||||
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
|
||||
})
|
||||
);
|
||||
const templatesLink = await screen.findByText(navLanguage.templates);
|
||||
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates");
|
||||
});
|
||||
|
||||
it("users nav link has the correct href", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -80,10 +80,10 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const userLink = await screen.findByText(navLanguage.users)
|
||||
expect((userLink as HTMLAnchorElement).href).toContain("/users")
|
||||
})
|
||||
);
|
||||
const userLink = await screen.findByText(navLanguage.users);
|
||||
expect((userLink as HTMLAnchorElement).href).toContain("/users");
|
||||
});
|
||||
|
||||
it("renders profile picture for user", async () => {
|
||||
// Given
|
||||
|
@ -91,7 +91,7 @@ describe("NavbarView", () => {
|
|||
...MockUser,
|
||||
username: "bryan",
|
||||
avatar_url: "",
|
||||
}
|
||||
};
|
||||
|
||||
// When
|
||||
renderWithAuth(
|
||||
|
@ -103,13 +103,13 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
);
|
||||
|
||||
// Then
|
||||
// There should be a 'B' avatar!
|
||||
const element = await screen.findByText("B")
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
const element = await screen.findByText("B");
|
||||
expect(element).toBeDefined();
|
||||
});
|
||||
|
||||
it("audit nav link has the correct href", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -121,10 +121,10 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = await screen.findByText(navLanguage.audit)
|
||||
expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
|
||||
})
|
||||
);
|
||||
const auditLink = await screen.findByText(navLanguage.audit);
|
||||
expect((auditLink as HTMLAnchorElement).href).toContain("/audit");
|
||||
});
|
||||
|
||||
it("audit nav link is hidden for members", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -136,10 +136,10 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = screen.queryByText(navLanguage.audit)
|
||||
expect(auditLink).not.toBeInTheDocument()
|
||||
})
|
||||
);
|
||||
const auditLink = screen.queryByText(navLanguage.audit);
|
||||
expect(auditLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deployment nav link has the correct href", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -151,12 +151,12 @@ describe("NavbarView", () => {
|
|||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = await screen.findByText(navLanguage.deployment)
|
||||
);
|
||||
const auditLink = await screen.findByText(navLanguage.deployment);
|
||||
expect((auditLink as HTMLAnchorElement).href).toContain(
|
||||
"/deployment/general",
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deployment nav link is hidden for members", async () => {
|
||||
renderWithAuth(
|
||||
|
@ -168,8 +168,8 @@ describe("NavbarView", () => {
|
|||
canViewDeployment={false}
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = screen.queryByText(navLanguage.deployment)
|
||||
expect(auditLink).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
);
|
||||
const auditLink = screen.queryByText(navLanguage.deployment);
|
||||
expect(auditLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,43 +1,45 @@
|
|||
import Drawer from "@mui/material/Drawer"
|
||||
import IconButton from "@mui/material/IconButton"
|
||||
import List from "@mui/material/List"
|
||||
import ListItem from "@mui/material/ListItem"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import MenuIcon from "@mui/icons-material/Menu"
|
||||
import { CoderIcon } from "components/Icons/CoderIcon"
|
||||
import { FC, useRef, useState } from "react"
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom"
|
||||
import { colors } from "theme/colors"
|
||||
import * as TypesGen from "../../../api/typesGenerated"
|
||||
import { navHeight } from "../../../theme/constants"
|
||||
import { combineClasses } from "../../../utils/combineClasses"
|
||||
import { UserDropdown } from "./UserDropdown/UserDropdown"
|
||||
import Box from "@mui/material/Box"
|
||||
import Menu from "@mui/material/Menu"
|
||||
import Button from "@mui/material/Button"
|
||||
import MenuItem from "@mui/material/MenuItem"
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"
|
||||
import { ProxyContextValue } from "contexts/ProxyContext"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import Divider from "@mui/material/Divider"
|
||||
import Skeleton from "@mui/material/Skeleton"
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme"
|
||||
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import Typography from "@mui/material/Typography"
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { colors } from "theme/colors";
|
||||
import * as TypesGen from "../../../api/typesGenerated";
|
||||
import { navHeight } from "../../../theme/constants";
|
||||
import { combineClasses } from "../../../utils/combineClasses";
|
||||
import { UserDropdown } from "./UserDropdown/UserDropdown";
|
||||
import Box from "@mui/material/Box";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import { ProxyContextValue } from "contexts/ProxyContext";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme";
|
||||
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
export const USERS_LINK = `/users?filter=${encodeURIComponent("status:active")}`
|
||||
export const USERS_LINK = `/users?filter=${encodeURIComponent(
|
||||
"status:active",
|
||||
)}`;
|
||||
|
||||
export interface NavbarViewProps {
|
||||
logo_url?: string
|
||||
user?: TypesGen.User
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
supportLinks?: TypesGen.LinkConfig[]
|
||||
onSignOut: () => void
|
||||
canViewAuditLog: boolean
|
||||
canViewDeployment: boolean
|
||||
canViewAllUsers: boolean
|
||||
proxyContextValue?: ProxyContextValue
|
||||
logo_url?: string;
|
||||
user?: TypesGen.User;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
supportLinks?: TypesGen.LinkConfig[];
|
||||
onSignOut: () => void;
|
||||
canViewAuditLog: boolean;
|
||||
canViewDeployment: boolean;
|
||||
canViewAllUsers: boolean;
|
||||
proxyContextValue?: ProxyContextValue;
|
||||
}
|
||||
|
||||
export const Language = {
|
||||
|
@ -46,18 +48,18 @@ export const Language = {
|
|||
users: "Users",
|
||||
audit: "Audit",
|
||||
deployment: "Deployment",
|
||||
}
|
||||
};
|
||||
|
||||
const NavItems: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
className?: string
|
||||
canViewAuditLog: boolean
|
||||
canViewDeployment: boolean
|
||||
canViewAllUsers: boolean
|
||||
className?: string;
|
||||
canViewAuditLog: boolean;
|
||||
canViewDeployment: boolean;
|
||||
canViewAllUsers: boolean;
|
||||
}>
|
||||
> = ({ className, canViewAuditLog, canViewDeployment, canViewAllUsers }) => {
|
||||
const styles = useStyles()
|
||||
const location = useLocation()
|
||||
const styles = useStyles();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<List className={combineClasses([styles.navItems, className])}>
|
||||
|
@ -99,8 +101,8 @@ const NavItems: React.FC<
|
|||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
export const NavbarView: FC<NavbarViewProps> = ({
|
||||
user,
|
||||
logo_url,
|
||||
|
@ -112,8 +114,8 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
canViewAllUsers,
|
||||
proxyContextValue,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const styles = useStyles();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className={styles.root}>
|
||||
|
@ -122,7 +124,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
aria-label="Open menu"
|
||||
className={styles.mobileMenuButton}
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(true)
|
||||
setIsDrawerOpen(true);
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
|
@ -188,30 +190,30 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
</Box>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
proxyContextValue,
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [refetchDate, setRefetchDate] = useState<Date>()
|
||||
const selectedProxy = proxyContextValue.proxy.proxy
|
||||
const refreshLatencies = proxyContextValue.refetchProxyLatencies
|
||||
const closeMenu = () => setIsOpen(false)
|
||||
const navigate = useNavigate()
|
||||
const latencies = proxyContextValue.proxyLatencies
|
||||
const isLoadingLatencies = Object.keys(latencies).length === 0
|
||||
const isLoading = proxyContextValue.isLoading || isLoadingLatencies
|
||||
const permissions = usePermissions()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [refetchDate, setRefetchDate] = useState<Date>();
|
||||
const selectedProxy = proxyContextValue.proxy.proxy;
|
||||
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
const navigate = useNavigate();
|
||||
const latencies = proxyContextValue.proxyLatencies;
|
||||
const isLoadingLatencies = Object.keys(latencies).length === 0;
|
||||
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
|
||||
const permissions = usePermissions();
|
||||
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
|
||||
if (!refetchDate) {
|
||||
// Only show loading if the user manually requested a refetch
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const latency = latencies?.[proxy.id]
|
||||
const latency = latencies?.[proxy.id];
|
||||
// Only show a loading spinner if:
|
||||
// - A latency exists. This means the latency was fetched at some point, so the
|
||||
// loader *should* be resolved.
|
||||
|
@ -220,11 +222,11 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
|||
// is stale and we should show a loading spinner until the new latency is
|
||||
// fetched.
|
||||
if (proxy.healthy && latency && latency.at < refetchDate) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -233,7 +235,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
|||
height={BUTTON_SM_HEIGHT}
|
||||
sx={{ borderRadius: "4px", transform: "none" }}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -315,21 +317,21 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
|||
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
|
||||
{proxyContextValue.proxies
|
||||
?.sort((a, b) => {
|
||||
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity
|
||||
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity
|
||||
return latencyA - latencyB
|
||||
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
|
||||
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
|
||||
return latencyA - latencyB;
|
||||
})
|
||||
.map((proxy) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!proxy.healthy) {
|
||||
displayError("Please select a healthy workspace proxy.")
|
||||
closeMenu()
|
||||
return
|
||||
displayError("Please select a healthy workspace proxy.");
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
proxyContextValue.setProxy(proxy)
|
||||
closeMenu()
|
||||
proxyContextValue.setProxy(proxy);
|
||||
closeMenu();
|
||||
}}
|
||||
key={proxy.id}
|
||||
selected={proxy.id === selectedProxy?.id}
|
||||
|
@ -361,7 +363,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
|||
<MenuItem
|
||||
sx={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
navigate("deployment/workspace-proxies")
|
||||
navigate("deployment/workspace-proxies");
|
||||
}}
|
||||
>
|
||||
Proxy settings
|
||||
|
@ -371,18 +373,18 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
|||
sx={{ fontSize: 14 }}
|
||||
onClick={(e) => {
|
||||
// Stop the menu from closing
|
||||
e.stopPropagation()
|
||||
e.stopPropagation();
|
||||
// Refresh the latencies.
|
||||
const refetchDate = refreshLatencies()
|
||||
setRefetchDate(refetchDate)
|
||||
const refetchDate = refreshLatencies();
|
||||
setRefetchDate(refetchDate);
|
||||
}}
|
||||
>
|
||||
Refresh Latencies
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
displayInitial: {
|
||||
|
@ -472,4 +474,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
padding: `0 ${theme.spacing(3)}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import Popover, { PopoverProps } from "@mui/material/Popover"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
import Popover, { PopoverProps } from "@mui/material/Popover";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
|
||||
type BorderedMenuVariant = "user-dropdown"
|
||||
type BorderedMenuVariant = "user-dropdown";
|
||||
|
||||
export type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
|
||||
variant?: BorderedMenuVariant
|
||||
}
|
||||
variant?: BorderedMenuVariant;
|
||||
};
|
||||
|
||||
export const BorderedMenu: FC<PropsWithChildren<BorderedMenuProps>> = ({
|
||||
children,
|
||||
variant,
|
||||
...rest
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
@ -23,8 +23,8 @@ export const BorderedMenu: FC<PropsWithChildren<BorderedMenuProps>> = ({
|
|||
>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
paperRoot: {
|
||||
|
@ -32,4 +32,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shadows[6],
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,32 +1,32 @@
|
|||
import ListItem from "@mui/material/ListItem"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import CheckIcon from "@mui/icons-material/Check"
|
||||
import { FC } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { ellipsizeText } from "../../../../../utils/ellipsizeText"
|
||||
import { Typography } from "../../../../Typography/Typography"
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { FC } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { ellipsizeText } from "../../../../../utils/ellipsizeText";
|
||||
import { Typography } from "../../../../Typography/Typography";
|
||||
|
||||
type BorderedMenuRowVariant = "narrow" | "wide"
|
||||
type BorderedMenuRowVariant = "narrow" | "wide";
|
||||
|
||||
interface BorderedMenuRowProps {
|
||||
/** `true` indicates this row is currently selected */
|
||||
active?: boolean
|
||||
active?: boolean;
|
||||
/** Optional description that appears beneath the title */
|
||||
description?: string
|
||||
description?: string;
|
||||
/** URL path */
|
||||
path: string
|
||||
path: string;
|
||||
/** Required title of this row */
|
||||
title: string
|
||||
title: string;
|
||||
/** Defaults to `"wide"` */
|
||||
variant?: BorderedMenuRowVariant
|
||||
variant?: BorderedMenuRowVariant;
|
||||
/** Callback fired when this row is clicked */
|
||||
onClick?: () => void
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const BorderedMenuRow: FC<
|
||||
React.PropsWithChildren<BorderedMenuRowProps>
|
||||
> = ({ active, description, path, title, variant, onClick }) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<NavLink className={styles.link} to={path}>
|
||||
|
@ -54,10 +54,10 @@ export const BorderedMenuRow: FC<
|
|||
</div>
|
||||
</ListItem>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const iconSize = 20
|
||||
const iconSize = 20;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
@ -127,4 +127,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
marginLeft: theme.spacing(4.5),
|
||||
marginTop: theme.spacing(0.5),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Box from "@mui/material/Box"
|
||||
import { Story } from "@storybook/react"
|
||||
import { MockUser } from "../../../../testHelpers/entities"
|
||||
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
|
||||
import Box from "@mui/material/Box";
|
||||
import { Story } from "@storybook/react";
|
||||
import { MockUser } from "../../../../testHelpers/entities";
|
||||
import { UserDropdown, UserDropdownProps } from "./UserDropdown";
|
||||
|
||||
export default {
|
||||
title: "components/UserDropdown",
|
||||
|
@ -9,18 +9,18 @@ export default {
|
|||
argTypes: {
|
||||
onSignOut: { action: "Sign Out" },
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<UserDropdownProps> = (args: UserDropdownProps) => (
|
||||
<Box style={{ backgroundColor: "#000", width: 88 }}>
|
||||
<UserDropdown {...args} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
export const Example = Template.bind({})
|
||||
export const Example = Template.bind({});
|
||||
Example.args = {
|
||||
user: MockUser,
|
||||
onSignOut: () => {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { fireEvent, screen } from "@testing-library/react"
|
||||
import { MockSupportLinks, MockUser } from "../../../../testHelpers/entities"
|
||||
import { render } from "../../../../testHelpers/renderHelpers"
|
||||
import { Language } from "./UserDropdownContent/UserDropdownContent"
|
||||
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { MockSupportLinks, MockUser } from "../../../../testHelpers/entities";
|
||||
import { render } from "../../../../testHelpers/renderHelpers";
|
||||
import { Language } from "./UserDropdownContent/UserDropdownContent";
|
||||
import { UserDropdown, UserDropdownProps } from "./UserDropdown";
|
||||
|
||||
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
||||
render(
|
||||
|
@ -11,20 +11,20 @@ const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
|||
supportLinks={MockSupportLinks}
|
||||
onSignOut={props.onSignOut ?? jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const trigger = await screen.findByTestId("user-dropdown-trigger")
|
||||
fireEvent.click(trigger)
|
||||
}
|
||||
);
|
||||
const trigger = await screen.findByTestId("user-dropdown-trigger");
|
||||
fireEvent.click(trigger);
|
||||
};
|
||||
|
||||
describe("UserDropdown", () => {
|
||||
describe("when the trigger is clicked", () => {
|
||||
it("opens the menu", async () => {
|
||||
await renderAndClick()
|
||||
expect(screen.getByText(Language.accountLabel)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
await renderAndClick();
|
||||
expect(screen.getByText(Language.accountLabel)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined();
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import Badge from "@mui/material/Badge"
|
||||
import MenuItem from "@mui/material/MenuItem"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { useState, FC, PropsWithChildren, MouseEvent } from "react"
|
||||
import { colors } from "theme/colors"
|
||||
import * as TypesGen from "../../../../api/typesGenerated"
|
||||
import { navHeight } from "../../../../theme/constants"
|
||||
import { BorderedMenu } from "./BorderedMenu/BorderedMenu"
|
||||
import Badge from "@mui/material/Badge";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { useState, FC, PropsWithChildren, MouseEvent } from "react";
|
||||
import { colors } from "theme/colors";
|
||||
import * as TypesGen from "../../../../api/typesGenerated";
|
||||
import { navHeight } from "../../../../theme/constants";
|
||||
import { BorderedMenu } from "./BorderedMenu/BorderedMenu";
|
||||
import {
|
||||
CloseDropdown,
|
||||
OpenDropdown,
|
||||
} from "../../../DropdownArrows/DropdownArrows"
|
||||
import { UserAvatar } from "../../../UserAvatar/UserAvatar"
|
||||
import { UserDropdownContent } from "./UserDropdownContent/UserDropdownContent"
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme"
|
||||
} from "../../../DropdownArrows/DropdownArrows";
|
||||
import { UserAvatar } from "../../../UserAvatar/UserAvatar";
|
||||
import { UserDropdownContent } from "./UserDropdownContent/UserDropdownContent";
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme";
|
||||
|
||||
export interface UserDropdownProps {
|
||||
user: TypesGen.User
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
supportLinks?: TypesGen.LinkConfig[]
|
||||
onSignOut: () => void
|
||||
user: TypesGen.User;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
supportLinks?: TypesGen.LinkConfig[];
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
|
||||
|
@ -27,15 +27,15 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
|
|||
supportLinks,
|
||||
onSignOut,
|
||||
}: UserDropdownProps) => {
|
||||
const styles = useStyles()
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>()
|
||||
const styles = useStyles();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>();
|
||||
|
||||
const handleDropdownClick = (ev: MouseEvent<HTMLLIElement>): void => {
|
||||
setAnchorEl(ev.currentTarget)
|
||||
}
|
||||
setAnchorEl(ev.currentTarget);
|
||||
};
|
||||
const onPopoverClose = () => {
|
||||
setAnchorEl(undefined)
|
||||
}
|
||||
setAnchorEl(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -88,8 +88,8 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
|
|||
/>
|
||||
</BorderedMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useStyles = makeStyles((theme) => ({
|
||||
divider: {
|
||||
|
@ -110,4 +110,4 @@ export const useStyles = makeStyles((theme) => ({
|
|||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { MockUser } from "../../../../../testHelpers/entities"
|
||||
import { Story } from "@storybook/react";
|
||||
import { MockUser } from "../../../../../testHelpers/entities";
|
||||
import {
|
||||
UserDropdownContent,
|
||||
UserDropdownContentProps,
|
||||
} from "./UserDropdownContent"
|
||||
} from "./UserDropdownContent";
|
||||
|
||||
export default {
|
||||
title: "components/UserDropdownContent",
|
||||
component: UserDropdownContent,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<UserDropdownContentProps> = (args) => (
|
||||
<UserDropdownContent {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const ExampleNoRoles = Template.bind({})
|
||||
export const ExampleNoRoles = Template.bind({});
|
||||
ExampleNoRoles.args = {
|
||||
user: {
|
||||
...MockUser,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const ExampleOneRole = Template.bind({})
|
||||
export const ExampleOneRole = Template.bind({});
|
||||
ExampleOneRole.args = {
|
||||
user: {
|
||||
...MockUser,
|
||||
roles: [{ name: "member", display_name: "Member" }],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const ExampleThreeRoles = Template.bind({})
|
||||
export const ExampleThreeRoles = Template.bind({});
|
||||
ExampleThreeRoles.args = {
|
||||
user: {
|
||||
...MockUser,
|
||||
|
@ -40,4 +40,4 @@ ExampleThreeRoles.args = {
|
|||
{ name: "auditor", display_name: "Auditor" },
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import { screen } from "@testing-library/react"
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
MockBuildInfo,
|
||||
MockSupportLinks,
|
||||
MockUser,
|
||||
} from "../../../../../testHelpers/entities"
|
||||
import { render } from "../../../../../testHelpers/renderHelpers"
|
||||
import { Language, UserDropdownContent } from "./UserDropdownContent"
|
||||
} from "../../../../../testHelpers/entities";
|
||||
import { render } from "../../../../../testHelpers/renderHelpers";
|
||||
import { Language, UserDropdownContent } from "./UserDropdownContent";
|
||||
|
||||
describe("UserDropdownContent", () => {
|
||||
const env = process.env
|
||||
const env = process.env;
|
||||
|
||||
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
|
||||
beforeEach(() => {
|
||||
process.env = { ...env }
|
||||
})
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
// REMARK: restoring process.env
|
||||
afterEach(() => {
|
||||
process.env = env
|
||||
})
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it("displays the menu items", () => {
|
||||
render(
|
||||
|
@ -29,21 +29,21 @@ describe("UserDropdownContent", () => {
|
|||
onSignOut={jest.fn()}
|
||||
onPopoverClose={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(Language.accountLabel)).toBeDefined()
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
|
||||
expect(screen.getByText(Language.copyrightText)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
|
||||
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
|
||||
);
|
||||
expect(screen.getByText(Language.accountLabel)).toBeDefined();
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined();
|
||||
expect(screen.getByText(Language.copyrightText)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined();
|
||||
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined();
|
||||
expect(
|
||||
screen.getByText(MockSupportLinks[2].name).closest("a"),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/coder/coder/issues/new?labels=needs+grooming&body=Version%3A%20%5B%60v99.999.9999%2Bc9cdf14%60%5D(file%3A%2F%2F%2Fmock-url)",
|
||||
)
|
||||
expect(screen.getByText(MockBuildInfo.version)).toBeDefined()
|
||||
})
|
||||
);
|
||||
expect(screen.getByText(MockBuildInfo.version)).toBeDefined();
|
||||
});
|
||||
|
||||
it("has the correct link for the account item", () => {
|
||||
render(
|
||||
|
@ -52,26 +52,26 @@ describe("UserDropdownContent", () => {
|
|||
onSignOut={jest.fn()}
|
||||
onPopoverClose={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
);
|
||||
|
||||
const link = screen.getByText(Language.accountLabel).closest("a")
|
||||
const link = screen.getByText(Language.accountLabel).closest("a");
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the account menu item")
|
||||
throw new Error("Anchor tag not found for the account menu item");
|
||||
}
|
||||
|
||||
expect(link.getAttribute("href")).toBe("/settings/account")
|
||||
})
|
||||
expect(link.getAttribute("href")).toBe("/settings/account");
|
||||
});
|
||||
|
||||
it("calls the onSignOut function", () => {
|
||||
const onSignOut = jest.fn()
|
||||
const onSignOut = jest.fn();
|
||||
render(
|
||||
<UserDropdownContent
|
||||
user={MockUser}
|
||||
onSignOut={onSignOut}
|
||||
onPopoverClose={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
screen.getByText(Language.signOutLabel).click()
|
||||
expect(onSignOut).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
);
|
||||
screen.getByText(Language.signOutLabel).click();
|
||||
expect(onSignOut).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
import Divider from "@mui/material/Divider"
|
||||
import MenuItem from "@mui/material/MenuItem"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import AccountIcon from "@mui/icons-material/AccountCircleOutlined"
|
||||
import BugIcon from "@mui/icons-material/BugReportOutlined"
|
||||
import ChatIcon from "@mui/icons-material/ChatOutlined"
|
||||
import LaunchIcon from "@mui/icons-material/LaunchOutlined"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import * as TypesGen from "../../../../../api/typesGenerated"
|
||||
import DocsIcon from "@mui/icons-material/MenuBook"
|
||||
import LogoutIcon from "@mui/icons-material/ExitToAppOutlined"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import Divider from "@mui/material/Divider";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import AccountIcon from "@mui/icons-material/AccountCircleOutlined";
|
||||
import BugIcon from "@mui/icons-material/BugReportOutlined";
|
||||
import ChatIcon from "@mui/icons-material/ChatOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/LaunchOutlined";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { FC } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as TypesGen from "../../../../../api/typesGenerated";
|
||||
import DocsIcon from "@mui/icons-material/MenuBook";
|
||||
import LogoutIcon from "@mui/icons-material/ExitToAppOutlined";
|
||||
import { combineClasses } from "utils/combineClasses";
|
||||
|
||||
export const Language = {
|
||||
accountLabel: "Account",
|
||||
signOutLabel: "Sign Out",
|
||||
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
|
||||
}
|
||||
};
|
||||
|
||||
export interface UserDropdownContentProps {
|
||||
user: TypesGen.User
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
supportLinks?: TypesGen.LinkConfig[]
|
||||
onPopoverClose: () => void
|
||||
onSignOut: () => void
|
||||
user: TypesGen.User;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
supportLinks?: TypesGen.LinkConfig[];
|
||||
onPopoverClose: () => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
export const UserDropdownContent: FC<UserDropdownContentProps> = ({
|
||||
|
@ -34,7 +34,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
|
|||
onPopoverClose,
|
||||
onSignOut,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -101,8 +101,8 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
|
|||
<div className={styles.footerText}>{Language.copyrightText}</div>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
info: {
|
||||
|
@ -166,7 +166,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
buildInfo: {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
const includeBuildInfo = (
|
||||
href: string,
|
||||
|
@ -177,5 +177,5 @@ const includeBuildInfo = (
|
|||
`${encodeURIComponent(
|
||||
`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { ServiceBannerView } from "./ServiceBannerView"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { ServiceBannerView } from "./ServiceBannerView";
|
||||
|
||||
export const ServiceBanner: React.FC = () => {
|
||||
const { appearance } = useDashboard()
|
||||
const { appearance } = useDashboard();
|
||||
const { message, background_color, enabled } =
|
||||
appearance.config.service_banner
|
||||
appearance.config.service_banner;
|
||||
|
||||
if (!enabled) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message !== undefined && background_color !== undefined) {
|
||||
|
@ -17,8 +17,8 @@ export const ServiceBanner: React.FC = () => {
|
|||
backgroundColor={background_color}
|
||||
preview={appearance.preview}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView"
|
||||
import { Story } from "@storybook/react";
|
||||
import { ServiceBannerView, ServiceBannerViewProps } from "./ServiceBannerView";
|
||||
|
||||
export default {
|
||||
title: "components/ServiceBannerView",
|
||||
component: ServiceBannerView,
|
||||
}
|
||||
};
|
||||
|
||||
const Template: Story<ServiceBannerViewProps> = (args) => (
|
||||
<ServiceBannerView {...args} />
|
||||
)
|
||||
);
|
||||
|
||||
export const Production = Template.bind({})
|
||||
export const Production = Template.bind({});
|
||||
Production.args = {
|
||||
message: "weeeee",
|
||||
backgroundColor: "#FFFFFF",
|
||||
}
|
||||
};
|
||||
|
||||
export const Preview = Template.bind({})
|
||||
export const Preview = Template.bind({});
|
||||
Preview.args = {
|
||||
message: "weeeee",
|
||||
backgroundColor: "#000000",
|
||||
preview: true,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { makeStyles } from "@mui/styles"
|
||||
import { Pill } from "components/Pill/Pill"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { colors } from "theme/colors"
|
||||
import { hex } from "color-convert"
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { colors } from "theme/colors";
|
||||
import { hex } from "color-convert";
|
||||
|
||||
export interface ServiceBannerViewProps {
|
||||
message: string
|
||||
backgroundColor: string
|
||||
preview: boolean
|
||||
message: string;
|
||||
backgroundColor: string;
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
||||
|
@ -15,7 +15,7 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
|||
backgroundColor,
|
||||
preview,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
// We don't want anything funky like an image or a heading in the service
|
||||
// banner.
|
||||
const markdownElementsAllowed = [
|
||||
|
@ -28,7 +28,7 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
|||
"italic",
|
||||
"link",
|
||||
"em",
|
||||
]
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
|
@ -50,8 +50,8 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
|||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
|
@ -74,14 +74,14 @@ const useStyles = makeStyles((theme) => ({
|
|||
color: "inherit",
|
||||
},
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
const readableForegroundColor = (backgroundColor: string): string => {
|
||||
const rgb = hex.rgb(backgroundColor)
|
||||
const rgb = hex.rgb(backgroundColor);
|
||||
|
||||
// Logic taken from here:
|
||||
// https://github.com/casesandberg/react-color/blob/bc9a0e1dc5d11b06c511a8e02a95bd85c7129f4b/src/helpers/color.js#L56
|
||||
// to be consistent with the color-picker label.
|
||||
const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000
|
||||
return yiq >= 128 ? "#000" : "#fff"
|
||||
}
|
||||
const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
|
||||
return yiq >= 128 ? "#000" : "#fff";
|
||||
};
|
||||
|
|
|
@ -1,92 +1,92 @@
|
|||
import { makeStyles } from "@mui/styles"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { PropsWithChildren, FC } from "react"
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { PropsWithChildren, FC } from "react";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { combineClasses } from "utils/combineClasses";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
export const EnabledBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
|
||||
Enabled
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const EntitledBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
|
||||
Entitled
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const HealthyBadge: FC<{ derpOnly: boolean }> = ({ derpOnly }) => {
|
||||
const styles = useStyles()
|
||||
let text = "Healthy"
|
||||
const styles = useStyles();
|
||||
let text = "Healthy";
|
||||
if (derpOnly) {
|
||||
text = "Healthy (DERP Only)"
|
||||
text = "Healthy (DERP Only)";
|
||||
}
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const NotHealthyBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.errorBadge])}>
|
||||
Unhealthy
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const NotRegisteredBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Tooltip title="Workspace Proxy has never come online and needs to be started.">
|
||||
<span className={combineClasses([styles.badge, styles.warnBadge])}>
|
||||
Never Seen
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const NotReachableBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Tooltip title="Workspace Proxy not responding to http(s) requests.">
|
||||
<span className={combineClasses([styles.badge, styles.warnBadge])}>
|
||||
Not Dialable
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const DisabledBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.disabledBadge])}>
|
||||
Disabled
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const EnterpriseBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.enterpriseBadge])}>
|
||||
Enterprise
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const Badges: FC<PropsWithChildren> = ({ children }) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Stack
|
||||
className={styles.badges}
|
||||
|
@ -96,8 +96,8 @@ export const Badges: FC<PropsWithChildren> = ({ children }) => {
|
|||
>
|
||||
{children}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
badges: {
|
||||
|
@ -152,4 +152,4 @@ const useStyles = makeStyles((theme) => ({
|
|||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue