site: reduce printWidth to 80 (#4437)

Resolves #4435
This commit is contained in:
Ammar Bandukwala
2022-10-10 12:33:35 -05:00
committed by GitHub
parent cb54986d3f
commit 85c679597c
274 changed files with 3238 additions and 1173 deletions

View File

@ -1,12 +1,11 @@
{ {
"printWidth": 100, "printWidth": 80,
"semi": false, "semi": false,
"trailingComma": "all", "trailingComma": "all",
"overrides": [ "overrides": [
{ {
"files": ["./README.md", "**/*.yaml"], "files": ["./README.md", "**/*.yaml"],
"options": { "options": {
"printWidth": 80,
"proseWrap": "always" "proseWrap": "always"
} }
} }

View File

@ -39,7 +39,11 @@ module.exports = {
// //
// SEE: https://storybook.js.org/docs/react/configure/webpack // SEE: https://storybook.js.org/docs/react/configure/webpack
webpackFinal: async (config) => { webpackFinal: async (config) => {
config.resolve.modules = [path.resolve(__dirname, ".."), "node_modules", "../src"] config.resolve.modules = [
path.resolve(__dirname, ".."),
"node_modules",
"../src",
]
return config return config
}, },
} }

View File

@ -6,7 +6,10 @@ export class SignInPage extends BasePom {
super(baseURL, "/login", page) super(baseURL, "/login", page)
} }
async submitBuiltInAuthentication(email: string, password: string): Promise<void> { async submitBuiltInAuthentication(
email: string,
password: string,
): Promise<void> {
await this.page.fill("text=Email", email) await this.page.fill("text=Email", email)
await this.page.fill("text=Password", password) await this.page.fill("text=Password", password)
await this.page.click("text=Sign In") await this.page.click("text=Sign In")

View File

@ -1,7 +1,10 @@
import { test } from "@playwright/test" import { test } from "@playwright/test"
import { HealthzPage } from "../pom/HealthzPage" import { HealthzPage } from "../pom/HealthzPage"
test("Healthz is available without authentication", async ({ baseURL, page }) => { test("Healthz is available without authentication", async ({
baseURL,
page,
}) => {
const healthzPage = new HealthzPage(baseURL, page) const healthzPage = new HealthzPage(baseURL, page)
await page.goto(healthzPage.url, { waitUntil: "networkidle" }) await page.goto(healthzPage.url, { waitUntil: "networkidle" })
await healthzPage.getOk().waitFor({ state: "visible" }) await healthzPage.getOk().waitFor({ state: "visible" })

View File

@ -23,7 +23,12 @@
href="/favicons/favicon.png" href="/favicons/favicon.png"
data-react-helmet="true" data-react-helmet="true"
/> />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" data-react-helmet="true" /> <link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon.svg"
data-react-helmet="true"
/>
</head> </head>
<body> <body>

View File

@ -35,8 +35,17 @@ module.exports = {
{ {
displayName: "lint", displayName: "lint",
runner: "jest-runner-eslint", runner: "jest-runner-eslint",
testMatch: ["<rootDir>/**/*.js", "<rootDir>/**/*.ts", "<rootDir>/**/*.tsx"], testMatch: [
testPathIgnorePatterns: ["/out/", "/_jest/", "jest.config.js", "jest-runner.*.js"], "<rootDir>/**/*.js",
"<rootDir>/**/*.ts",
"<rootDir>/**/*.tsx",
],
testPathIgnorePatterns: [
"/out/",
"/_jest/",
"jest.config.js",
"jest-runner.*.js",
],
}, },
], ],
collectCoverageFrom: [ collectCoverageFrom: [

View File

@ -45,7 +45,10 @@ const CONSOLE_FAIL_TYPES = ["error" /* 'warn' */] as const
CONSOLE_FAIL_TYPES.forEach((logType: typeof CONSOLE_FAIL_TYPES[number]) => { CONSOLE_FAIL_TYPES.forEach((logType: typeof CONSOLE_FAIL_TYPES[number]) => {
global.console[logType] = <Type>(format: string, ...args: Type[]): void => { global.console[logType] = <Type>(format: string, ...args: Type[]): void => {
throw new Error( throw new Error(
`Failing due to console.${logType} while running test!\n\n${util.format(format, ...args)}`, `Failing due to console.${logType} while running test!\n\n${util.format(
format,
...args,
)}`,
) )
} }
}) })

View File

@ -23,25 +23,42 @@ import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
// - Pages that are secondary, not in the main navigation or not usually accessed // - Pages that are secondary, not in the main navigation or not usually accessed
// - Pages that use heavy dependencies like charts or time libraries // - Pages that use heavy dependencies like charts or time libraries
const NotFoundPage = lazy(() => import("./pages/404Page/404Page")) const NotFoundPage = lazy(() => import("./pages/404Page/404Page"))
const CliAuthenticationPage = lazy(() => import("./pages/CliAuthPage/CliAuthPage")) const CliAuthenticationPage = lazy(
() => import("./pages/CliAuthPage/CliAuthPage"),
)
const HealthzPage = lazy(() => import("./pages/HealthzPage/HealthzPage")) const HealthzPage = lazy(() => import("./pages/HealthzPage/HealthzPage"))
const AccountPage = lazy(() => import("./pages/UserSettingsPage/AccountPage/AccountPage")) const AccountPage = lazy(
const SecurityPage = lazy(() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage")) () => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
const SSHKeysPage = lazy(() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage")) )
const CreateUserPage = lazy(() => import("./pages/UsersPage/CreateUserPage/CreateUserPage")) const SecurityPage = lazy(
const WorkspaceBuildPage = lazy(() => import("./pages/WorkspaceBuildPage/WorkspaceBuildPage")) () => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
)
const SSHKeysPage = lazy(
() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"),
)
const CreateUserPage = lazy(
() => import("./pages/UsersPage/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( const WorkspaceSchedulePage = lazy(
() => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"), () => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"),
) )
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage")) const CreateWorkspacePage = lazy(
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
)
const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage")) const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage"))
export const AppRouter: FC = () => { export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const permissions = useSelector(xServices.authXService, selectPermissions) const permissions = useSelector(xServices.authXService, selectPermissions)
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility) const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
)
return ( return (
<Suspense fallback={<FullScreenLoader />}> <Suspense fallback={<FullScreenLoader />}>
@ -142,7 +159,8 @@ export const AppRouter: FC = () => {
<AuthAndFrame> <AuthAndFrame>
<RequirePermission <RequirePermission
isFeatureVisible={ isFeatureVisible={
featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) featureVisibility[FeatureNames.AuditLog] &&
Boolean(permissions?.viewAuditLog)
} }
> >
<AuditPage /> <AuditPage />

View File

@ -6,7 +6,10 @@ import "./i18n"
// if this is a development build and the developer wants to inspect // if this is a development build and the developer wants to inspect
// helpful to see realtime changes on the services // helpful to see realtime changes on the services
if (process.env.NODE_ENV === "development" && process.env.INSPECT_XSTATE === "true") { if (
process.env.NODE_ENV === "development" &&
process.env.INSPECT_XSTATE === "true"
) {
// configure the XState inspector to open in a new tab // configure the XState inspector to open in a new tab
inspect({ inspect({
url: "https://stately.ai/viz?inspect", url: "https://stately.ai/viz?inspect",

View File

@ -119,21 +119,39 @@ describe("api.ts", () => {
["/api/v2/workspaces", undefined, "/api/v2/workspaces"], ["/api/v2/workspaces", undefined, "/api/v2/workspaces"],
["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"], ["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"],
["/api/v2/workspaces", { q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"], [
"/api/v2/workspaces",
{ q: "owner:1" },
"/api/v2/workspaces?q=owner%3A1",
],
["/api/v2/workspaces", { q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"], [
])(`Workspaces - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => { "/api/v2/workspaces",
expect(getURLWithSearchParams(basePath, filter)).toBe(expected) { q: "owner:me" },
}) "/api/v2/workspaces?q=owner%3Ame",
],
])(
`Workspaces - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
},
)
}) })
describe("getURLWithSearchParams - users", () => { describe("getURLWithSearchParams - users", () => {
it.each<[string, TypesGen.UsersRequest | undefined, string]>([ it.each<[string, TypesGen.UsersRequest | undefined, string]>([
["/api/v2/users", undefined, "/api/v2/users"], ["/api/v2/users", undefined, "/api/v2/users"],
["/api/v2/users", { q: "status:active" }, "/api/v2/users?q=status%3Aactive"], [
"/api/v2/users",
{ q: "status:active" },
"/api/v2/users?q=status%3Aactive",
],
["/api/v2/users", { q: "" }, "/api/v2/users"], ["/api/v2/users", { q: "" }, "/api/v2/users"],
])(`Users - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => { ])(
expect(getURLWithSearchParams(basePath, filter)).toBe(expected) `Users - getURLWithSearchParams(%p, %p) returns %p`,
}) (basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
},
)
}) })
}) })

View File

@ -48,7 +48,8 @@ if (token !== null && token.getAttribute("content") !== null) {
axios.defaults.headers.common["X-CSRF-TOKEN"] = hardCodedCSRFCookie() axios.defaults.headers.common["X-CSRF-TOKEN"] = hardCodedCSRFCookie()
token.setAttribute("content", hardCodedCSRFCookie()) token.setAttribute("content", hardCodedCSRFCookie())
} else { } else {
axios.defaults.headers.common["X-CSRF-TOKEN"] = token.getAttribute("content") ?? "" axios.defaults.headers.common["X-CSRF-TOKEN"] =
token.getAttribute("content") ?? ""
} }
} else { } else {
// Do not write error logs if we are in a FE unit test. // Do not write error logs if we are in a FE unit test.
@ -106,44 +107,65 @@ export const getUser = async (): Promise<TypesGen.User> => {
} }
export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => { export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
const response = await axios.get<TypesGen.AuthMethods>("/api/v2/users/authmethods") const response = await axios.get<TypesGen.AuthMethods>(
"/api/v2/users/authmethods",
)
return response.data return response.data
} }
export const checkAuthorization = async ( export const checkAuthorization = async (
params: TypesGen.AuthorizationRequest, params: TypesGen.AuthorizationRequest,
): Promise<TypesGen.AuthorizationResponse> => { ): Promise<TypesGen.AuthorizationResponse> => {
const response = await axios.post<TypesGen.AuthorizationResponse>(`/api/v2/authcheck`, params) const response = await axios.post<TypesGen.AuthorizationResponse>(
`/api/v2/authcheck`,
params,
)
return response.data return response.data
} }
export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => { export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
const response = await axios.post<TypesGen.GenerateAPIKeyResponse>("/api/v2/users/me/keys") const response = await axios.post<TypesGen.GenerateAPIKeyResponse>(
"/api/v2/users/me/keys",
)
return response.data return response.data
} }
export const getUsers = async (filter?: TypesGen.UsersRequest): Promise<TypesGen.User[]> => { export const getUsers = async (
filter?: TypesGen.UsersRequest,
): Promise<TypesGen.User[]> => {
const url = getURLWithSearchParams("/api/v2/users", filter) const url = getURLWithSearchParams("/api/v2/users", filter)
const response = await axios.get<TypesGen.User[]>(url) const response = await axios.get<TypesGen.User[]>(url)
return response.data return response.data
} }
export const getOrganization = async (organizationId: string): Promise<TypesGen.Organization> => { export const getOrganization = async (
const response = await axios.get<TypesGen.Organization>(`/api/v2/organizations/${organizationId}`) organizationId: string,
): Promise<TypesGen.Organization> => {
const response = await axios.get<TypesGen.Organization>(
`/api/v2/organizations/${organizationId}`,
)
return response.data return response.data
} }
export const getOrganizations = async (): Promise<TypesGen.Organization[]> => { export const getOrganizations = async (): Promise<TypesGen.Organization[]> => {
const response = await axios.get<TypesGen.Organization[]>("/api/v2/users/me/organizations") const response = await axios.get<TypesGen.Organization[]>(
"/api/v2/users/me/organizations",
)
return response.data return response.data
} }
export const getTemplate = async (templateId: string): Promise<TypesGen.Template> => { export const getTemplate = async (
const response = await axios.get<TypesGen.Template>(`/api/v2/templates/${templateId}`) templateId: string,
): Promise<TypesGen.Template> => {
const response = await axios.get<TypesGen.Template>(
`/api/v2/templates/${templateId}`,
)
return response.data return response.data
} }
export const getTemplates = async (organizationId: string): Promise<TypesGen.Template[]> => { export const getTemplates = async (
organizationId: string,
): Promise<TypesGen.Template[]> => {
const response = await axios.get<TypesGen.Template[]>( const response = await axios.get<TypesGen.Template[]>(
`/api/v2/organizations/${organizationId}/templates`, `/api/v2/organizations/${organizationId}/templates`,
) )
@ -160,7 +182,9 @@ export const getTemplateByName = async (
return response.data return response.data
} }
export const getTemplateVersion = async (versionId: string): Promise<TypesGen.TemplateVersion> => { export const getTemplateVersion = async (
versionId: string,
): Promise<TypesGen.TemplateVersion> => {
const response = await axios.get<TypesGen.TemplateVersion>( const response = await axios.get<TypesGen.TemplateVersion>(
`/api/v2/templateversions/${versionId}`, `/api/v2/templateversions/${versionId}`,
) )
@ -198,12 +222,19 @@ export const updateTemplateMeta = async (
templateId: string, templateId: string,
data: TypesGen.UpdateTemplateMeta, data: TypesGen.UpdateTemplateMeta,
): Promise<TypesGen.Template> => { ): Promise<TypesGen.Template> => {
const response = await axios.patch<TypesGen.Template>(`/api/v2/templates/${templateId}`, data) const response = await axios.patch<TypesGen.Template>(
`/api/v2/templates/${templateId}`,
data,
)
return response.data return response.data
} }
export const deleteTemplate = async (templateId: string): Promise<TypesGen.Template> => { export const deleteTemplate = async (
const response = await axios.delete<TypesGen.Template>(`/api/v2/templates/${templateId}`) templateId: string,
): Promise<TypesGen.Template> => {
const response = await axios.delete<TypesGen.Template>(
`/api/v2/templates/${templateId}`,
)
return response.data return response.data
} }
@ -211,9 +242,12 @@ export const getWorkspace = async (
workspaceId: string, workspaceId: string,
params?: TypesGen.WorkspaceOptions, params?: TypesGen.WorkspaceOptions,
): Promise<TypesGen.Workspace> => { ): Promise<TypesGen.Workspace> => {
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`, { const response = await axios.get<TypesGen.Workspace>(
params, `/api/v2/workspaces/${workspaceId}`,
}) {
params,
},
)
return response.data return response.data
} }
@ -268,12 +302,18 @@ export const getWorkspaceByOwnerAndName = async (
const postWorkspaceBuild = const postWorkspaceBuild =
(transition: WorkspaceBuildTransition) => (transition: WorkspaceBuildTransition) =>
async (workspaceId: string, template_version_id?: string): Promise<TypesGen.WorkspaceBuild> => { async (
workspaceId: string,
template_version_id?: string,
): Promise<TypesGen.WorkspaceBuild> => {
const payload = { const payload = {
transition, transition,
template_version_id, template_version_id,
} }
const response = await axios.post(`/api/v2/workspaces/${workspaceId}/builds`, payload) const response = await axios.post(
`/api/v2/workspaces/${workspaceId}/builds`,
payload,
)
return response.data return response.data
} }
@ -284,11 +324,15 @@ export const deleteWorkspace = postWorkspaceBuild("delete")
export const cancelWorkspaceBuild = async ( export const cancelWorkspaceBuild = async (
workspaceBuildId: TypesGen.WorkspaceBuild["id"], workspaceBuildId: TypesGen.WorkspaceBuild["id"],
): Promise<Types.Message> => { ): Promise<Types.Message> => {
const response = await axios.patch(`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`) const response = await axios.patch(
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`,
)
return response.data return response.data
} }
export const createUser = async (user: TypesGen.CreateUserRequest): Promise<TypesGen.User> => { export const createUser = async (
user: TypesGen.CreateUserRequest,
): Promise<TypesGen.User> => {
const response = await axios.post<TypesGen.User>("/api/v2/users", user) const response = await axios.post<TypesGen.User>("/api/v2/users", user)
return response.data return response.data
} }
@ -338,17 +382,27 @@ export const updateProfile = async (
return response.data return response.data
} }
export const activateUser = async (userId: TypesGen.User["id"]): Promise<TypesGen.User> => { export const activateUser = async (
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/status/activate`) userId: TypesGen.User["id"],
): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(
`/api/v2/users/${userId}/status/activate`,
)
return response.data return response.data
} }
export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen.User> => { export const suspendUser = async (
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/status/suspend`) userId: TypesGen.User["id"],
): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(
`/api/v2/users/${userId}/status/suspend`,
)
return response.data return response.data
} }
export const deleteUser = async (userId: TypesGen.User["id"]): Promise<undefined> => { export const deleteUser = async (
userId: TypesGen.User["id"],
): Promise<undefined> => {
return await axios.delete(`/api/v2/users/${userId}`) return await axios.delete(`/api/v2/users/${userId}`)
} }
@ -379,10 +433,15 @@ export const createFirstUser = async (
export const updateUserPassword = async ( export const updateUserPassword = async (
userId: TypesGen.User["id"], userId: TypesGen.User["id"],
updatePassword: TypesGen.UpdateUserPasswordRequest, updatePassword: TypesGen.UpdateUserPasswordRequest,
): Promise<undefined> => axios.put(`/api/v2/users/${userId}/password`, updatePassword) ): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, updatePassword)
export const getSiteRoles = async (): Promise<Array<TypesGen.AssignableRoles>> => { export const getSiteRoles = async (): Promise<
const response = await axios.get<Array<TypesGen.AssignableRoles>>(`/api/v2/users/roles`) Array<TypesGen.AssignableRoles>
> => {
const response = await axios.get<Array<TypesGen.AssignableRoles>>(
`/api/v2/users/roles`,
)
return response.data return response.data
} }
@ -390,17 +449,28 @@ export const updateUserRoles = async (
roles: TypesGen.Role["name"][], roles: TypesGen.Role["name"][],
userId: TypesGen.User["id"], userId: TypesGen.User["id"],
): Promise<TypesGen.User> => { ): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles }) const response = await axios.put<TypesGen.User>(
`/api/v2/users/${userId}/roles`,
{ roles },
)
return response.data return response.data
} }
export const getUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => { export const getUserSSHKey = async (
const response = await axios.get<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`) userId = "me",
): Promise<TypesGen.GitSSHKey> => {
const response = await axios.get<TypesGen.GitSSHKey>(
`/api/v2/users/${userId}/gitsshkey`,
)
return response.data return response.data
} }
export const regenerateUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => { export const regenerateUserSSHKey = async (
const response = await axios.put<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`) userId = "me",
): Promise<TypesGen.GitSSHKey> => {
const response = await axios.put<TypesGen.GitSSHKey>(
`/api/v2/users/${userId}/gitsshkey`,
)
return response.data return response.data
} }
@ -439,7 +509,9 @@ export const putWorkspaceExtension = async (
workspaceId: string, workspaceId: string,
newDeadline: dayjs.Dayjs, newDeadline: dayjs.Dayjs,
): Promise<void> => { ): Promise<void> => {
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline }) await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, {
deadline: newDeadline,
})
} }
export const getEntitlements = async (): Promise<TypesGen.Entitlements> => { export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
@ -479,7 +551,9 @@ export const getAuditLogsCount = async (
if (options.q) { if (options.q) {
searchParams.set("q", options.q) searchParams.set("q", options.q)
} }
const response = await axios.get(`/api/v2/audit/count?${searchParams.toString()}`) const response = await axios.get(
`/api/v2/audit/count?${searchParams.toString()}`,
)
return response.data return response.data
} }
@ -490,12 +564,15 @@ export const getTemplateDAUs = async (
return response.data return response.data
} }
export const getApplicationsHost = async (): Promise<TypesGen.GetAppHostResponse> => { export const getApplicationsHost =
const response = await axios.get(`/api/v2/applications/host`) async (): Promise<TypesGen.GetAppHostResponse> => {
return response.data const response = await axios.get(`/api/v2/applications/host`)
} return response.data
}
export const getWorkspaceQuota = async (userID: string): Promise<TypesGen.WorkspaceQuota> => { export const getWorkspaceQuota = async (
userID: string,
): Promise<TypesGen.WorkspaceQuota> => {
const response = await axios.get(`/api/v2/workspace-quota/${userID}`) const response = await axios.get(`/api/v2/workspace-quota/${userID}`)
return response.data return response.data
} }

View File

@ -1,4 +1,8 @@
import { getValidationErrorMessage, isApiError, mapApiErrorToFieldErrors } from "./errors" import {
getValidationErrorMessage,
isApiError,
mapApiErrorToFieldErrors,
} from "./errors"
describe("isApiError", () => { describe("isApiError", () => {
it("returns true when the object is an API Error", () => { it("returns true when the object is an API Error", () => {
@ -8,7 +12,9 @@ describe("isApiError", () => {
response: { response: {
data: { data: {
message: "Invalid entry", message: "Invalid entry",
errors: [{ detail: "Username is already in use", field: "username" }], errors: [
{ detail: "Username is already in use", field: "username" },
],
}, },
}, },
}), }),
@ -29,7 +35,9 @@ describe("mapApiErrorToFieldErrors", () => {
expect( expect(
mapApiErrorToFieldErrors({ mapApiErrorToFieldErrors({
message: "Invalid entry", message: "Invalid entry",
validations: [{ detail: "Username is already in use", field: "username" }], validations: [
{ detail: "Username is already in use", field: "username" },
],
}), }),
).toEqual({ ).toEqual({
username: "Username is already in use", username: "Username is already in use",

View File

@ -19,7 +19,9 @@ export interface ApiErrorResponse {
validations?: FieldError[] validations?: FieldError[]
} }
export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> } export type ApiError = AxiosError<ApiErrorResponse> & {
response: AxiosResponse<ApiErrorResponse>
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export const isApiError = (err: any): err is ApiError => { export const isApiError = (err: any): err is ApiError => {
@ -47,12 +49,15 @@ export const isApiError = (err: any): err is ApiError => {
export const hasApiFieldErrors = (error: ApiError): boolean => export const hasApiFieldErrors = (error: ApiError): boolean =>
Array.isArray(error.response.data.validations) Array.isArray(error.response.data.validations)
export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => { export const mapApiErrorToFieldErrors = (
apiErrorResponse: ApiErrorResponse,
): FieldErrors => {
const result: FieldErrors = {} const result: FieldErrors = {}
if (apiErrorResponse.validations) { if (apiErrorResponse.validations) {
for (const error of apiErrorResponse.validations) { for (const error of apiErrorResponse.validations) {
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode result[error.field] =
error.detail || Language.errorsByCode.defaultErrorCode
} }
} }
@ -81,11 +86,21 @@ export const getErrorMessage = (
* @returns a combined validation error message if the error is an ApiError * @returns a combined validation error message if the error is an ApiError
* and contains validation messages for different form fields. * and contains validation messages for different form fields.
*/ */
export const getValidationErrorMessage = (error: Error | ApiError | unknown): string => { export const getValidationErrorMessage = (
error: Error | ApiError | unknown,
): string => {
const validationErrors = const validationErrors =
isApiError(error) && error.response.data.validations ? error.response.data.validations : [] 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: Error | ApiError | unknown): string | undefined | null => export const getErrorDetail = (
isApiError(error) ? error.response.data.detail : error instanceof Error ? error.stack : null error: Error | ApiError | unknown,
): string | undefined | null =>
isApiError(error)
? error.response.data.detail
: error instanceof Error
? error.stack
: null

View File

@ -707,7 +707,10 @@ export type LogSource = "provisioner" | "provisioner_daemon"
export type LoginType = "github" | "oidc" | "password" | "token" export type LoginType = "github" | "oidc" | "password" | "token"
// From codersdk/parameters.go // From codersdk/parameters.go
export type ParameterDestinationScheme = "environment_variable" | "none" | "provisioner_variable" export type ParameterDestinationScheme =
| "environment_variable"
| "none"
| "provisioner_variable"
// From codersdk/parameters.go // From codersdk/parameters.go
export type ParameterScope = "import_job" | "template" | "workspace" export type ParameterScope = "import_job" | "template" | "workspace"
@ -753,7 +756,11 @@ export type UserStatus = "active" | "suspended"
export type WorkspaceAgentStatus = "connected" | "connecting" | "disconnected" export type WorkspaceAgentStatus = "connected" | "connecting" | "disconnected"
// From codersdk/workspaceapps.go // From codersdk/workspaceapps.go
export type WorkspaceAppHealth = "disabled" | "healthy" | "initializing" | "unhealthy" export type WorkspaceAppHealth =
| "disabled"
| "healthy"
| "initializing"
| "unhealthy"
// From codersdk/workspacebuilds.go // From codersdk/workspacebuilds.go
export type WorkspaceStatus = export type WorkspaceStatus =

View File

@ -32,7 +32,10 @@ export const AlertBanner: FC<AlertBannerProps> = ({
// if an error is passed in, display that error, otherwise // if an error is passed in, display that error, otherwise
// display the text passed in, e.g. warning text // display the text passed in, e.g. warning text
const alertMessage = getErrorMessage(error, text ?? t("warningsAndErrors.somethingWentWrong")) const alertMessage = getErrorMessage(
error,
text ?? t("warningsAndErrors.somethingWentWrong"),
)
// if we have an error, check if there's detail to display // if we have an error, check if there's detail to display
const detail = error ? getErrorDetail(error) : undefined const detail = error ? getErrorDetail(error) : undefined

View File

@ -5,7 +5,10 @@ import Button from "@material-ui/core/Button"
import RefreshIcon from "@material-ui/icons/Refresh" import RefreshIcon from "@material-ui/icons/Refresh"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
type AlertBannerCtasProps = Pick<AlertBannerProps, "actions" | "dismissible" | "retry"> & { type AlertBannerCtasProps = Pick<
AlertBannerProps,
"actions" | "dismissible" | "retry"
> & {
setOpen: (arg0: boolean) => void setOpen: (arg0: boolean) => void
} }
@ -20,12 +23,18 @@ export const AlertBannerCtas: FC<AlertBannerCtasProps> = ({
return ( return (
<Stack direction="row"> <Stack direction="row">
{/* CTAs passed in by the consumer */} {/* CTAs passed in by the consumer */}
{actions.length > 0 && actions.map((action) => <div key={String(action)}>{action}</div>)} {actions.length > 0 &&
actions.map((action) => <div key={String(action)}>{action}</div>)}
{/* retry CTA */} {/* retry CTA */}
{retry && ( {retry && (
<div> <div>
<Button size="small" onClick={retry} startIcon={<RefreshIcon />} variant="outlined"> <Button
size="small"
onClick={retry}
startIcon={<RefreshIcon />}
variant="outlined"
>
{t("ctas.retry")} {t("ctas.retry")}
</Button> </Button>
</div> </div>

View File

@ -4,13 +4,26 @@ import { colors } from "theme/colors"
import { Severity } from "./alertTypes" import { Severity } from "./alertTypes"
import { ReactElement } from "react" import { ReactElement } from "react"
export const severityConstants: Record<Severity, { color: string; icon: ReactElement }> = { export const severityConstants: Record<
Severity,
{ color: string; icon: ReactElement }
> = {
warning: { warning: {
color: colors.orange[7], color: colors.orange[7],
icon: <ReportProblemOutlinedIcon fontSize="small" style={{ color: colors.orange[7] }} />, icon: (
<ReportProblemOutlinedIcon
fontSize="small"
style={{ color: colors.orange[7] }}
/>
),
}, },
error: { error: {
color: colors.red[7], color: colors.red[7],
icon: <ErrorOutlineOutlinedIcon fontSize="small" style={{ color: colors.red[7] }} />, icon: (
<ErrorOutlineOutlinedIcon
fontSize="small"
style={{ color: colors.red[7] }}
/>
),
}, },
} }

View File

@ -10,7 +10,8 @@ import * as TypesGen from "../../api/typesGenerated"
import { generateRandomString } from "../../util/random" import { generateRandomString } from "../../util/random"
export const Language = { export const Language = {
appTitle: (appName: string, identifier: string): string => `${appName} - ${identifier}`, appTitle: (appName: string, identifier: string): string =>
`${appName} - ${identifier}`,
} }
export interface AppLinkProps { export interface AppLinkProps {
@ -40,7 +41,9 @@ export const AppLink: FC<PropsWithChildren<AppLinkProps>> = ({
// The backend redirects if the trailing slash isn't included, so we add it // The backend redirects if the trailing slash isn't included, so we add it
// here to avoid extra roundtrips. // here to avoid extra roundtrips.
let href = `/@${username}/${workspaceName}.${agentName}/apps/${encodeURIComponent(appName)}/` let href = `/@${username}/${workspaceName}.${agentName}/apps/${encodeURIComponent(
appName,
)}/`
if (appCommand) { if (appCommand) {
href = `/@${username}/${workspaceName}.${agentName}/terminal?command=${encodeURIComponent( href = `/@${username}/${workspaceName}.${agentName}/terminal?command=${encodeURIComponent(
appCommand, appCommand,
@ -52,7 +55,11 @@ export const AppLink: FC<PropsWithChildren<AppLinkProps>> = ({
} }
let canClick = true let canClick = true
let icon = appIcon ? <img alt={`${appName} Icon`} src={appIcon} /> : <ComputerIcon /> let icon = appIcon ? (
<img alt={`${appName} Icon`} src={appIcon} />
) : (
<ComputerIcon />
)
let tooltip = "" let tooltip = ""
if (health === "initializing") { if (health === "initializing") {
canClick = false canClick = false
@ -71,7 +78,12 @@ export const AppLink: FC<PropsWithChildren<AppLinkProps>> = ({
} }
const button = ( const button = (
<Button size="small" startIcon={icon} className={styles.button} disabled={!canClick}> <Button
size="small"
startIcon={icon}
className={styles.button}
disabled={!canClick}
>
{appName} {appName}
</Button> </Button>
) )

View File

@ -21,7 +21,9 @@ const getDiffValue = (value: unknown): string => {
return value.toString() return value.toString()
} }
export const AuditLogDiff: React.FC<{ diff: AuditLog["diff"] }> = ({ diff }) => { export const AuditLogDiff: React.FC<{ diff: AuditLog["diff"] }> = ({
diff,
}) => {
const styles = useStyles() const styles = useStyles()
const diffEntries = Object.entries(diff) const diffEntries = Object.entries(diff)
@ -34,7 +36,12 @@ export const AuditLogDiff: React.FC<{ diff: AuditLog["diff"] }> = ({ diff }) =>
<div className={styles.diffIcon}>-</div> <div className={styles.diffIcon}>-</div>
<div> <div>
{attrName}:{" "} {attrName}:{" "}
<span className={combineClasses([styles.diffValue, styles.diffValueOld])}> <span
className={combineClasses([
styles.diffValue,
styles.diffValueOld,
])}
>
{getDiffValue(valueDiff.old)} {getDiffValue(valueDiff.old)}
</span> </span>
</div> </div>
@ -48,7 +55,12 @@ export const AuditLogDiff: React.FC<{ diff: AuditLog["diff"] }> = ({ diff }) =>
<div className={styles.diffIcon}>+</div> <div className={styles.diffIcon}>+</div>
<div> <div>
{attrName}:{" "} {attrName}:{" "}
<span className={combineClasses([styles.diffValue, styles.diffValueNew])}> <span
className={combineClasses([
styles.diffValue,
styles.diffValueNew,
])}
>
{getDiffValue(valueDiff.new)} {getDiffValue(valueDiff.new)}
</span> </span>
</div> </div>

View File

@ -3,7 +3,10 @@ import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell" import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow" import TableRow from "@material-ui/core/TableRow"
import { AuditLog } from "api/typesGenerated" import { AuditLog } from "api/typesGenerated"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" import {
CloseDropdown,
OpenDropdown,
} from "components/DropdownArrows/DropdownArrows"
import { Pill } from "components/Pill/Pill" import { Pill } from "components/Pill/Pill"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { UserAvatar } from "components/UserAvatar/UserAvatar" import { UserAvatar } from "components/UserAvatar/UserAvatar"
@ -13,7 +16,9 @@ import userAgentParser from "ua-parser-js"
import { createDayString } from "util/createDayString" import { createDayString } from "util/createDayString"
import { AuditLogDiff } from "./AuditLogDiff" import { AuditLogDiff } from "./AuditLogDiff"
const pillTypeByHttpStatus = (httpStatus: number): ComponentProps<typeof Pill>["type"] => { const pillTypeByHttpStatus = (
httpStatus: number,
): ComponentProps<typeof Pill>["type"] => {
if (httpStatus >= 300 && httpStatus < 500) { if (httpStatus >= 300 && httpStatus < 500) {
return "warning" return "warning"
} }
@ -47,7 +52,9 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
const shouldDisplayDiff = diffs.length > 0 const shouldDisplayDiff = diffs.length > 0
const { os, browser } = userAgentParser(auditLog.user_agent) const { os, browser } = userAgentParser(auditLog.user_agent)
const notAvailableLabel = "Not available" const notAvailableLabel = "Not available"
const displayBrowserInfo = browser.name ? `${browser.name} ${browser.version}` : notAvailableLabel const displayBrowserInfo = browser.name
? `${browser.name} ${browser.version}`
: notAvailableLabel
const toggle = () => { const toggle = () => {
if (shouldDisplayDiff) { if (shouldDisplayDiff) {
@ -85,9 +92,13 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
<div> <div>
<span <span
className={styles.auditLogResume} className={styles.auditLogResume}
dangerouslySetInnerHTML={{ __html: readableActionMessage(auditLog) }} dangerouslySetInnerHTML={{
__html: readableActionMessage(auditLog),
}}
/> />
<span className={styles.auditLogTime}>{createDayString(auditLog.time)}</span> <span className={styles.auditLogTime}>
{createDayString(auditLog.time)}
</span>
</div> </div>
</Stack> </Stack>
@ -101,7 +112,11 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
type={pillTypeByHttpStatus(auditLog.status_code)} type={pillTypeByHttpStatus(auditLog.status_code)}
text={auditLog.status_code.toString()} text={auditLog.status_code.toString()}
/> />
<Stack direction="row" alignItems="center" className={styles.auditLogExtraInfo}> <Stack
direction="row"
alignItems="center"
className={styles.auditLogExtraInfo}
>
<div> <div>
<strong>IP</strong> {auditLog.ip ?? notAvailableLabel} <strong>IP</strong> {auditLog.ip ?? notAvailableLabel}
</div> </div>
@ -115,7 +130,11 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
</Stack> </Stack>
</Stack> </Stack>
<div className={shouldDisplayDiff ? undefined : styles.disabledDropdownIcon}> <div
className={
shouldDisplayDiff ? undefined : styles.disabledDropdownIcon
}
>
{isDiffOpen ? <CloseDropdown /> : <OpenDropdown />} {isDiffOpen ? <CloseDropdown /> : <OpenDropdown />}
</div> </div>
</Stack> </Stack>

View File

@ -6,7 +6,9 @@ export default {
component: AvatarData, component: AvatarData,
} }
const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => <AvatarData {...args} /> const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => (
<AvatarData {...args} />
)
export const Example = Template.bind({}) export const Example = Template.bind({})
Example.args = { Example.args = {

View File

@ -38,14 +38,22 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
{link ? ( {link ? (
<Link to={link} underline="none" component={RouterLink}> <Link to={link} underline="none" component={RouterLink}>
<TableCellData> <TableCellData>
<TableCellDataPrimary highlight={highlightTitle}>{title}</TableCellDataPrimary> <TableCellDataPrimary highlight={highlightTitle}>
{subtitle && <TableCellDataSecondary>{subtitle}</TableCellDataSecondary>} {title}
</TableCellDataPrimary>
{subtitle && (
<TableCellDataSecondary>{subtitle}</TableCellDataSecondary>
)}
</TableCellData> </TableCellData>
</Link> </Link>
) : ( ) : (
<TableCellData> <TableCellData>
<TableCellDataPrimary highlight={highlightTitle}>{title}</TableCellDataPrimary> <TableCellDataPrimary highlight={highlightTitle}>
{subtitle && <TableCellDataSecondary>{subtitle}</TableCellDataSecondary>} {title}
</TableCellDataPrimary>
{subtitle && (
<TableCellDataSecondary>{subtitle}</TableCellDataSecondary>
)}
</TableCellData> </TableCellData>
)} )}
</div> </div>

View File

@ -26,15 +26,9 @@ interface BorderedMenuRowProps {
onClick?: () => void onClick?: () => void
} }
export const BorderedMenuRow: FC<React.PropsWithChildren<BorderedMenuRowProps>> = ({ export const BorderedMenuRow: FC<
active, React.PropsWithChildren<BorderedMenuRowProps>
description, > = ({ active, description, Icon, path, title, variant, onClick }) => {
Icon,
path,
title,
variant,
onClick,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
@ -53,7 +47,11 @@ export const BorderedMenuRow: FC<React.PropsWithChildren<BorderedMenuRowProps>>
</div> </div>
{description && ( {description && (
<Typography className={styles.description} color="textSecondary" variant="caption"> <Typography
className={styles.description}
color="textSecondary"
variant="caption"
>
{ellipsizeText(description)} {ellipsizeText(description)}
</Typography> </Typography>
)} )}

View File

@ -11,7 +11,10 @@ import useTheme from "@material-ui/styles/useTheme"
import { FC } from "react" import { FC } from "react"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { displayWorkspaceBuildDuration, getDisplayWorkspaceBuildStatus } from "../../util/workspace" import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildStatus,
} from "../../util/workspace"
import { EmptyState } from "../EmptyState/EmptyState" import { EmptyState } from "../EmptyState/EmptyState"
import { TableCellLink } from "../TableCellLink/TableCellLink" import { TableCellLink } from "../TableCellLink/TableCellLink"
import { TableLoader } from "../TableLoader/TableLoader" import { TableLoader } from "../TableLoader/TableLoader"
@ -72,7 +75,9 @@ export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
}} }}
className={styles.clickableTableRow} className={styles.clickableTableRow}
> >
<TableCellLink to={buildPageLink}>{build.transition}</TableCellLink> <TableCellLink to={buildPageLink}>
{build.transition}
</TableCellLink>
<TableCellLink to={buildPageLink}> <TableCellLink to={buildPageLink}>
<span style={{ color: theme.palette.text.secondary }}> <span style={{ color: theme.palette.text.secondary }}>
{displayWorkspaceBuildDuration(build)} {displayWorkspaceBuildDuration(build)}
@ -84,7 +89,10 @@ export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
</span> </span>
</TableCellLink> </TableCellLink>
<TableCellLink to={buildPageLink}> <TableCellLink to={buildPageLink}>
<span style={{ color: status.color }} className={styles.status}> <span
style={{ color: status.color }}
className={styles.status}
>
{status.status} {status.status}
</span> </span>
</TableCellLink> </TableCellLink>

View File

@ -15,7 +15,9 @@ export default {
}, },
} }
const Template: Story<CodeBlockProps> = (args: CodeBlockProps) => <CodeBlock {...args} /> const Template: Story<CodeBlockProps> = (args: CodeBlockProps) => (
<CodeBlock {...args} />
)
export const Example = Template.bind({}) export const Example = Template.bind({})
Example.args = { Example.args = {

View File

@ -11,7 +11,9 @@ export default {
}, },
} }
const Template: Story<CodeExampleProps> = (args: CodeExampleProps) => <CodeExample {...args} /> const Template: Story<CodeExampleProps> = (args: CodeExampleProps) => (
<CodeExample {...args} />
)
export const Example = Template.bind({}) export const Example = Template.bind({})
Example.args = { Example.args = {

View File

@ -11,7 +11,9 @@ export interface CondProps {
* @param condition boolean expression indicating whether the child should be rendered, or undefined * @param condition boolean expression indicating whether the child should be rendered, or undefined
* @returns child. Note that Cond alone does not enforce the condition; it should be used inside ChooseOne. * @returns child. Note that Cond alone does not enforce the condition; it should be used inside ChooseOne.
*/ */
export const Cond = ({ children }: PropsWithChildren<CondProps>): JSX.Element => { export const Cond = ({
children,
}: PropsWithChildren<CondProps>): JSX.Element => {
return <>{children}</> return <>{children}</>
} }
@ -22,7 +24,9 @@ export const Cond = ({ children }: PropsWithChildren<CondProps>): JSX.Element =>
* @returns one of its children, or null if there are no children * @returns one of its children, or null if there are no children
* @throws an error if its last child has a condition prop, or any non-final children do not have a condition prop * @throws an error if its last child has a condition prop, or any non-final children do not have a condition prop
*/ */
export const ChooseOne = ({ children }: PropsWithChildren): JSX.Element | null => { 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) { if (childArray.length === 0) {
return null return null
@ -35,7 +39,9 @@ export const ChooseOne = ({ children }: PropsWithChildren): JSX.Element | null =
) )
} }
if (conditionedOptions.some((cond) => cond.props.condition === undefined)) { if (conditionedOptions.some((cond) => cond.props.condition === undefined)) {
throw new Error("A non-final Cond in a ChooseOne does not have a condition prop.") throw new Error(
"A non-final Cond in a ChooseOne does not have a condition prop.",
)
} }
const chosen = conditionedOptions.find((child) => child.props.condition) const chosen = conditionedOptions.find((child) => child.props.condition)
return chosen ?? defaultCase return chosen ?? defaultCase

View File

@ -6,7 +6,9 @@ export default {
component: Maybe, component: Maybe,
} }
const Template: Story<MaybeProps> = (args: MaybeProps) => <Maybe {...args}>Now you see me</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 = { ConditionIsTrue.args = {

View File

@ -53,7 +53,9 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
setIsCopied(false) setIsCopied(false)
}, 1000) }, 1000)
} else { } else {
const wrappedErr = new Error("copyToClipboard: failed to copy text to clipboard") const wrappedErr = new Error(
"copyToClipboard: failed to copy text to clipboard",
)
if (err instanceof Error) { if (err instanceof Error) {
wrappedErr.stack = err.stack wrappedErr.stack = err.stack
} }
@ -64,7 +66,9 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
return ( return (
<Tooltip title={tooltipTitle} placement="top"> <Tooltip title={tooltipTitle} placement="top">
<div className={combineClasses([styles.copyButtonWrapper, wrapperClassName])}> <div
className={combineClasses([styles.copyButtonWrapper, wrapperClassName])}
>
<IconButton <IconButton
className={combineClasses([styles.copyButton, buttonClassName])} className={combineClasses([styles.copyButton, buttonClassName])}
onClick={copyToClipboard} onClick={copyToClipboard}

View File

@ -4,7 +4,11 @@ import { FormikContextType, FormikErrors, useFormik } from "formik"
import { FC } from "react" import { FC } from "react"
import * as Yup from "yup" import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils" import {
getFormHelpers,
nameValidator,
onChangeTrimmed,
} from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter" import { FormFooter } from "../FormFooter/FormFooter"
import { FullPageForm } from "../FullPageForm/FullPageForm" import { FullPageForm } from "../FullPageForm/FullPageForm"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
@ -30,21 +34,19 @@ export interface CreateUserFormProps {
} }
const validationSchema = Yup.object({ const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired), email: Yup.string()
.trim()
.email(Language.emailInvalid)
.required(Language.emailRequired),
password: Yup.string().required(Language.passwordRequired), password: Yup.string().required(Language.passwordRequired),
username: nameValidator(Language.usernameLabel), username: nameValidator(Language.usernameLabel),
}) })
export const CreateUserForm: FC<React.PropsWithChildren<CreateUserFormProps>> = ({ export const CreateUserForm: FC<
onSubmit, React.PropsWithChildren<CreateUserFormProps>
onCancel, > = ({ onSubmit, onCancel, formErrors, isLoading, error, myOrgId }) => {
formErrors, const form: FormikContextType<TypesGen.CreateUserRequest> =
isLoading, useFormik<TypesGen.CreateUserRequest>({
error,
myOrgId,
}) => {
const form: FormikContextType<TypesGen.CreateUserRequest> = useFormik<TypesGen.CreateUserRequest>(
{
initialValues: { initialValues: {
email: "", email: "",
password: "", password: "",
@ -53,9 +55,11 @@ export const CreateUserForm: FC<React.PropsWithChildren<CreateUserFormProps>> =
}, },
validationSchema, validationSchema,
onSubmit, onSubmit,
}, })
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequest>(
form,
formErrors,
) )
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequest>(form, formErrors)
return ( return (
<FullPageForm title="Create user" onCancel={onCancel}> <FullPageForm title="Create user" onCancel={onCancel}>

View File

@ -21,7 +21,9 @@ export default {
}, },
} as ComponentMeta<typeof ConfirmDialog> } as ComponentMeta<typeof ConfirmDialog>
const Template: Story<ConfirmDialogProps> = (args) => <ConfirmDialog {...args} /> const Template: Story<ConfirmDialogProps> = (args) => (
<ConfirmDialog {...args} />
)
export const DeleteDialog = Template.bind({}) export const DeleteDialog = Template.bind({})
DeleteDialog.args = { DeleteDialog.args = {

View File

@ -2,7 +2,11 @@ import DialogActions from "@material-ui/core/DialogActions"
import { alpha, makeStyles } from "@material-ui/core/styles" import { alpha, makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography" import Typography from "@material-ui/core/Typography"
import React, { ReactNode } from "react" import React, { ReactNode } from "react"
import { Dialog, DialogActionButtons, DialogActionButtonsProps } from "../Dialog" import {
Dialog,
DialogActionButtons,
DialogActionButtonsProps,
} from "../Dialog"
import { ConfirmDialogType } from "../types" import { ConfirmDialogType } from "../types"
interface ConfirmDialogTypeConfig { interface ConfirmDialogTypeConfig {
@ -10,7 +14,10 @@ interface ConfirmDialogTypeConfig {
hideCancel: boolean hideCancel: boolean
} }
const CONFIRM_DIALOG_DEFAULTS: Record<ConfirmDialogType, ConfirmDialogTypeConfig> = { const CONFIRM_DIALOG_DEFAULTS: Record<
ConfirmDialogType,
ConfirmDialogTypeConfig
> = {
delete: { delete: {
confirmText: "Delete", confirmText: "Delete",
hideCancel: false, hideCancel: false,
@ -26,7 +33,10 @@ const CONFIRM_DIALOG_DEFAULTS: Record<ConfirmDialogType, ConfirmDialogTypeConfig
} }
export interface ConfirmDialogProps export interface ConfirmDialogProps
extends Omit<DialogActionButtonsProps, "color" | "confirmDialog" | "onCancel"> { extends Omit<
DialogActionButtonsProps,
"color" | "confirmDialog" | "onCancel"
> {
readonly description?: React.ReactNode readonly description?: React.ReactNode
/** /**
* hideCancel hides the cancel button when set true, and shows the cancel * hideCancel hides the cancel button when set true, and shows the cancel
@ -78,7 +88,9 @@ const useStyles = makeStyles((theme) => ({
* Quick-use version of the Dialog component with slightly alternative styles, * Quick-use version of the Dialog component with slightly alternative styles,
* great to use for dialogs that don't have any interaction beyond yes / no. * great to use for dialogs that don't have any interaction beyond yes / no.
*/ */
export const ConfirmDialog: React.FC<React.PropsWithChildren<ConfirmDialogProps>> = ({ export const ConfirmDialog: React.FC<
React.PropsWithChildren<ConfirmDialogProps>
> = ({
cancelText, cancelText,
confirmLoading, confirmLoading,
confirmText, confirmText,
@ -100,7 +112,12 @@ export const ConfirmDialog: React.FC<React.PropsWithChildren<ConfirmDialogProps>
} }
return ( return (
<Dialog className={styles.dialogWrapper} maxWidth="sm" onClose={onClose} open={open}> <Dialog
className={styles.dialogWrapper}
maxWidth="sm"
onClose={onClose}
open={open}
>
<div className={styles.dialogContent}> <div className={styles.dialogContent}>
<Typography className={styles.titleText} variant="h3"> <Typography className={styles.titleText} variant="h3">
{title} {title}

View File

@ -22,7 +22,8 @@ export default {
defaultValue: "MyFoo", defaultValue: "MyFoo",
}, },
info: { info: {
defaultValue: "Here's some info about the foo so you know you're deleting the right one.", defaultValue:
"Here's some info about the foo so you know you're deleting the right one.",
}, },
}, },
} as ComponentMeta<typeof DeleteDialog> } as ComponentMeta<typeof DeleteDialog>

View File

@ -30,7 +30,10 @@ describe("DeleteDialog", () => {
name="MyTemplate" name="MyTemplate"
/>, />,
) )
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "template" }) const labelText = t("deleteDialog.confirmLabel", {
ns: "common",
entity: "template",
})
const textField = screen.getByLabelText(labelText) const textField = screen.getByLabelText(labelText)
await userEvent.type(textField, "MyTemplateWrong") await userEvent.type(textField, "MyTemplateWrong")
const confirmButton = screen.getByRole("button", { name: "Delete" }) const confirmButton = screen.getByRole("button", { name: "Delete" })
@ -48,7 +51,10 @@ describe("DeleteDialog", () => {
name="MyTemplate" name="MyTemplate"
/>, />,
) )
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "template" }) const labelText = t("deleteDialog.confirmLabel", {
ns: "common",
entity: "template",
})
const textField = screen.getByLabelText(labelText) const textField = screen.getByLabelText(labelText)
await userEvent.type(textField, "MyTemplate") await userEvent.type(textField, "MyTemplate")
const confirmButton = screen.getByRole("button", { name: "Delete" }) const confirmButton = screen.getByRole("button", { name: "Delete" })

View File

@ -18,15 +18,9 @@ export interface DeleteDialogProps {
confirmLoading?: boolean confirmLoading?: boolean
} }
export const DeleteDialog: React.FC<React.PropsWithChildren<DeleteDialogProps>> = ({ export const DeleteDialog: React.FC<
isOpen, React.PropsWithChildren<DeleteDialogProps>
onCancel, > = ({ isOpen, onCancel, onConfirm, entity, info, name, confirmLoading }) => {
onConfirm,
entity,
info,
name,
confirmLoading,
}) => {
const styles = useStyles() const styles = useStyles()
const { t } = useTranslation("common") const { t } = useTranslation("common")
const [nameValue, setNameValue] = useState("") const [nameValue, setNameValue] = useState("")
@ -52,7 +46,9 @@ export const DeleteDialog: React.FC<React.PropsWithChildren<DeleteDialogProps>>
label={t("deleteDialog.confirmLabel", { entity })} label={t("deleteDialog.confirmLabel", { entity })}
/> />
<Maybe condition={nameValue.length > 0 && !confirmed}> <Maybe condition={nameValue.length > 0 && !confirmed}>
<FormHelperText error>{t("deleteDialog.incorrectName", { entity })}</FormHelperText> <FormHelperText error>
{t("deleteDialog.incorrectName", { entity })}
</FormHelperText>
</Maybe> </Maybe>
</Stack> </Stack>
</> </>

View File

@ -1,10 +1,15 @@
import MuiDialog, { DialogProps as MuiDialogProps } from "@material-ui/core/Dialog" import MuiDialog, {
DialogProps as MuiDialogProps,
} from "@material-ui/core/Dialog"
import MuiDialogTitle from "@material-ui/core/DialogTitle" import MuiDialogTitle from "@material-ui/core/DialogTitle"
import { alpha, darken, lighten, makeStyles } from "@material-ui/core/styles" import { alpha, darken, lighten, makeStyles } from "@material-ui/core/styles"
import SvgIcon from "@material-ui/core/SvgIcon" import SvgIcon from "@material-ui/core/SvgIcon"
import * as React from "react" import * as React from "react"
import { combineClasses } from "../../util/combineClasses" import { combineClasses } from "../../util/combineClasses"
import { LoadingButton, LoadingButtonProps } from "../LoadingButton/LoadingButton" import {
LoadingButton,
LoadingButtonProps,
} from "../LoadingButton/LoadingButton"
import { ConfirmDialogType } from "./types" import { ConfirmDialogType } from "./types"
export interface DialogTitleProps { export interface DialogTitleProps {
@ -19,7 +24,11 @@ export interface DialogTitleProps {
/** /**
* Override of Material UI's DialogTitle that allows for a supertitle and background icon * Override of Material UI's DialogTitle that allows for a supertitle and background icon
*/ */
export const DialogTitle: React.FC<DialogTitleProps> = ({ title, icon: Icon, superTitle }) => { export const DialogTitle: React.FC<DialogTitleProps> = ({
title,
icon: Icon,
superTitle,
}) => {
const styles = useTitleStyles() const styles = useTitleStyles()
return ( return (
<MuiDialogTitle disableTypography> <MuiDialogTitle disableTypography>
@ -164,7 +173,9 @@ const useButtonStyles = makeStyles((theme) => ({
}, },
confirmDialogCancelButton: (props: StyleProps) => { confirmDialogCancelButton: (props: StyleProps) => {
const color = const color =
props.type === "info" ? theme.palette.primary.contrastText : theme.palette.error.contrastText props.type === "info"
? theme.palette.primary.contrastText
: theme.palette.error.contrastText
return { return {
background: alpha(color, 0.15), background: alpha(color, 0.15),
color, color,
@ -222,7 +233,10 @@ const useButtonStyles = makeStyles((theme) => ({
color: theme.palette.error.main, color: theme.palette.error.main,
borderColor: theme.palette.error.main, borderColor: theme.palette.error.main,
"&:hover": { "&:hover": {
backgroundColor: alpha(theme.palette.error.main, theme.palette.action.hoverOpacity), backgroundColor: alpha(
theme.palette.error.main,
theme.palette.action.hoverOpacity,
),
"@media (hover: none)": { "@media (hover: none)": {
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
@ -239,7 +253,10 @@ const useButtonStyles = makeStyles((theme) => ({
"&.MuiButton-text": { "&.MuiButton-text": {
color: theme.palette.error.main, color: theme.palette.error.main,
"&:hover": { "&:hover": {
backgroundColor: alpha(theme.palette.error.main, theme.palette.action.hoverOpacity), backgroundColor: alpha(
theme.palette.error.main,
theme.palette.action.hoverOpacity,
),
"@media (hover: none)": { "@media (hover: none)": {
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
@ -272,7 +289,10 @@ const useButtonStyles = makeStyles((theme) => ({
color: theme.palette.success.main, color: theme.palette.success.main,
borderColor: theme.palette.success.main, borderColor: theme.palette.success.main,
"&:hover": { "&:hover": {
backgroundColor: alpha(theme.palette.success.main, theme.palette.action.hoverOpacity), backgroundColor: alpha(
theme.palette.success.main,
theme.palette.action.hoverOpacity,
),
"@media (hover: none)": { "@media (hover: none)": {
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
@ -289,7 +309,10 @@ const useButtonStyles = makeStyles((theme) => ({
"&.MuiButton-text": { "&.MuiButton-text": {
color: theme.palette.success.main, color: theme.palette.success.main,
"&:hover": { "&:hover": {
backgroundColor: alpha(theme.palette.success.main, theme.palette.action.hoverOpacity), backgroundColor: alpha(
theme.palette.success.main,
theme.palette.action.hoverOpacity,
),
"@media (hover: none)": { "@media (hover: none)": {
backgroundColor: "transparent", backgroundColor: "transparent",
}, },

View File

@ -1,6 +1,9 @@
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import { MockUser } from "../../../testHelpers/renderHelpers" import { MockUser } from "../../../testHelpers/renderHelpers"
import { ResetPasswordDialog, ResetPasswordDialogProps } from "./ResetPasswordDialog" import {
ResetPasswordDialog,
ResetPasswordDialogProps,
} from "./ResetPasswordDialog"
export default { export default {
title: "components/Dialogs/ResetPasswordDialog", title: "components/Dialogs/ResetPasswordDialog",
@ -11,9 +14,9 @@ export default {
}, },
} }
const Template: Story<ResetPasswordDialogProps> = (args: ResetPasswordDialogProps) => ( const Template: Story<ResetPasswordDialogProps> = (
<ResetPasswordDialog {...args} /> args: ResetPasswordDialogProps,
) ) => <ResetPasswordDialog {...args} />
export const Example = Template.bind({}) export const Example = Template.bind({})
Example.args = { Example.args = {

View File

@ -24,19 +24,16 @@ export const Language = {
confirmText: "Reset password", confirmText: "Reset password",
} }
export const ResetPasswordDialog: FC<React.PropsWithChildren<ResetPasswordDialogProps>> = ({ export const ResetPasswordDialog: FC<
open, React.PropsWithChildren<ResetPasswordDialogProps>
onClose, > = ({ open, onClose, onConfirm, user, newPassword, loading }) => {
onConfirm,
user,
newPassword,
loading,
}) => {
const styles = useStyles() const styles = useStyles()
const description = ( const description = (
<> <>
<DialogContentText variant="subtitle2">{Language.message(user?.username)}</DialogContentText> <DialogContentText variant="subtitle2">
{Language.message(user?.username)}
</DialogContentText>
<DialogContentText component="div" className={styles.codeBlock}> <DialogContentText component="div" className={styles.codeBlock}>
<CodeExample code={newPassword ?? ""} className={styles.codeExample} /> <CodeExample code={newPassword ?? ""} className={styles.codeExample} />
</DialogContentText> </DialogContentText>

View File

@ -22,7 +22,12 @@ interface ArrowProps {
export const OpenDropdown: FC<ArrowProps> = ({ margin = true, color }) => { export const OpenDropdown: FC<ArrowProps> = ({ margin = true, color }) => {
const styles = useStyles({ margin, color }) const styles = useStyles({ margin, color })
return <KeyboardArrowDown aria-label="open-dropdown" className={styles.arrowIcon} /> return (
<KeyboardArrowDown
aria-label="open-dropdown"
className={styles.arrowIcon}
/>
)
} }
export const CloseDropdown: FC<ArrowProps> = ({ margin = true, color }) => { export const CloseDropdown: FC<ArrowProps> = ({ margin = true, color }) => {

View File

@ -16,18 +16,26 @@ interface WorkspaceAction {
handleAction: () => void handleAction: () => void
} }
export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => { export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles() const styles = useStyles()
const { t } = useTranslation("workspacePage") const { t } = useTranslation("workspacePage")
return ( return (
<Button className={styles.actionButton} startIcon={<CloudQueueIcon />} onClick={handleAction}> <Button
className={styles.actionButton}
startIcon={<CloudQueueIcon />}
onClick={handleAction}
>
{t("actionButton.update")} {t("actionButton.update")}
</Button> </Button>
) )
} }
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => { export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles() const styles = useStyles()
const { t } = useTranslation("workspacePage") const { t } = useTranslation("workspacePage")
@ -41,7 +49,9 @@ export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ hand
) )
} }
export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => { export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles() const styles = useStyles()
const { t } = useTranslation("workspacePage") const { t } = useTranslation("workspacePage")
@ -55,7 +65,9 @@ export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handl
) )
} }
export const DeleteButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => { export const DeleteButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles() const styles = useStyles()
const { t } = useTranslation("workspacePage") const { t } = useTranslation("workspacePage")
@ -69,7 +81,9 @@ export const DeleteButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ han
) )
} }
export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => { export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles() const styles = useStyles()
// this is an icon button, so it's important to include an aria label // this is an icon button, so it's important to include an aria label
@ -94,7 +108,9 @@ interface DisabledProps {
label: string label: string
} }
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({ label }) => { export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({
label,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
@ -108,7 +124,9 @@ interface LoadingProps {
label: string label: string
} }
export const ActionLoadingButton: FC<React.PropsWithChildren<LoadingProps>> = ({ label }) => { export const ActionLoadingButton: FC<React.PropsWithChildren<LoadingProps>> = ({
label,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<LoadingButton <LoadingButton

View File

@ -1,6 +1,11 @@
import { action } from "@storybook/addon-actions" import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import { DeleteButton, DisabledButton, StartButton, UpdateButton } from "./ActionCtas" import {
DeleteButton,
DisabledButton,
StartButton,
UpdateButton,
} from "./ActionCtas"
import { DropdownButton, DropdownButtonProps } from "./DropdownButton" import { DropdownButton, DropdownButtonProps } from "./DropdownButton"
export default { export default {
@ -8,14 +13,22 @@ export default {
component: DropdownButton, component: DropdownButton,
} }
const Template: Story<DropdownButtonProps> = (args) => <DropdownButton {...args} /> const Template: Story<DropdownButtonProps> = (args) => (
<DropdownButton {...args} />
)
export const WithDropdown = Template.bind({}) export const WithDropdown = Template.bind({})
WithDropdown.args = { WithDropdown.args = {
primaryAction: <StartButton handleAction={action("start")} />, primaryAction: <StartButton handleAction={action("start")} />,
secondaryActions: [ secondaryActions: [
{ action: "update", button: <UpdateButton handleAction={action("update")} /> }, {
{ action: "delete", button: <DeleteButton handleAction={action("delete")} /> }, action: "update",
button: <UpdateButton handleAction={action("update")} />,
},
{
action: "delete",
button: <DeleteButton handleAction={action("delete")} />,
},
], ],
canCancel: false, canCancel: false,
} }

View File

@ -1,7 +1,10 @@
import Button from "@material-ui/core/Button" import Button from "@material-ui/core/Button"
import Popover from "@material-ui/core/Popover" import Popover from "@material-ui/core/Popover"
import { makeStyles, useTheme } from "@material-ui/core/styles" import { makeStyles, useTheme } from "@material-ui/core/styles"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" import {
CloseDropdown,
OpenDropdown,
} from "components/DropdownArrows/DropdownArrows"
import { DropdownContent } from "components/DropdownButton/DropdownContent/DropdownContent" import { DropdownContent } from "components/DropdownButton/DropdownContent/DropdownContent"
import { FC, ReactNode, useRef, useState } from "react" import { FC, ReactNode, useRef, useState } from "react"
import { CancelButton } from "./ActionCtas" import { CancelButton } from "./ActionCtas"
@ -51,7 +54,9 @@ export const DropdownButton: FC<DropdownButtonProps> = ({
{isOpen ? ( {isOpen ? (
<CloseDropdown /> <CloseDropdown />
) : ( ) : (
<OpenDropdown color={canOpen ? undefined : theme.palette.action.disabled} /> <OpenDropdown
color={canOpen ? undefined : theme.palette.action.disabled}
/>
)} )}
</Button> </Button>
<Popover <Popover
@ -105,6 +110,8 @@ const useStyles = makeStyles((theme) => ({
}, },
}, },
popoverPaper: { popoverPaper: {
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing(1)}px`, padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing(
1,
)}px`,
}, },
})) }))

View File

@ -6,9 +6,9 @@ export interface DropdownContentProps {
} }
/* secondary workspace CTAs */ /* secondary workspace CTAs */
export const DropdownContent: FC<React.PropsWithChildren<DropdownContentProps>> = ({ export const DropdownContent: FC<
secondaryActions, React.PropsWithChildren<DropdownContentProps>
}) => { > = ({ secondaryActions }) => {
const styles = useStyles() const styles = useStyles()
return ( return (

View File

@ -13,7 +13,9 @@ describe("EmptyState", () => {
it("renders description text", async () => { it("renders description text", async () => {
// When // When
render(<EmptyState message="Hello, world" description="Friendly greeting" />) render(
<EmptyState message="Hello, world" description="Friendly greeting" />,
)
// Then // Then
await screen.findByText("Hello, world") await screen.findByText("Hello, world")

View File

@ -23,8 +23,17 @@ export interface EmptyStateProps {
* EmptyState's props extend the [Material UI Box component](https://material-ui.com/components/box/) * EmptyState's props extend the [Material UI Box component](https://material-ui.com/components/box/)
* that you can directly pass props through to to customize the shape and layout of it. * that you can directly pass props through to to customize the shape and layout of it.
*/ */
export const EmptyState: FC<React.PropsWithChildren<EmptyStateProps>> = (props) => { export const EmptyState: FC<React.PropsWithChildren<EmptyStateProps>> = (
const { message, description, cta, descriptionClassName, className, ...boxProps } = props props,
) => {
const {
message,
description,
cta,
descriptionClassName,
className,
...boxProps
} = props
const styles = useStyles() const styles = useStyles()
return ( return (
@ -37,7 +46,10 @@ export const EmptyState: FC<React.PropsWithChildren<EmptyStateProps>> = (props)
<Typography <Typography
variant="body2" variant="body2"
color="textSecondary" color="textSecondary"
className={combineClasses([styles.description, descriptionClassName])} className={combineClasses([
styles.description,
descriptionClassName,
])}
> >
{description} {description}
</Typography> </Typography>

View File

@ -1,14 +1,17 @@
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import { EnterpriseSnackbar, EnterpriseSnackbarProps } from "./EnterpriseSnackbar" import {
EnterpriseSnackbar,
EnterpriseSnackbarProps,
} from "./EnterpriseSnackbar"
export default { export default {
title: "components/EnterpriseSnackbar", title: "components/EnterpriseSnackbar",
component: EnterpriseSnackbar, component: EnterpriseSnackbar,
} }
const Template: Story<EnterpriseSnackbarProps> = (args: EnterpriseSnackbarProps) => ( const Template: Story<EnterpriseSnackbarProps> = (
<EnterpriseSnackbar {...args} /> args: EnterpriseSnackbarProps,
) ) => <EnterpriseSnackbar {...args} />
export const Error = Template.bind({}) export const Error = Template.bind({})
Error.args = { Error.args = {

View File

@ -1,5 +1,7 @@
import IconButton from "@material-ui/core/IconButton" import IconButton from "@material-ui/core/IconButton"
import Snackbar, { SnackbarProps as MuiSnackbarProps } from "@material-ui/core/Snackbar" import Snackbar, {
SnackbarProps as MuiSnackbarProps,
} from "@material-ui/core/Snackbar"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import CloseIcon from "@material-ui/icons/Close" import CloseIcon from "@material-ui/icons/Close"
import { FC } from "react" import { FC } from "react"
@ -25,13 +27,9 @@ export interface EnterpriseSnackbarProps extends MuiSnackbarProps {
* *
* See original component's Material UI documentation here: https://material-ui.com/components/snackbars/ * See original component's Material UI documentation here: https://material-ui.com/components/snackbars/
*/ */
export const EnterpriseSnackbar: FC<React.PropsWithChildren<EnterpriseSnackbarProps>> = ({ export const EnterpriseSnackbar: FC<
onClose, React.PropsWithChildren<EnterpriseSnackbarProps>
variant = "info", > = ({ onClose, variant = "info", ContentProps = {}, action, ...rest }) => {
ContentProps = {},
action,
...rest
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (

View File

@ -11,7 +11,10 @@ interface ErrorBoundaryState {
* Our app's Error Boundary * Our app's Error Boundary
* Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html * Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html
*/ */
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) { constructor(props: ErrorBoundaryProps) {
super(props) super(props)
this.state = { error: null } this.state = { error: null }

View File

@ -1,6 +1,9 @@
import Link from "@material-ui/core/Link" import Link from "@material-ui/core/Link"
import makeStyles from "@material-ui/core/styles/makeStyles" import makeStyles from "@material-ui/core/styles/makeStyles"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" import {
CloseDropdown,
OpenDropdown,
} from "components/DropdownArrows/DropdownArrows"
import { PropsWithChildren, FC } from "react" import { PropsWithChildren, FC } from "react"
import Collapse from "@material-ui/core/Collapse" import Collapse from "@material-ui/core/Collapse"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"

View File

@ -10,7 +10,9 @@ describe("Footer", () => {
// Then // Then
await screen.findByText("Copyright", { exact: false }) await screen.findByText("Copyright", { exact: false })
await screen.findByText(Language.buildInfoText(MockBuildInfo)) await screen.findByText(Language.buildInfoText(MockBuildInfo))
const reportBugLink = screen.getByText(Language.reportBugLink, { exact: false }).closest("a") const reportBugLink = screen
.getByText(Language.reportBugLink, { exact: false })
.closest("a")
if (!reportBugLink) { if (!reportBugLink) {
throw new Error("Bug report link not found in footer") throw new Error("Bug report link not found in footer")
} }

View File

@ -19,7 +19,9 @@ export interface FooterProps {
buildInfo?: TypesGen.BuildInfoResponse buildInfo?: TypesGen.BuildInfoResponse
} }
export const Footer: React.FC<React.PropsWithChildren<FooterProps>> = ({ buildInfo }) => { export const Footer: React.FC<React.PropsWithChildren<FooterProps>> = ({
buildInfo,
}) => {
const styles = useFooterStyles() const styles = useFooterStyles()
const githubUrl = `https://github.com/coder/coder/issues/new?labels=needs+grooming&body=${encodeURIComponent(`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url}) const githubUrl = `https://github.com/coder/coder/issues/new?labels=needs+grooming&body=${encodeURIComponent(`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})
@ -38,14 +40,25 @@ export const Footer: React.FC<React.PropsWithChildren<FooterProps>> = ({ buildIn
target="_blank" target="_blank"
href={buildInfo.external_url} href={buildInfo.external_url}
> >
<AccountTreeIcon className={styles.icon} /> {Language.buildInfoText(buildInfo)} <AccountTreeIcon className={styles.icon} />{" "}
{Language.buildInfoText(buildInfo)}
</Link> </Link>
&nbsp;|&nbsp; &nbsp;|&nbsp;
<Link className={styles.link} variant="caption" target="_blank" href={githubUrl}> <Link
className={styles.link}
variant="caption"
target="_blank"
href={githubUrl}
>
<AssistantIcon className={styles.icon} /> {Language.reportBugLink} <AssistantIcon className={styles.icon} /> {Language.reportBugLink}
</Link> </Link>
&nbsp;|&nbsp; &nbsp;|&nbsp;
<Link className={styles.link} variant="caption" target="_blank" href={discordUrl}> <Link
className={styles.link}
variant="caption"
target="_blank"
href={discordUrl}
>
<ChatIcon className={styles.icon} /> {Language.discordLink} <ChatIcon className={styles.icon} /> {Language.discordLink}
</Link> </Link>
</div> </div>

View File

@ -9,7 +9,9 @@ export default {
}, },
} }
const Template: Story<FormCloseButtonProps> = (args) => <FormCloseButton {...args} /> const Template: Story<FormCloseButtonProps> = (args) => (
<FormCloseButton {...args} />
)
export const Example = Template.bind({}) export const Example = Template.bind({})
Example.args = {} Example.args = {}

View File

@ -8,9 +8,9 @@ export interface FormCloseButtonProps {
onClose: () => void onClose: () => void
} }
export const FormCloseButton: React.FC<React.PropsWithChildren<FormCloseButtonProps>> = ({ export const FormCloseButton: React.FC<
onClose, React.PropsWithChildren<FormCloseButtonProps>
}) => { > = ({ onClose }) => {
const styles = useStyles() const styles = useStyles()
useEffect(() => { useEffect(() => {

View File

@ -3,7 +3,10 @@ import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography" import Typography from "@material-ui/core/Typography"
import { ReactElement } from "react" import { ReactElement } from "react"
import { FormTextField, FormTextFieldProps } from "../FormTextField/FormTextField" import {
FormTextField,
FormTextFieldProps,
} from "../FormTextField/FormTextField"
export interface FormDropdownItem { export interface FormDropdownItem {
value: string value: string

View File

@ -53,7 +53,11 @@ export const FormSection: FC<React.PropsWithChildren<FormSectionProps>> = ({
{title} {title}
</Typography> </Typography>
{description && ( {description && (
<Typography className={styles.descriptionText} variant="body2" color="textSecondary"> <Typography
className={styles.descriptionText}
variant="body2"
color="textSecondary"
>
{description} {description}
</Typography> </Typography>
)} )}

View File

@ -12,7 +12,9 @@ namespace Helpers {
export const requiredValidationMsg = "required" export const requiredValidationMsg = "required"
export const Component: FC< export const Component: FC<
React.PropsWithChildren<Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">> React.PropsWithChildren<
Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">
>
> = (props) => { > = (props) => {
const form = useFormik<FormValues>({ const form = useFormik<FormValues>({
initialValues: { initialValues: {
@ -26,7 +28,9 @@ namespace Helpers {
}), }),
}) })
return <FormTextField<FormValues> {...props} form={form} formFieldName="name" /> return (
<FormTextField<FormValues> {...props} form={form} formFieldName="name" />
)
} }
} }

View File

@ -119,7 +119,8 @@ export const FormTextField = <T,>({
variant = "outlined", variant = "outlined",
...rest ...rest
}: FormTextFieldProps<T>): ReactElement => { }: FormTextFieldProps<T>): ReactElement => {
const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) const isError =
form.touched[formFieldName] && Boolean(form.errors[formFieldName])
// Conversion to a string primitive is necessary as formFieldName is an in // Conversion to a string primitive is necessary as formFieldName is an in
// indexable type such as a string, number or enum. // indexable type such as a string, number or enum.
@ -145,7 +146,10 @@ export const FormTextField = <T,>({
} }
const event = e const event = e
if (typeof eventTransform !== "undefined" && typeof event.target.value === "string") { if (
typeof eventTransform !== "undefined" &&
typeof event.target.value === "string"
) {
event.target.value = eventTransform(e.target.value) event.target.value = eventTransform(e.target.value)
} }
form.handleChange(event) form.handleChange(event)

View File

@ -18,7 +18,10 @@ const useStyles = makeStyles((theme) => ({
}, },
})) }))
export const FormTitle: FC<React.PropsWithChildren<FormTitleProps>> = ({ title, detail }) => { export const FormTitle: FC<React.PropsWithChildren<FormTitleProps>> = ({
title,
detail,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (

View File

@ -30,23 +30,36 @@ export const GlobalSnackbar: React.FC = () => {
const [open, setOpen] = useState<boolean>(false) const [open, setOpen] = useState<boolean>(false)
const [notification, setNotification] = useState<NotificationMsg>() const [notification, setNotification] = useState<NotificationMsg>()
const handleNotification = useCallback<CustomEventListener<NotificationMsg>>((event) => { const handleNotification = useCallback<CustomEventListener<NotificationMsg>>(
setNotification(event.detail) (event) => {
setOpen(true) setNotification(event.detail)
}, []) setOpen(true)
},
[],
)
useCustomEvent(SnackbarEventType, handleNotification) useCustomEvent(SnackbarEventType, handleNotification)
const renderAdditionalMessage = (msg: AdditionalMessage, idx: number) => { const renderAdditionalMessage = (msg: AdditionalMessage, idx: number) => {
if (isNotificationText(msg)) { if (isNotificationText(msg)) {
return ( return (
<Typography key={idx} gutterBottom variant="body2" className={styles.messageSubtitle}> <Typography
key={idx}
gutterBottom
variant="body2"
className={styles.messageSubtitle}
>
{msg} {msg}
</Typography> </Typography>
) )
} else if (isNotificationTextPrefixed(msg)) { } else if (isNotificationTextPrefixed(msg)) {
return ( return (
<Typography key={idx} gutterBottom variant="body2" className={styles.messageSubtitle}> <Typography
key={idx}
gutterBottom
variant="body2"
className={styles.messageSubtitle}
>
<strong>{msg.prefix}:</strong> {msg.text} <strong>{msg.prefix}:</strong> {msg.text}
</Typography> </Typography>
) )
@ -77,7 +90,9 @@ export const GlobalSnackbar: React.FC = () => {
variant={variantFromMsgType(notification.msgType)} variant={variantFromMsgType(notification.msgType)}
message={ message={
<div className={styles.messageWrapper}> <div className={styles.messageWrapper}>
{notification.msgType === MsgType.Error && <ErrorIcon className={styles.errorIcon} />} {notification.msgType === MsgType.Error && (
<ErrorIcon className={styles.errorIcon} />
)}
<div className={styles.message}> <div className={styles.message}>
<Typography variant="body1" className={styles.messageTitle}> <Typography variant="body1" className={styles.messageTitle}>
{notification.msg} {notification.msg}

View File

@ -48,13 +48,17 @@ describe("Snackbar", () => {
describe("displaySuccess", () => { describe("displaySuccess", () => {
const originalWindowDispatchEvent = window.dispatchEvent const originalWindowDispatchEvent = window.dispatchEvent
type TDispatchEventMock = jest.MockedFunction<(msg: CustomEvent<NotificationMsg>) => boolean> type TDispatchEventMock = jest.MockedFunction<
(msg: CustomEvent<NotificationMsg>) => boolean
>
let dispatchEventMock: TDispatchEventMock let dispatchEventMock: TDispatchEventMock
// Helper function to extract the notification event // Helper function to extract the notification event
// that was sent to `dispatchEvent`. This lets us validate // that was sent to `dispatchEvent`. This lets us validate
// the contents of the notification event are what we expect. // the contents of the notification event are what we expect.
const extractNotificationEvent = (dispatchEventMock: TDispatchEventMock): NotificationMsg => { const extractNotificationEvent = (
dispatchEventMock: TDispatchEventMock,
): NotificationMsg => {
// calls[0] is the first call made to the mock (this is reset in `beforeEach`) // calls[0] is the first call made to the mock (this is reset in `beforeEach`)
// calls[0][0] is the first argument of the first call // calls[0][0] is the first argument of the first call
// calls[0][0].detail is the 'detail' argument passed to the `CustomEvent` - // calls[0][0].detail is the 'detail' argument passed to the `CustomEvent` -
@ -64,7 +68,8 @@ describe("Snackbar", () => {
beforeEach(() => { beforeEach(() => {
dispatchEventMock = jest.fn() dispatchEventMock = jest.fn()
window.dispatchEvent = dispatchEventMock as unknown as typeof window.dispatchEvent window.dispatchEvent =
dispatchEventMock as unknown as typeof window.dispatchEvent
}) })
afterEach(() => { afterEach(() => {
@ -84,7 +89,9 @@ describe("Snackbar", () => {
// Then // Then
expect(dispatchEventMock).toBeCalledTimes(1) expect(dispatchEventMock).toBeCalledTimes(1)
expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected) expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(
expected,
)
}) })
it("can be called with a title and additional message", () => { it("can be called with a title and additional message", () => {
@ -100,7 +107,9 @@ describe("Snackbar", () => {
// Then // Then
expect(dispatchEventMock).toBeCalledTimes(1) expect(dispatchEventMock).toBeCalledTimes(1)
expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected) expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(
expected,
)
}) })
}) })

View File

@ -28,7 +28,10 @@ export const isNotificationTextPrefixed = (
msg: AdditionalMessage | null, msg: AdditionalMessage | null,
): msg is NotificationTextPrefixed => { ): msg is NotificationTextPrefixed => {
if (msg) { if (msg) {
return typeof msg !== "string" && Object.prototype.hasOwnProperty.call(msg, "prefix") return (
typeof msg !== "string" &&
Object.prototype.hasOwnProperty.call(msg, "prefix")
)
} }
return false return false
} }
@ -62,13 +65,25 @@ function dispatchNotificationEvent(
} }
export const displayMsg = (msg: string, additionalMsg?: string): void => { export const displayMsg = (msg: string, additionalMsg?: string): void => {
dispatchNotificationEvent(MsgType.Info, msg, additionalMsg ? [additionalMsg] : undefined) dispatchNotificationEvent(
MsgType.Info,
msg,
additionalMsg ? [additionalMsg] : undefined,
)
} }
export const displaySuccess = (msg: string, additionalMsg?: string): void => { export const displaySuccess = (msg: string, additionalMsg?: string): void => {
dispatchNotificationEvent(MsgType.Success, msg, additionalMsg ? [additionalMsg] : undefined) dispatchNotificationEvent(
MsgType.Success,
msg,
additionalMsg ? [additionalMsg] : undefined,
)
} }
export const displayError = (msg: string, additionalMsg?: string): void => { export const displayError = (msg: string, additionalMsg?: string): void => {
dispatchNotificationEvent(MsgType.Error, msg, additionalMsg ? [additionalMsg] : undefined) dispatchNotificationEvent(
MsgType.Error,
msg,
additionalMsg ? [additionalMsg] : undefined,
)
} }

View File

@ -2,7 +2,13 @@ import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const UsersOutlinedIcon: typeof SvgIcon = (props: SvgIconProps) => ( export const UsersOutlinedIcon: typeof SvgIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 20 20"> <SvgIcon {...props} viewBox="0 0 20 20">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M18.75 18.75H17.5V15.625V15.625C17.498 13.8999 16.1001 12.502 14.375 12.5V11.25C16.7901 11.2527 18.7473 13.2099 18.75 15.625L18.75 18.75Z" d="M18.75 18.75H17.5V15.625V15.625C17.498 13.8999 16.1001 12.502 14.375 12.5V11.25C16.7901 11.2527 18.7473 13.2099 18.75 15.625L18.75 18.75Z"
fill="#677693" fill="#677693"

View File

@ -5,7 +5,9 @@ import { LicenseBannerView } from "./LicenseBannerView"
export const LicenseBanner: React.FC = () => { export const LicenseBanner: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [entitlementsState, entitlementsSend] = useActor(xServices.entitlementsXService) const [entitlementsState, entitlementsSend] = useActor(
xServices.entitlementsXService,
)
const { warnings } = entitlementsState.context.entitlements const { warnings } = entitlementsState.context.entitlements
/** Gets license data on app mount because LicenseBanner is mounted in App */ /** Gets license data on app mount because LicenseBanner is mounted in App */

View File

@ -6,7 +6,9 @@ export default {
component: LicenseBannerView, component: LicenseBannerView,
} }
const Template: Story<LicenseBannerViewProps> = (args) => <LicenseBannerView {...args} /> const Template: Story<LicenseBannerViewProps> = (args) => (
<LicenseBannerView {...args} />
)
export const OneWarning = Template.bind({}) export const OneWarning = Template.bind({})
OneWarning.args = { OneWarning.args = {

View File

@ -16,7 +16,9 @@ export interface LicenseBannerViewProps {
warnings: string[] warnings: string[]
} }
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({ warnings }) => { export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
warnings,
}) => {
const styles = useStyles() const styles = useStyles()
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
if (warnings.length === 1) { if (warnings.length === 1) {
@ -35,7 +37,11 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({ warnings }
<div className={styles.container}> <div className={styles.container}>
<div className={styles.flex}> <div className={styles.flex}>
<div className={styles.leftContent}> <div className={styles.leftContent}>
<Pill text={Language.licenseIssues(warnings.length)} type="warning" lightBorder /> <Pill
text={Language.licenseIssues(warnings.length)}
type="warning"
lightBorder
/>
<span className={styles.text}>{Language.exceeded}</span> <span className={styles.text}>{Language.exceeded}</span>
&nbsp; &nbsp;
<a href="mailto:sales@coder.com" className={styles.link}> <a href="mailto:sales@coder.com" className={styles.link}>

View File

@ -2,9 +2,17 @@ import Box from "@material-ui/core/Box"
import CircularProgress from "@material-ui/core/CircularProgress" import CircularProgress from "@material-ui/core/CircularProgress"
import { FC } from "react" import { FC } from "react"
export const Loader: FC<React.PropsWithChildren<{ size?: number }>> = ({ size = 26 }) => { export const Loader: FC<React.PropsWithChildren<{ size?: number }>> = ({
size = 26,
}) => {
return ( return (
<Box p={4} width="100%" display="flex" alignItems="center" justifyContent="center"> <Box
p={4}
width="100%"
display="flex"
alignItems="center"
justifyContent="center"
>
<CircularProgress size={size} /> <CircularProgress size={size} />
</Box> </Box>
) )

View File

@ -10,7 +10,9 @@ export default {
}, },
} }
const Template: Story<LoadingButtonProps> = (args) => <LoadingButton {...args} /> const Template: Story<LoadingButtonProps> = (args) => (
<LoadingButton {...args} />
)
export const Loading = Template.bind({}) export const Loading = Template.bind({})
Loading.args = { Loading.args = {

View File

@ -14,14 +14,19 @@ export interface LogsProps {
className?: string className?: string
} }
export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({ lines, className = "" }) => { export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
lines,
className = "",
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<div className={combineClasses([className, styles.root])}> <div className={combineClasses([className, styles.root])}>
{lines.map((line, idx) => ( {lines.map((line, idx) => (
<div className={styles.line} key={idx}> <div className={styles.line} key={idx}>
<span className={styles.time}>{dayjs(line.time).format(`HH:mm:ss.SSS`)}</span> <span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span> <span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span>{line.output}</span> <span>{line.output}</span>
</div> </div>

View File

@ -6,7 +6,9 @@ export default {
component: Markdown, component: Markdown,
} as ComponentMeta<typeof Markdown> } as ComponentMeta<typeof Markdown>
const Template: Story<MarkdownProps> = ({ children }) => <Markdown>{children}</Markdown> const Template: Story<MarkdownProps> = ({ children }) => (
<Markdown>{children}</Markdown>
)
export const WithCode = Template.bind({}) export const WithCode = Template.bind({})
WithCode.args = { WithCode.args = {

View File

@ -2,7 +2,10 @@ import { render, screen, waitFor } from "@testing-library/react"
import { App } from "app" import { App } from "app"
import { Language } from "components/NavbarView/NavbarView" import { Language } from "components/NavbarView/NavbarView"
import { rest } from "msw" import { rest } from "msw"
import { MockEntitlementsWithAuditLog, MockMemberPermissions } from "testHelpers/renderHelpers" import {
MockEntitlementsWithAuditLog,
MockMemberPermissions,
} from "testHelpers/renderHelpers"
import { server } from "testHelpers/server" import { server } from "testHelpers/server"
/** /**

View File

@ -15,8 +15,15 @@ export const Navbar: React.FC = () => {
shallowEqual, shallowEqual,
) )
const canViewAuditLog = const canViewAuditLog =
featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) featureVisibility[FeatureNames.AuditLog] &&
Boolean(permissions?.viewAuditLog)
const onSignOut = () => authSend("SIGN_OUT") const onSignOut = () => authSend("SIGN_OUT")
return <NavbarView user={me} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} /> return (
<NavbarView
user={me}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
/>
)
} }

View File

@ -10,7 +10,9 @@ export default {
}, },
} }
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView {...args} /> const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => (
<NavbarView {...args} />
)
export const ForAdmin = Template.bind({}) export const ForAdmin = Template.bind({})
ForAdmin.args = { ForAdmin.args = {

View File

@ -70,7 +70,9 @@ describe("NavbarView", () => {
}) })
it("audit nav link is hidden for members", async () => { it("audit nav link is hidden for members", async () => {
render(<NavbarView user={MockUser2} onSignOut={noop} canViewAuditLog={false} />) render(
<NavbarView user={MockUser2} onSignOut={noop} canViewAuditLog={false} />,
)
const auditLink = screen.queryByText(navLanguage.audit) const auditLink = screen.queryByText(navLanguage.audit)
expect(auditLink).not.toBeInTheDocument() expect(auditLink).not.toBeInTheDocument()
}) })

View File

@ -36,7 +36,10 @@ const NavItems: React.FC<
<List className={combineClasses([styles.navItems, className])}> <List className={combineClasses([styles.navItems, className])}>
<ListItem button className={styles.item}> <ListItem button className={styles.item}>
<NavLink <NavLink
className={combineClasses([styles.link, location.pathname.startsWith("/@") && "active"])} className={combineClasses([
styles.link,
location.pathname.startsWith("/@") && "active",
])}
to="/workspaces" to="/workspaces"
> >
{Language.workspaces} {Language.workspaces}
@ -86,7 +89,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Drawer anchor="left" open={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}> <Drawer
anchor="left"
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
>
<div className={styles.drawer}> <div className={styles.drawer}>
<div className={styles.drawerHeader}> <div className={styles.drawerHeader}>
<Logo fill="white" opacity={1} width={125} /> <Logo fill="white" opacity={1} width={125} />
@ -99,7 +106,10 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<Logo fill="white" opacity={1} width={125} /> <Logo fill="white" opacity={1} width={125} />
</NavLink> </NavLink>
<NavItems className={styles.desktopNavItems} canViewAuditLog={canViewAuditLog} /> <NavItems
className={styles.desktopNavItems}
canViewAuditLog={canViewAuditLog}
/>
<div className={styles.profileButton}> <div className={styles.profileButton}>
{user && <UserDropdown user={user} onSignOut={onSignOut} />} {user && <UserDropdown user={user} onSignOut={onSignOut} />}

View File

@ -17,7 +17,9 @@ export const WithTitle = WithTitleTemplate.bind({})
const WithSubtitleTemplate: Story = () => ( const WithSubtitleTemplate: Story = () => (
<PageHeader> <PageHeader>
<PageHeaderTitle>Templates</PageHeaderTitle> <PageHeaderTitle>Templates</PageHeaderTitle>
<PageHeaderSubtitle>Create a new workspace from a Template</PageHeaderSubtitle> <PageHeaderSubtitle>
Create a new workspace from a Template
</PageHeaderSubtitle>
</PageHeader> </PageHeader>
) )

View File

@ -26,16 +26,17 @@ export const PageHeader: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
) )
} }
export const PageHeaderTitle: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => { export const PageHeaderTitle: React.FC<React.PropsWithChildren<unknown>> = ({
children,
}) => {
const styles = useStyles({}) const styles = useStyles({})
return <h1 className={styles.title}>{children}</h1> return <h1 className={styles.title}>{children}</h1>
} }
export const PageHeaderSubtitle: React.FC<React.PropsWithChildren<{ condensed?: boolean }>> = ({ export const PageHeaderSubtitle: React.FC<
children, React.PropsWithChildren<{ condensed?: boolean }>
condensed, > = ({ children, condensed }) => {
}) => {
const styles = useStyles({ const styles = useStyles({
condensed, condensed,
}) })

View File

@ -7,9 +7,9 @@ export default {
component: PaginationWidget, component: PaginationWidget,
} }
const Template: Story<PaginationWidgetProps> = (args: PaginationWidgetProps) => ( const Template: Story<PaginationWidgetProps> = (
<PaginationWidget {...args} /> args: PaginationWidgetProps,
) ) => <PaginationWidget {...args} />
const defaultProps = { const defaultProps = {
prevLabel: "Previous", prevLabel: "Previous",

View File

@ -13,10 +13,16 @@ describe("PaginatedList", () => {
/>, />,
) )
expect(await screen.findByRole("button", { name: "Previous page" })).toBeTruthy() expect(
expect(await screen.findByRole("button", { name: "Next page" })).toBeTruthy() await screen.findByRole("button", { name: "Previous page" }),
).toBeTruthy()
expect(
await screen.findByRole("button", { name: "Next page" }),
).toBeTruthy()
// Shouldn't render any pages if no records are passed in // Shouldn't render any pages if no records are passed in
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(0) expect(
await container.querySelectorAll(`button[name="Page button"]`),
).toHaveLength(0)
}) })
it("displays the expected number of pages with one ellipsis tile", async () => { it("displays the expected number of pages with one ellipsis tile", async () => {
@ -34,7 +40,9 @@ describe("PaginatedList", () => {
) )
// 7 total spaces. 6 are page numbers, one is ellipsis // 7 total spaces. 6 are page numbers, one is ellipsis
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(6) expect(
await container.querySelectorAll(`button[name="Page button"]`),
).toHaveLength(6)
}) })
it("displays the expected number of pages with two ellipsis tiles", async () => { it("displays the expected number of pages with two ellipsis tiles", async () => {
@ -52,6 +60,8 @@ describe("PaginatedList", () => {
) )
// 7 total spaces. 2 sets of ellipsis on either side of the active page // 7 total spaces. 2 sets of ellipsis on either side of the active page
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(5) expect(
await container.querySelectorAll(`button[name="Page button"]`),
).toHaveLength(5)
}) })
}) })

View File

@ -38,7 +38,10 @@ const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2
* Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages. * Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages.
* List result is used to from the buttons that make up the Pagination Widget * List result is used to from the buttons that make up the Pagination Widget
*/ */
export const buildPagedList = (numPages: number, activePage: number): (string | number)[] => { export const buildPagedList = (
numPages: number,
activePage: number,
): (string | number)[] => {
if (numPages > NUM_PAGE_BLOCKS) { if (numPages > NUM_PAGE_BLOCKS) {
let pages = [] let pages = []
const leftBound = activePage - PAGE_NEIGHBORS const leftBound = activePage - PAGE_NEIGHBORS
@ -128,7 +131,11 @@ export const PaginationWidget = ({
</Button> </Button>
), ),
)} )}
<Button aria-label="Next page" disabled={lastPageActive} onClick={onNextClick}> <Button
aria-label="Next page"
disabled={lastPageActive}
onClick={onNextClick}
>
<div>{nextLabel}</div> <div>{nextLabel}</div>
<KeyboardArrowRight /> <KeyboardArrowRight />
</Button> </Button>

View File

@ -2,13 +2,28 @@ import { buildPagedList } from "./PaginationWidget"
describe("unit/PaginationWidget", () => { describe("unit/PaginationWidget", () => {
describe("buildPagedList", () => { describe("buildPagedList", () => {
it.each<{ numPages: number; activePage: number; expected: (string | number)[] }>([ it.each<{
numPages: number
activePage: number
expected: (string | number)[]
}>([
{ numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] }, { numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] },
{ numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] }, { numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] },
{ numPages: 17, activePage: 9, expected: [1, "left", 8, 9, 10, "right", 17] }, {
{ numPages: 17, activePage: 17, expected: [1, "left", 13, 14, 15, 16, 17] }, numPages: 17,
])(`buildPagedList($numPages, $activePage)`, ({ numPages, activePage, expected }) => { activePage: 9,
expect(buildPagedList(numPages, activePage)).toEqual(expected) expected: [1, "left", 8, 9, 10, "right", 17],
}) },
{
numPages: 17,
activePage: 17,
expected: [1, "left", 13, 14, 15, 16, 17],
},
])(
`buildPagedList($numPages, $activePage)`,
({ numPages, activePage, expected }) => {
expect(buildPagedList(numPages, activePage)).toEqual(expected)
},
)
}) })
}) })

View File

@ -11,7 +11,9 @@ const Template: Story<ParameterInputProps> = (args: ParameterInputProps) => (
<ParameterInput {...args} /> <ParameterInput {...args} />
) )
const createParameterSchema = (partial: Partial<ParameterSchema>): ParameterSchema => { const createParameterSchema = (
partial: Partial<ParameterSchema>,
): ParameterSchema => {
return { return {
id: "000000", id: "000000",
job_id: "000000", job_id: "000000",
@ -38,7 +40,8 @@ export const Basic = Template.bind({})
Basic.args = { Basic.args = {
schema: createParameterSchema({ schema: createParameterSchema({
name: "project_name", name: "project_name",
description: "Customize the name of a Google Cloud project that will be created!", description:
"Customize the name of a Google Cloud project that will be created!",
}), }),
} }
@ -58,6 +61,11 @@ Contains.args = {
name: "region", name: "region",
default_source_value: "🏈 US Central", default_source_value: "🏈 US Central",
description: "Where would you like your workspace to live?", description: "Where would you like your workspace to live?",
validation_contains: ["🏈 US Central", "⚽ Brazil East", "💶 EU West", "🦘 Australia South"], validation_contains: [
"🏈 US Central",
"⚽ Brazil East",
"💶 EU West",
"🦘 Australia South",
],
}), }),
} }

View File

@ -19,7 +19,9 @@ const ParameterLabel: React.FC<{ schema: ParameterSchema }> = ({ schema }) => {
return ( return (
<label className={styles.label} htmlFor={schema.name}> <label className={styles.label} htmlFor={schema.name}>
<strong>var.{schema.name}</strong> <strong>var.{schema.name}</strong>
{schema.description && <span className={styles.labelDescription}>{schema.description}</span>} {schema.description && (
<span className={styles.labelDescription}>{schema.description}</span>
)}
</label> </label>
) )
} }
@ -30,28 +32,28 @@ export interface ParameterInputProps {
onChange: (value: string) => void onChange: (value: string) => void
} }
export const ParameterInput: FC<React.PropsWithChildren<ParameterInputProps>> = ({ export const ParameterInput: FC<
disabled, React.PropsWithChildren<ParameterInputProps>
onChange, > = ({ disabled, onChange, schema }) => {
schema,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<Stack direction="column" className={styles.root}> <Stack direction="column" className={styles.root}>
<ParameterLabel schema={schema} /> <ParameterLabel schema={schema} />
<div className={styles.input}> <div className={styles.input}>
<ParameterField disabled={disabled} onChange={onChange} schema={schema} /> <ParameterField
disabled={disabled}
onChange={onChange}
schema={schema}
/>
</div> </div>
</Stack> </Stack>
) )
} }
const ParameterField: React.FC<React.PropsWithChildren<ParameterInputProps>> = ({ const ParameterField: React.FC<
disabled, React.PropsWithChildren<ParameterInputProps>
onChange, > = ({ disabled, onChange, schema }) => {
schema,
}) => {
if (schema.validation_contains && schema.validation_contains.length > 0) { if (schema.validation_contains && schema.validation_contains.length > 0) {
return ( return (
<TextField <TextField

View File

@ -8,10 +8,9 @@ import React, { useCallback, useState } from "react"
type PasswordFieldProps = Omit<TextFieldProps, "InputProps" | "type"> type PasswordFieldProps = Omit<TextFieldProps, "InputProps" | "type">
export const PasswordField: React.FC<React.PropsWithChildren<PasswordFieldProps>> = ({ export const PasswordField: React.FC<
variant = "outlined", React.PropsWithChildren<PasswordFieldProps>
...rest > = ({ variant = "outlined", ...rest }) => {
}) => {
const styles = useStyles() const styles = useStyles()
const [showPassword, setShowPassword] = useState<boolean>(false) const [showPassword, setShowPassword] = useState<boolean>(false)
@ -19,7 +18,9 @@ export const PasswordField: React.FC<React.PropsWithChildren<PasswordFieldProps>
() => setShowPassword((showPassword) => !showPassword), () => setShowPassword((showPassword) => !showPassword),
[], [],
) )
const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined const VisibilityIcon = showPassword
? VisibilityOffOutlined
: VisibilityOutlined
return ( return (
<TextField <TextField

View File

@ -16,7 +16,10 @@ export const Pill: FC<PillProps> = (props) => {
const { className, icon, text = false } = props const { className, icon, text = false } = props
const styles = useStyles(props) const styles = useStyles(props)
return ( return (
<div className={combineClasses([styles.wrapper, styles.pillColor, className])} role="status"> <div
className={combineClasses([styles.wrapper, styles.pillColor, className])}
role="status"
>
{icon && <div className={styles.iconWrapper}>{icon}</div>} {icon && <div className={styles.iconWrapper}>{icon}</div>}
{text} {text}
</div> </div>
@ -35,13 +38,15 @@ const useStyles = makeStyles<Theme, PillProps>((theme) => ({
fontWeight: 500, fontWeight: 500,
color: "#FFF", color: "#FFF",
height: theme.spacing(3), height: theme.spacing(3),
paddingLeft: ({ icon }) => (icon ? theme.spacing(0.75) : theme.spacing(1.5)), paddingLeft: ({ icon }) =>
icon ? theme.spacing(0.75) : theme.spacing(1.5),
paddingRight: theme.spacing(1.5), paddingRight: theme.spacing(1.5),
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
pillColor: { pillColor: {
backgroundColor: ({ type }) => (type ? theme.palette[type].dark : theme.palette.text.secondary), backgroundColor: ({ type }) =>
type ? theme.palette[type].dark : theme.palette.text.secondary,
borderColor: ({ type, lightBorder }) => borderColor: ({ type, lightBorder }) =>
type type
? lightBorder ? lightBorder

View File

@ -9,7 +9,11 @@ import { Stack } from "components/Stack/Stack"
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { colors } from "theme/colors" import { colors } from "theme/colors"
import { CodeExample } from "../CodeExample/CodeExample" import { CodeExample } from "../CodeExample/CodeExample"
import { HelpTooltipLink, HelpTooltipLinksGroup, HelpTooltipText } from "../Tooltips/HelpTooltip" import {
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
} from "../Tooltips/HelpTooltip"
export interface PortForwardButtonProps { export interface PortForwardButtonProps {
host: string host: string
@ -28,13 +32,16 @@ const EnabledView: React.FC<PortForwardButtonProps> = (props) => {
return ( return (
<Stack direction="column" spacing={1}> <Stack direction="column" spacing={1}>
<HelpTooltipText> <HelpTooltipText>
Access ports running on the agent with the <strong>port, agent name, workspace name</strong>{" "} Access ports running on the agent with the{" "}
and <strong>your username</strong> URL schema, as shown below. <strong>port, agent name, workspace name</strong> and{" "}
<strong>your username</strong> URL schema, as shown below.
</HelpTooltipText> </HelpTooltipText>
<CodeExample code={urlExample} /> <CodeExample code={urlExample} />
<HelpTooltipText>Use the form to open applications in a new tab.</HelpTooltipText> <HelpTooltipText>
Use the form to open applications in a new tab.
</HelpTooltipText>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<TextField <TextField
@ -70,8 +77,8 @@ const DisabledView: React.FC<PortForwardButtonProps> = () => {
return ( return (
<Stack direction="column" spacing={1}> <Stack direction="column" spacing={1}>
<HelpTooltipText> <HelpTooltipText>
<strong>Your deployment does not have port forward enabled.</strong> See the docs for more <strong>Your deployment does not have port forward enabled.</strong> See
details. the docs for more details.
</HelpTooltipText> </HelpTooltipText>
<HelpTooltipLinksGroup> <HelpTooltipLinksGroup>
@ -136,7 +143,9 @@ export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
popoverPaper: { popoverPaper: {
padding: `${theme.spacing(2.5)}px ${theme.spacing(3.5)}px ${theme.spacing(3.5)}px`, padding: `${theme.spacing(2.5)}px ${theme.spacing(3.5)}px ${theme.spacing(
3.5,
)}px`,
width: theme.spacing(46), width: theme.spacing(46),
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
marginTop: theme.spacing(0.25), marginTop: theme.spacing(0.25),

View File

@ -9,7 +9,9 @@ export interface RequireAuthProps {
children: JSX.Element children: JSX.Element
} }
export const RequireAuth: React.FC<React.PropsWithChildren<RequireAuthProps>> = ({ children }) => { export const RequireAuth: React.FC<
React.PropsWithChildren<RequireAuthProps>
> = ({ children }) => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService) const [authState] = useActor(xServices.authXService)
const location = useLocation() const location = useLocation()

View File

@ -9,7 +9,10 @@ export interface RequirePermissionProps {
/** /**
* Wraps routes that are available based on RBAC or licensing. * Wraps routes that are available based on RBAC or licensing.
*/ */
export const RequirePermission: FC<RequirePermissionProps> = ({ children, isFeatureVisible }) => { export const RequirePermission: FC<RequirePermissionProps> = ({
children,
isFeatureVisible,
}) => {
if (!isFeatureVisible) { if (!isFeatureVisible) {
return <Navigate to="/workspaces" /> return <Navigate to="/workspaces" />
} else { } else {

View File

@ -1,12 +1,17 @@
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import { ResourceAgentLatency, ResourceAgentLatencyProps } from "./ResourceAgentLatency" import {
ResourceAgentLatency,
ResourceAgentLatencyProps,
} from "./ResourceAgentLatency"
export default { export default {
title: "components/ResourceAgentLatency", title: "components/ResourceAgentLatency",
component: ResourceAgentLatency, component: ResourceAgentLatency,
} }
const Template: Story<ResourceAgentLatencyProps> = (args) => <ResourceAgentLatency {...args} /> const Template: Story<ResourceAgentLatencyProps> = (args) => (
<ResourceAgentLatency {...args} />
)
export const Single = Template.bind({}) export const Single = Template.bind({})
Single.args = { Single.args = {

View File

@ -8,7 +8,9 @@ export interface ResourceAgentLatencyProps {
latency: WorkspaceAgent["latency"] latency: WorkspaceAgent["latency"]
} }
export const ResourceAgentLatency: React.FC<ResourceAgentLatencyProps> = (props) => { export const ResourceAgentLatency: React.FC<ResourceAgentLatencyProps> = (
props,
) => {
const styles = useStyles() const styles = useStyles()
if (!props.latency) { if (!props.latency) {
return null return null
@ -23,8 +25,8 @@ export const ResourceAgentLatency: React.FC<ResourceAgentLatencyProps> = (props)
<b>Latency</b> <b>Latency</b>
<HelpTooltip size="small"> <HelpTooltip size="small">
<HelpTooltipText> <HelpTooltipText>
Latency from relay servers, used when connections cannot connect peer-to-peer. Star Latency from relay servers, used when connections cannot connect
indicates the preferred relay. peer-to-peer. Star indicates the preferred relay.
</HelpTooltipText> </HelpTooltipText>
</HelpTooltip> </HelpTooltip>
</div> </div>
@ -34,7 +36,8 @@ export const ResourceAgentLatency: React.FC<ResourceAgentLatencyProps> = (props)
const value = latency[region] const value = latency[region]
return ( return (
<div key={region} className={styles.region}> <div key={region} className={styles.region}>
<b>{region}:</b>&nbsp;{Math.round(value.latency_ms * 100) / 100} ms <b>{region}:</b>&nbsp;{Math.round(value.latency_ms * 100) / 100}{" "}
ms
{value.preferred && <StarIcon className={styles.star} />} {value.preferred && <StarIcon className={styles.star} />}
</div> </div>
) )

View File

@ -7,7 +7,9 @@ export default {
component: ResourceAvatar, component: ResourceAvatar,
} }
const Template: Story<ResourceAvatarProps> = (args) => <ResourceAvatar {...args} /> const Template: Story<ResourceAvatarProps> = (args) => (
<ResourceAvatar {...args} />
)
export const VolumeResource = Template.bind({}) export const VolumeResource = Template.bind({})
VolumeResource.args = { VolumeResource.args = {

View File

@ -5,7 +5,10 @@ import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined"
import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined"
import { WorkspaceResource } from "api/typesGenerated" import { WorkspaceResource } from "api/typesGenerated"
import { FC, useState } from "react" import { FC, useState } from "react"
import { TableCellData, TableCellDataPrimary } from "../TableCellData/TableCellData" import {
TableCellData,
TableCellDataPrimary,
} from "../TableCellData/TableCellData"
import { ResourceAvatar } from "./ResourceAvatar" import { ResourceAvatar } from "./ResourceAvatar"
const Language = { const Language = {
@ -18,7 +21,11 @@ const SensitiveValue: React.FC<{ value: string }> = ({ value }) => {
const styles = useStyles() const styles = useStyles()
const displayValue = shouldDisplay ? value : "••••••••" const displayValue = shouldDisplay ? value : "••••••••"
const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel
const icon = shouldDisplay ? <VisibilityOffOutlined /> : <VisibilityOutlined /> const icon = shouldDisplay ? (
<VisibilityOffOutlined />
) : (
<VisibilityOutlined />
)
return ( return (
<div className={styles.sensitiveValue}> <div className={styles.sensitiveValue}>
@ -43,7 +50,9 @@ export interface ResourceAvatarDataProps {
resource: WorkspaceResource resource: WorkspaceResource
} }
export const ResourceAvatarData: FC<ResourceAvatarDataProps> = ({ resource }) => { export const ResourceAvatarData: FC<ResourceAvatarDataProps> = ({
resource,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (

View File

@ -8,12 +8,19 @@ import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow" import TableRow from "@material-ui/core/TableRow"
import { Skeleton } from "@material-ui/lab" import { Skeleton } from "@material-ui/lab"
import useTheme from "@material-ui/styles/useTheme" import useTheme from "@material-ui/styles/useTheme"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" import {
CloseDropdown,
OpenDropdown,
} from "components/DropdownArrows/DropdownArrows"
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
import { TableCellDataPrimary } from "components/TableCellData/TableCellData" import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
import { FC, useState } from "react" import { FC, useState } from "react"
import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace" import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
import { BuildInfoResponse, Workspace, WorkspaceResource } from "../../api/typesGenerated" import {
BuildInfoResponse,
Workspace,
WorkspaceResource,
} from "../../api/typesGenerated"
import { AppLink } from "../AppLink/AppLink" import { AppLink } from "../AppLink/AppLink"
import { SSHButton } from "../SSHButton/SSHButton" import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
@ -58,7 +65,8 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
const styles = useStyles() const styles = useStyles()
const theme: Theme = useTheme() const theme: Theme = useTheme()
const serverVersion = buildInfo?.version || "" const serverVersion = buildInfo?.version || ""
const [shouldDisplayHideResources, setShouldDisplayHideResources] = useState(false) const [shouldDisplayHideResources, setShouldDisplayHideResources] =
useState(false)
const displayResources = shouldDisplayHideResources const displayResources = shouldDisplayHideResources
? resources ? resources
: resources.filter((resource) => !resource.hide) : resources.filter((resource) => !resource.hide)
@ -95,13 +103,18 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
/* We need to initialize the agents to display the resource */ /* We need to initialize the agents to display the resource */
} }
const agents = resource.agents ?? [null] const agents = resource.agents ?? [null]
const resourceName = <ResourceAvatarData resource={resource} /> const resourceName = (
<ResourceAvatarData resource={resource} />
)
return agents.map((agent, agentIndex) => { return agents.map((agent, agentIndex) => {
{ {
/* If there is no agent, just display the resource name */ /* If there is no agent, just display the resource name */
} }
if (!agent || workspace.latest_build.transition === "stop") { if (
!agent ||
workspace.latest_build.transition === "stop"
) {
return ( return (
<TableRow key={`${resource.id}-${agentIndex}`}> <TableRow key={`${resource.id}-${agentIndex}`}>
<TableCell>{resourceName}</TableCell> <TableCell>{resourceName}</TableCell>
@ -109,27 +122,33 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
</TableRow> </TableRow>
) )
} }
const { displayVersion, outdated } = getDisplayVersionStatus( const { displayVersion, outdated } =
agent.version, getDisplayVersionStatus(agent.version, serverVersion)
serverVersion,
)
const agentStatus = getDisplayAgentStatus(theme, agent) const agentStatus = getDisplayAgentStatus(theme, agent)
return ( return (
<TableRow key={`${resource.id}-${agent.id}`}> <TableRow key={`${resource.id}-${agent.id}`}>
{/* We only want to display the name in the first row because we are using rowSpan */} {/* We only want to display the name in the first row because we are using rowSpan */}
{/* The rowspan should be the same than the number of agents */} {/* The rowspan should be the same than the number of agents */}
{agentIndex === 0 && ( {agentIndex === 0 && (
<TableCell className={styles.resourceNameCell} rowSpan={agents.length}> <TableCell
className={styles.resourceNameCell}
rowSpan={agents.length}
>
{resourceName} {resourceName}
</TableCell> </TableCell>
)} )}
<TableCell className={styles.agentColumn}> <TableCell className={styles.agentColumn}>
<TableCellDataPrimary highlight>{agent.name}</TableCellDataPrimary> <TableCellDataPrimary highlight>
{agent.name}
</TableCellDataPrimary>
<div className={styles.data}> <div className={styles.data}>
<div className={styles.dataRow}> <div className={styles.dataRow}>
<strong>{Language.statusLabel}</strong> <strong>{Language.statusLabel}</strong>
<span style={{ color: agentStatus.color }} className={styles.status}> <span
style={{ color: agentStatus.color }}
className={styles.status}
>
{agentStatus.status} {agentStatus.status}
</span> </span>
</div> </div>
@ -141,7 +160,9 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
</div> </div>
<div className={styles.dataRow}> <div className={styles.dataRow}>
<strong>{Language.versionLabel}</strong> <strong>{Language.versionLabel}</strong>
<span className={styles.agentVersion}>{displayVersion}</span> <span className={styles.agentVersion}>
{displayVersion}
</span>
<AgentOutdatedTooltip outdated={outdated} /> <AgentOutdatedTooltip outdated={outdated} />
</div> </div>
<div className={styles.dataRow}> <div className={styles.dataRow}>
@ -151,49 +172,51 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className={styles.accessLinks}> <div className={styles.accessLinks}>
{canUpdateWorkspace && agent.status === "connected" && ( {canUpdateWorkspace &&
<> agent.status === "connected" && (
{applicationsHost !== undefined && ( <>
<PortForwardButton {applicationsHost !== undefined && (
host={applicationsHost} <PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
<TerminalLink
workspaceName={workspace.name} workspaceName={workspace.name}
agentName={agent.name} agentName={agent.name}
username={workspace.owner_name} userName={workspace.owner_name}
/> />
)} {agent.apps.map((app) => (
{!hideSSHButton && ( <AppLink
<SSHButton key={app.name}
workspaceName={workspace.name} appsHost={applicationsHost}
agentName={agent.name} appIcon={app.icon}
/> appName={app.name}
)} appCommand={app.command}
<TerminalLink appSubdomain={app.subdomain}
workspaceName={workspace.name} username={workspace.owner_name}
agentName={agent.name} workspaceName={workspace.name}
userName={workspace.owner_name} agentName={agent.name}
/> health={app.health}
{agent.apps.map((app) => ( />
<AppLink ))}
key={app.name} </>
appsHost={applicationsHost} )}
appIcon={app.icon} {canUpdateWorkspace &&
appName={app.name} agent.status === "connecting" && (
appCommand={app.command} <>
appSubdomain={app.subdomain} <Skeleton width={80} height={60} />
username={workspace.owner_name} <Skeleton width={120} height={60} />
workspaceName={workspace.name} </>
agentName={agent.name} )}
health={app.health}
/>
))}
</>
)}
{canUpdateWorkspace && agent.status === "connecting" && (
<>
<Skeleton width={80} height={60} />
<Skeleton width={120} height={60} />
</>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -21,7 +21,11 @@ describe("UserRoleSelect", () => {
assignableRole(MockAuditorRole, true), assignableRole(MockAuditorRole, true),
assignableRole(MockUserAdminRole, true), assignableRole(MockUserAdminRole, true),
]} ]}
selectedRoles={[MockUserAdminRole, MockTemplateAdminRole, MockMemberRole]} selectedRoles={[
MockUserAdminRole,
MockTemplateAdminRole,
MockMemberRole,
]}
loading={false} loading={false}
onChange={jest.fn()} onChange={jest.fn()}
open open
@ -30,7 +34,9 @@ describe("UserRoleSelect", () => {
// Then // Then
const owner = await screen.findByText(MockOwnerRole.display_name) const owner = await screen.findByText(MockOwnerRole.display_name)
const templateAdmin = await screen.findByText(MockTemplateAdminRole.display_name) const templateAdmin = await screen.findByText(
MockTemplateAdminRole.display_name,
)
const auditor = await screen.findByText(MockAuditorRole.display_name) const auditor = await screen.findByText(MockAuditorRole.display_name)
const userAdmin = await screen.findByText(MockUserAdminRole.display_name) const userAdmin = await screen.findByText(MockUserAdminRole.display_name)

View File

@ -26,7 +26,9 @@ export const RoleSelect: FC<React.PropsWithChildren<RoleSelectProps>> = ({
const styles = useStyles() const styles = useStyles()
const value = selectedRoles.map((r) => r.name) const value = selectedRoles.map((r) => r.name)
const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ") const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ")
const sortedRoles = roles.sort((a, b) => a.display_name.localeCompare(b.display_name)) const sortedRoles = roles.sort((a, b) =>
a.display_name.localeCompare(b.display_name),
)
return ( return (
<Select <Select
@ -43,11 +45,18 @@ export const RoleSelect: FC<React.PropsWithChildren<RoleSelectProps>> = ({
}} }}
> >
{sortedRoles.map((r) => { {sortedRoles.map((r) => {
const isChecked = selectedRoles.some((selectedRole) => selectedRole.name === r.name) const isChecked = selectedRoles.some(
(selectedRole) => selectedRole.name === r.name,
)
return ( return (
<MenuItem key={r.name} value={r.name} disabled={loading || !r.assignable}> <MenuItem
<Checkbox size="small" color="primary" checked={isChecked} /> {r.display_name} key={r.name}
value={r.name}
disabled={loading || !r.assignable}
>
<Checkbox size="small" color="primary" checked={isChecked} />{" "}
{r.display_name}
</MenuItem> </MenuItem>
) )
})} })}

View File

@ -26,21 +26,29 @@ export const stackTraceUnavailable = {
type ReportMessage = StackTraceAvailableMsg | typeof stackTraceUnavailable type ReportMessage = StackTraceAvailableMsg | typeof stackTraceUnavailable
export const stackTraceAvailable = (stackTrace: string[]): StackTraceAvailableMsg => { export const stackTraceAvailable = (
stackTrace: string[],
): StackTraceAvailableMsg => {
return { return {
type: "stackTraceAvailable", type: "stackTraceAvailable",
stackTrace, stackTrace,
} }
} }
const setStackTrace = (model: ReportState, mappedStack: string[]): ReportState => { const setStackTrace = (
model: ReportState,
mappedStack: string[],
): ReportState => {
return { return {
...model, ...model,
mappedStack, mappedStack,
} }
} }
export const reducer = (model: ReportState, msg: ReportMessage): ReportState => { export const reducer = (
model: ReportState,
msg: ReportMessage,
): ReportState => {
switch (msg.type) { switch (msg.type) {
case "stackTraceAvailable": case "stackTraceAvailable":
return setStackTrace(model, msg.stackTrace) return setStackTrace(model, msg.stackTrace)
@ -49,7 +57,10 @@ export const reducer = (model: ReportState, msg: ReportMessage): ReportState =>
} }
} }
export const createFormattedStackTrace = (error: Error, mappedStack: string[] | null): string[] => { export const createFormattedStackTrace = (
error: Error,
mappedStack: string[] | null,
): string[] => {
return [ return [
"======================= STACK TRACE ========================", "======================= STACK TRACE ========================",
"", "",
@ -63,11 +74,19 @@ export const createFormattedStackTrace = (error: Error, mappedStack: string[] |
/** /**
* A code block component that contains the error stack resulting from an error boundary trigger * A code block component that contains the error stack resulting from an error boundary trigger
*/ */
export const RuntimeErrorReport = ({ error, mappedStack }: ReportState): ReactElement => { export const RuntimeErrorReport = ({
error,
mappedStack,
}: ReportState): ReactElement => {
const styles = useStyles() const styles = useStyles()
if (!mappedStack) { if (!mappedStack) {
return <CodeBlock lines={[Language.reportLoading]} className={styles.codeBlock} /> return (
<CodeBlock
lines={[Language.reportLoading]}
className={styles.codeBlock}
/>
)
} }
const formattedStackTrace = createFormattedStackTrace(error, mappedStack) const formattedStackTrace = createFormattedStackTrace(error, mappedStack)

View File

@ -13,7 +13,9 @@ export default {
}, },
} as ComponentMeta<typeof RuntimeErrorState> } as ComponentMeta<typeof RuntimeErrorState>
const Template: Story<RuntimeErrorStateProps> = (args) => <RuntimeErrorState {...args} /> const Template: Story<RuntimeErrorStateProps> = (args) => (
<RuntimeErrorState {...args} />
)
export const Errored = Template.bind({}) export const Errored = Template.bind({})
Errored.parameters = { Errored.parameters = {

View File

@ -1,7 +1,10 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import { render } from "../../testHelpers/renderHelpers" import { render } from "../../testHelpers/renderHelpers"
import { Language as ButtonLanguage } from "./createCtas" import { Language as ButtonLanguage } from "./createCtas"
import { Language as RuntimeErrorStateLanguage, RuntimeErrorState } from "./RuntimeErrorState" import {
Language as RuntimeErrorStateLanguage,
RuntimeErrorState,
} from "./RuntimeErrorState"
describe("RuntimeErrorState", () => { describe("RuntimeErrorState", () => {
beforeEach(() => { beforeEach(() => {

View File

@ -61,13 +61,20 @@ const ErrorStateDescription = ({ emailBody }: { emailBody?: string }) => {
/** /**
* An error UI that is displayed when our error boundary (ErrorBoundary.tsx) is triggered * An error UI that is displayed when our error boundary (ErrorBoundary.tsx) is triggered
*/ */
export const RuntimeErrorState: React.FC<RuntimeErrorStateProps> = ({ error }) => { export const RuntimeErrorState: React.FC<RuntimeErrorStateProps> = ({
error,
}) => {
const styles = useStyles() const styles = useStyles()
const [reportState, dispatch] = useReducer(reducer, { error, mappedStack: null }) const [reportState, dispatch] = useReducer(reducer, {
error,
mappedStack: null,
})
useEffect(() => { useEffect(() => {
try { try {
mapStackTrace(error.stack, (mappedStack) => dispatch(stackTraceAvailable(mappedStack))) mapStackTrace(error.stack, (mappedStack) =>
dispatch(stackTraceAvailable(mappedStack)),
)
} catch { } catch {
dispatch(stackTraceUnavailable) dispatch(stackTraceUnavailable)
} }
@ -81,13 +88,17 @@ export const RuntimeErrorState: React.FC<RuntimeErrorStateProps> = ({ error }) =
title={<ErrorStateTitle />} title={<ErrorStateTitle />}
description={ description={
<ErrorStateDescription <ErrorStateDescription
emailBody={createFormattedStackTrace(reportState.error, reportState.mappedStack).join( emailBody={createFormattedStackTrace(
"\r\n", reportState.error,
)} reportState.mappedStack,
).join("\r\n")}
/> />
} }
> >
<RuntimeErrorReport error={reportState.error} mappedStack={reportState.mappedStack} /> <RuntimeErrorReport
error={reportState.error}
mappedStack={reportState.mappedStack}
/>
</Section> </Section>
</Margins> </Margins>
</Box> </Box>

View File

@ -1,5 +1,8 @@
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import { MockWorkspace, MockWorkspaceAgent } from "../../testHelpers/renderHelpers" import {
MockWorkspace,
MockWorkspaceAgent,
} from "../../testHelpers/renderHelpers"
import { SSHButton, SSHButtonProps } from "./SSHButton" import { SSHButton, SSHButtonProps } from "./SSHButton"
export default { export default {

View File

@ -5,7 +5,11 @@ import CloudIcon from "@material-ui/icons/CloudOutlined"
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { CodeExample } from "../CodeExample/CodeExample" import { CodeExample } from "../CodeExample/CodeExample"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
import { HelpTooltipLink, HelpTooltipLinksGroup, HelpTooltipText } from "../Tooltips/HelpTooltip" import {
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
} from "../Tooltips/HelpTooltip"
export interface SSHButtonProps { export interface SSHButtonProps {
workspaceName: string workspaceName: string
@ -54,19 +58,25 @@ export const SSHButton: React.FC<React.PropsWithChildren<SSHButtonProps>> = ({
horizontal: "left", horizontal: "left",
}} }}
> >
<HelpTooltipText>Run the following commands to connect with SSH:</HelpTooltipText> <HelpTooltipText>
Run the following commands to connect with SSH:
</HelpTooltipText>
<Stack spacing={0.5} className={styles.codeExamples}> <Stack spacing={0.5} className={styles.codeExamples}>
<div> <div>
<HelpTooltipText> <HelpTooltipText>
<strong className={styles.codeExampleLabel}>Configure SSH hosts on machine:</strong> <strong className={styles.codeExampleLabel}>
Configure SSH hosts on machine:
</strong>
</HelpTooltipText> </HelpTooltipText>
<CodeExample code="coder config-ssh" /> <CodeExample code="coder config-ssh" />
</div> </div>
<div> <div>
<HelpTooltipText> <HelpTooltipText>
<strong className={styles.codeExampleLabel}>Connect to the agent:</strong> <strong className={styles.codeExampleLabel}>
Connect to the agent:
</strong>
</HelpTooltipText> </HelpTooltipText>
<CodeExample code={`ssh coder.${workspaceName}.${agentName}`} /> <CodeExample code={`ssh coder.${workspaceName}.${agentName}`} />
</div> </div>
@ -93,7 +103,9 @@ export const SSHButton: React.FC<React.PropsWithChildren<SSHButtonProps>> = ({
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
popoverPaper: { popoverPaper: {
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(
3,
)}px`,
width: theme.spacing(38), width: theme.spacing(38),
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
marginTop: theme.spacing(0.25), marginTop: theme.spacing(0.25),

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