chore: remove Language objects (#23866)

This commit is contained in:
Kayla はな
2026-03-31 15:26:59 -06:00
committed by GitHub
parent 7f7b13f0ab
commit b9f140e53e
38 changed files with 251 additions and 486 deletions
+1 -8
View File
@@ -1,11 +1,5 @@
import { type AxiosError, type AxiosResponse, isAxiosError } from "axios"; import { type AxiosError, type AxiosResponse, isAxiosError } from "axios";
const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
};
export interface FieldError { export interface FieldError {
field: string; field: string;
detail: string; detail: string;
@@ -64,8 +58,7 @@ export const mapApiErrorToFieldErrors = (
if (apiErrorResponse.validations) { if (apiErrorResponse.validations) {
for (const error of apiErrorResponse.validations) { for (const error of apiErrorResponse.validations) {
result[error.field] = result[error.field] = error.detail || "Invalid value";
error.detail || Language.errorsByCode.defaultErrorCode;
} }
} }
@@ -6,7 +6,7 @@ import {
} from "#/components/DropdownMenu/DropdownMenu"; } from "#/components/DropdownMenu/DropdownMenu";
import { MockUserOwner } from "#/testHelpers/entities"; import { MockUserOwner } from "#/testHelpers/entities";
import { render, waitForLoaderToBeRemoved } from "#/testHelpers/renderHelpers"; import { render, waitForLoaderToBeRemoved } from "#/testHelpers/renderHelpers";
import { Language, UserDropdownContent } from "./UserDropdownContent"; import { UserDropdownContent } from "./UserDropdownContent";
const renderUserDropdownContent = (props: { onSignOut: () => void }) => { const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
return render( return render(
@@ -28,7 +28,7 @@ describe("UserDropdownContent", () => {
renderUserDropdownContent({ onSignOut: vi.fn() }); renderUserDropdownContent({ onSignOut: vi.fn() });
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
const link = screen.getByText(Language.accountLabel).closest("a"); const link = screen.getByText("Account").closest("a");
if (!link) { if (!link) {
throw new Error("Anchor tag not found for the account menu item"); throw new Error("Anchor tag not found for the account menu item");
} }
@@ -40,7 +40,7 @@ describe("UserDropdownContent", () => {
const onSignOut = vi.fn(); const onSignOut = vi.fn();
renderUserDropdownContent({ onSignOut }); renderUserDropdownContent({ onSignOut });
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click(); screen.getByText("Sign Out").click();
expect(onSignOut).toBeCalledTimes(1); expect(onSignOut).toBeCalledTimes(1);
}); });
}); });
@@ -21,12 +21,6 @@ import {
import { useClipboard } from "#/hooks/useClipboard"; import { useClipboard } from "#/hooks/useClipboard";
import { SupportIcon } from "../SupportIcon"; import { SupportIcon } from "../SupportIcon";
export const Language = {
accountLabel: "Account",
signOutLabel: "Sign Out",
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
};
interface UserDropdownContentProps { interface UserDropdownContentProps {
user: TypesGen.User; user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse; buildInfo?: TypesGen.BuildInfoResponse;
@@ -126,7 +120,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
</Tooltip> </Tooltip>
)} )}
<DropdownMenuItem className="text-xs" disabled> <DropdownMenuItem className="text-xs" disabled>
<span>{Language.copyrightText}</span> <span>&copy; {new Date().getFullYear()} Coder Technologies, Inc.</span>
</DropdownMenuItem> </DropdownMenuItem>
</> </>
); );
@@ -8,11 +8,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "#/components/Tooltip/Tooltip"; } from "#/components/Tooltip/Tooltip";
const Language = {
showLabel: "Show value",
hideLabel: "Hide value",
};
interface SensitiveValueProps { interface SensitiveValueProps {
value: string; value: string;
} }
@@ -20,7 +15,7 @@ interface SensitiveValueProps {
export const SensitiveValue: FC<SensitiveValueProps> = ({ value }) => { export const SensitiveValue: FC<SensitiveValueProps> = ({ value }) => {
const [shouldDisplay, setShouldDisplay] = useState(false); const [shouldDisplay, setShouldDisplay] = useState(false);
const displayValue = shouldDisplay ? value : "••••••••"; const displayValue = shouldDisplay ? value : "••••••••";
const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel; const buttonLabel = shouldDisplay ? "Hide value" : "Show value";
const icon = shouldDisplay ? ( const icon = shouldDisplay ? (
<EyeOffIcon className="size-icon-xs" /> <EyeOffIcon className="size-icon-xs" />
) : ( ) : (
@@ -13,10 +13,6 @@ import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "#/components/Logs/Logs";
import { BODY_FONT_FAMILY } from "#/theme/constants"; import { BODY_FONT_FAMILY } from "#/theme/constants";
import { cn } from "#/utils/cn"; import { cn } from "#/utils/cn";
const Language = {
seconds: "seconds",
};
type Stage = ProvisionerJobLog["stage"]; type Stage = ProvisionerJobLog["stage"];
type LogsGroupedByStage = Record<Stage, ProvisionerJobLog[]>; type LogsGroupedByStage = Record<Stage, ProvisionerJobLog[]>;
type GroupLogsByStageFn = (logs: ProvisionerJobLog[]) => LogsGroupedByStage; type GroupLogsByStageFn = (logs: ProvisionerJobLog[]) => LogsGroupedByStage;
@@ -98,9 +94,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
> >
<div>{stage}</div> <div>{stage}</div>
{shouldDisplayDuration && ( {shouldDisplayDuration && (
<div css={styles.duration}> <div css={styles.duration}>{duration} seconds</div>
{duration} {Language.seconds}
</div>
)} )}
</div> </div>
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />} {!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
@@ -10,23 +10,20 @@ import {
} from "#/components/HelpPopover/HelpPopover"; } from "#/components/HelpPopover/HelpPopover";
import { docs } from "#/utils/docs"; import { docs } from "#/utils/docs";
const Language = {
title: "What is an audit log?",
body: "An audit log is a record of events and changes made throughout a system.",
docs: "Events we track",
};
export const AuditHelpPopover: FC = () => { export const AuditHelpPopover: FC = () => {
return ( return (
<HelpPopover> <HelpPopover>
<HelpPopoverIconTrigger /> <HelpPopoverIconTrigger />
<HelpPopoverContent> <HelpPopoverContent>
<HelpPopoverTitle>{Language.title}</HelpPopoverTitle> <HelpPopoverTitle>What is an audit log?</HelpPopoverTitle>
<HelpPopoverText>{Language.body}</HelpPopoverText> <HelpPopoverText>
An audit log is a record of events and changes made throughout a
system.
</HelpPopoverText>
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/admin/security/audit-logs")}> <HelpPopoverLink href={docs("/admin/security/audit-logs")}>
{Language.docs} Events we track
</HelpPopoverLink> </HelpPopoverLink>
</HelpPopoverLinksGroup> </HelpPopoverLinksGroup>
</HelpPopoverContent> </HelpPopoverContent>
+2 -7
View File
@@ -27,11 +27,6 @@ import { AuditFilter } from "./AuditFilter";
import { AuditHelpPopover } from "./AuditHelpPopover"; import { AuditHelpPopover } from "./AuditHelpPopover";
import { AuditLogRow } from "./AuditLogRow/AuditLogRow"; import { AuditLogRow } from "./AuditLogRow/AuditLogRow";
const Language = {
title: "Audit",
subtitle: "View events in your audit log.",
};
interface AuditPageViewProps { interface AuditPageViewProps {
auditLogs?: readonly AuditLog[]; auditLogs?: readonly AuditLog[];
isNonInitialPage: boolean; isNonInitialPage: boolean;
@@ -62,11 +57,11 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<PageHeader> <PageHeader>
<PageHeaderTitle> <PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.title}</span> <span>Audit</span>
<AuditHelpPopover /> <AuditHelpPopover />
</Stack> </Stack>
</PageHeaderTitle> </PageHeaderTitle>
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle> <PageHeaderSubtitle>View events in your audit log.</PageHeaderSubtitle>
</PageHeader> </PageHeader>
<ChooseOne> <ChooseOne>
@@ -10,23 +10,21 @@ import {
} from "#/components/HelpPopover/HelpPopover"; } from "#/components/HelpPopover/HelpPopover";
import { docs } from "#/utils/docs"; import { docs } from "#/utils/docs";
const Language = {
title: "Why are some events missing?",
body: "The connection log is a best-effort log of workspace access. Some events are reported by workspace agents, and receipt of these events by the server is not guaranteed.",
docs: "Connection log documentation",
};
export const ConnectionLogHelpPopover: FC = () => { export const ConnectionLogHelpPopover: FC = () => {
return ( return (
<HelpPopover> <HelpPopover>
<HelpPopoverIconTrigger /> <HelpPopoverIconTrigger />
<HelpPopoverContent> <HelpPopoverContent>
<HelpPopoverTitle>{Language.title}</HelpPopoverTitle> <HelpPopoverTitle>Why are some events missing?</HelpPopoverTitle>
<HelpPopoverText>{Language.body}</HelpPopoverText> <HelpPopoverText>
The connection log is a best-effort log of workspace access. Some
events are reported by workspace agents, and receipt of these events
by the server is not guaranteed.
</HelpPopoverText>
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/admin/monitoring/connection-logs")}> <HelpPopoverLink href={docs("/admin/monitoring/connection-logs")}>
{Language.docs} Connection log documentation
</HelpPopoverLink> </HelpPopoverLink>
</HelpPopoverLinksGroup> </HelpPopoverLinksGroup>
</HelpPopoverContent> </HelpPopoverContent>
@@ -27,11 +27,6 @@ import { ConnectionLogFilter } from "./ConnectionLogFilter";
import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover"; import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover";
import { ConnectionLogRow } from "./ConnectionLogRow/ConnectionLogRow"; import { ConnectionLogRow } from "./ConnectionLogRow/ConnectionLogRow";
const Language = {
title: "Connection Log",
subtitle: "View workspace connection events.",
};
interface ConnectionLogPageViewProps { interface ConnectionLogPageViewProps {
connectionLogs?: readonly ConnectionLog[]; connectionLogs?: readonly ConnectionLog[];
isNonInitialPage: boolean; isNonInitialPage: boolean;
@@ -61,11 +56,13 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
<PageHeader> <PageHeader>
<PageHeaderTitle> <PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.title}</span> <span>Connection Log</span>
<ConnectionLogHelpPopover /> <ConnectionLogHelpPopover />
</Stack> </Stack>
</PageHeaderTitle> </PageHeaderTitle>
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle> <PageHeaderSubtitle>
View workspace connection events.
</PageHeaderSubtitle>
</PageHeader> </PageHeader>
<ChooseOne> <ChooseOne>
-9
View File
@@ -1,9 +0,0 @@
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordSignIn: "Sign In",
githubSignIn: "GitHub",
oidcSignIn: "OpenID Connect",
};
+15 -14
View File
@@ -9,7 +9,6 @@ import {
waitForLoaderToBeRemoved, waitForLoaderToBeRemoved,
} from "#/testHelpers/renderHelpers"; } from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server"; import { server } from "#/testHelpers/server";
import { Language } from "./Language";
import LoginPage from "./LoginPage"; import LoginPage from "./LoginPage";
describe("LoginPage", () => { describe("LoginPage", () => {
@@ -35,12 +34,12 @@ describe("LoginPage", () => {
// When // When
render(<LoginPage />); render(<LoginPage />);
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
const email = screen.getByLabelText(new RegExp(Language.emailLabel)); const email = screen.getByLabelText(/Email/);
const password = screen.getByLabelText(new RegExp(Language.passwordLabel)); const password = screen.getByLabelText(/Password/);
await userEvent.type(email, "test@coder.com"); await userEvent.type(email, "test@coder.com");
await userEvent.type(password, "password"); await userEvent.type(password, "password");
// Click sign-in // Click sign-in
const signInButton = await screen.findByText(Language.passwordSignIn); const signInButton = await screen.findByText("Sign In");
fireEvent.click(signInButton); fireEvent.click(signInButton);
// Then // Then
@@ -53,10 +52,8 @@ describe("LoginPage", () => {
render(<LoginPage />); render(<LoginPage />);
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
const emailInput = screen.getByLabelText(new RegExp(Language.emailLabel)); const emailInput = screen.getByLabelText(/Email/);
const passwordInput = screen.getByLabelText( const passwordInput = screen.getByLabelText(/Password/);
new RegExp(Language.passwordLabel),
);
expect(emailInput).not.toHaveAttribute("aria-invalid", "true"); expect(emailInput).not.toHaveAttribute("aria-invalid", "true");
expect(emailInput).not.toHaveAttribute( expect(emailInput).not.toHaveAttribute(
"aria-describedby", "aria-describedby",
@@ -68,11 +65,13 @@ describe("LoginPage", () => {
"signin-password-error", "signin-password-error",
); );
const signInButton = await screen.findByText(Language.passwordSignIn); const signInButton = await screen.findByText("Sign In");
fireEvent.click(signInButton); fireEvent.click(signInButton);
// Then // Then
const emailError = await screen.findByText(Language.emailRequired); const emailError = await screen.findByText(
"Please enter an email address.",
);
expect(emailInput).toHaveAttribute("aria-invalid", "true"); expect(emailInput).toHaveAttribute("aria-invalid", "true");
expect(emailInput).toHaveAttribute( expect(emailInput).toHaveAttribute(
"aria-describedby", "aria-describedby",
@@ -81,7 +80,9 @@ describe("LoginPage", () => {
const emailErrorElement = document.getElementById("signin-email-error"); const emailErrorElement = document.getElementById("signin-email-error");
expect(emailErrorElement).toBe(emailError); expect(emailErrorElement).toBe(emailError);
expect(emailErrorElement).toHaveTextContent(Language.emailRequired); expect(emailErrorElement).toHaveTextContent(
"Please enter an email address.",
);
expect(passwordInput).not.toHaveAttribute("aria-invalid", "true"); expect(passwordInput).not.toHaveAttribute("aria-invalid", "true");
expect(passwordInput).not.toHaveAttribute( expect(passwordInput).not.toHaveAttribute(
@@ -240,13 +241,13 @@ describe("LoginPage", () => {
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
const email = screen.getByLabelText(new RegExp(Language.emailLabel)); const email = screen.getByLabelText(/Email/);
const password = screen.getByLabelText(new RegExp(Language.passwordLabel)); const password = screen.getByLabelText(/Password/);
await userEvent.type(email, "test@coder.com"); await userEvent.type(email, "test@coder.com");
await userEvent.type(password, "password"); await userEvent.type(password, "password");
const signInButton = await screen.findByText(Language.passwordSignIn); const signInButton = await screen.findByText("Sign In");
fireEvent.click(signInButton); fireEvent.click(signInButton);
// Then - it should hard redirect to OAuth endpoint // Then - it should hard redirect to OAuth endpoint
+2 -3
View File
@@ -3,7 +3,6 @@ import { type FC, useId } from "react";
import type { AuthMethods } from "#/api/typesGenerated"; import type { AuthMethods } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button"; import { Button } from "#/components/Button/Button";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { Language } from "./Language";
type OAuthSignInFormProps = { type OAuthSignInFormProps = {
isSigningIn: boolean; isSigningIn: boolean;
@@ -33,7 +32,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
)}`} )}`}
> >
<ExternalImage src="/icon/github.svg" /> <ExternalImage src="/icon/github.svg" />
{Language.githubSignIn} GitHub
</a> </a>
</Button> </Button>
)} )}
@@ -57,7 +56,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
) : ( ) : (
<KeyIcon /> <KeyIcon />
)} )}
{authMethods.oidc.signInText || Language.oidcSignIn} {authMethods.oidc.signInText || "OpenID Connect"}
</a> </a>
</Button> </Button>
)} )}
@@ -8,7 +8,6 @@ import { Label } from "#/components/Label/Label";
import { Link } from "#/components/Link/Link"; import { Link } from "#/components/Link/Link";
import { Spinner } from "#/components/Spinner/Spinner"; import { Spinner } from "#/components/Spinner/Spinner";
import { getFormHelpers, onChangeTrimmed } from "#/utils/formUtils"; import { getFormHelpers, onChangeTrimmed } from "#/utils/formUtils";
import { Language } from "./Language";
type PasswordSignInFormProps = { type PasswordSignInFormProps = {
onSubmit: (credentials: { email: string; password: string }) => void; onSubmit: (credentials: { email: string; password: string }) => void;
@@ -24,8 +23,8 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
const validationSchema = Yup.object({ const validationSchema = Yup.object({
email: Yup.string() email: Yup.string()
.trim() .trim()
.email(Language.emailInvalid) .email("Please enter a valid email address.")
.required(Language.emailRequired), .required("Please enter an email address."),
password: Yup.string(), password: Yup.string(),
}); });
@@ -48,7 +47,7 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
<form onSubmit={form.handleSubmit} className="flex flex-col gap-5"> <form onSubmit={form.handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<Label htmlFor={emailField.id}> <Label htmlFor={emailField.id}>
{Language.emailLabel}{" "} Email{" "}
<span className="text-xs text-content-destructive font-bold">*</span> <span className="text-xs text-content-destructive font-bold">*</span>
</Label> </Label>
<Input <Input
@@ -75,7 +74,7 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<Label htmlFor={passwordField.id}> <Label htmlFor={passwordField.id}>
{Language.passwordLabel}{" "} Password{" "}
<span className="text-xs text-content-destructive font-bold">*</span> <span className="text-xs text-content-destructive font-bold">*</span>
</Label> </Label>
<Input <Input
@@ -101,7 +100,7 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
<Button size="lg" disabled={isSigningIn} className="w-full" type="submit"> <Button size="lg" disabled={isSigningIn} className="w-full" type="submit">
<Spinner loading={isSigningIn} /> <Spinner loading={isSigningIn} />
{Language.passwordSignIn} Sign In
</Button> </Button>
<Link <Link
@@ -18,7 +18,7 @@ type TooltipData = {
links: readonly { text: string; href: string }[]; links: readonly { text: string; href: string }[];
}; };
const Language = { const tooltipData: Record<ColumnHeader, TooltipData> = {
roles: { roles: {
title: "What is a role?", title: "What is a role?",
text: text:
@@ -26,7 +26,6 @@ const Language = {
"View our docs on how to use the available roles.", "View our docs on how to use the available roles.",
links: [{ text: "User Roles", href: docs("/admin/users/groups-roles") }], links: [{ text: "User Roles", href: docs("/admin/users/groups-roles") }],
}, },
groups: { groups: {
title: "What is a group?", title: "What is a group?",
text: text:
@@ -34,7 +33,6 @@ const Language = {
"to specific templates. View our docs on how to use groups.", "to specific templates. View our docs on how to use groups.",
links: [{ text: "User Groups", href: docs("/admin/users/groups-roles") }], links: [{ text: "User Groups", href: docs("/admin/users/groups-roles") }],
}, },
ai_addon: { ai_addon: {
title: "What is the AI add-on?", title: "What is the AI add-on?",
text: text:
@@ -42,24 +40,24 @@ const Language = {
"who are actively consuming a seat.", "who are actively consuming a seat.",
links: [], links: [],
}, },
} as const satisfies Record<ColumnHeader, TooltipData>; };
type Props = { type Props = {
variant: ColumnHeader; variant: ColumnHeader;
}; };
export const TableColumnHelpPopover: FC<Props> = ({ variant }) => { export const TableColumnHelpPopover: FC<Props> = ({ variant }) => {
const variantLang = Language[variant]; const data = tooltipData[variant];
return ( return (
<HelpPopover> <HelpPopover>
<HelpPopoverIconTrigger size="small" /> <HelpPopoverIconTrigger size="small" />
<HelpPopoverContent> <HelpPopoverContent>
<HelpPopoverTitle>{variantLang.title}</HelpPopoverTitle> <HelpPopoverTitle>{data.title}</HelpPopoverTitle>
<HelpPopoverText>{variantLang.text}</HelpPopoverText> <HelpPopoverText>{data.text}</HelpPopoverText>
{variantLang.links.length > 0 && ( {data.links.length > 0 && (
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
{variantLang.links.map((link) => ( {data.links.map((link) => (
<HelpPopoverLink key={link.text} href={link.href}> <HelpPopoverLink key={link.text} href={link.href}>
{link.text} {link.text}
</HelpPopoverLink> </HelpPopoverLink>
+3 -4
View File
@@ -10,7 +10,6 @@ import {
} from "#/testHelpers/renderHelpers"; } from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server"; import { server } from "#/testHelpers/server";
import { SetupPage } from "./SetupPage"; import { SetupPage } from "./SetupPage";
import { Language as PageViewLanguage } from "./SetupPageView";
const fillForm = async ({ const fillForm = async ({
email = "someone@coder.com", email = "someone@coder.com",
@@ -20,12 +19,12 @@ const fillForm = async ({
email?: string; email?: string;
password?: string; password?: string;
} = {}) => { } = {}) => {
const emailField = screen.getByLabelText(PageViewLanguage.emailLabel); const emailField = screen.getByLabelText("Email");
const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel); const passwordField = screen.getByLabelText("Password");
await userEvent.type(emailField, email); await userEvent.type(emailField, email);
await userEvent.type(passwordField, password); await userEvent.type(passwordField, password);
const submitButton = screen.getByRole("button", { const submitButton = screen.getByRole("button", {
name: PageViewLanguage.create, name: "Continue with email",
}); });
await userEvent.click(submitButton); await userEvent.click(submitButton);
}; };
+24 -48
View File
@@ -24,33 +24,7 @@ import {
onChangeTrimmed, onChangeTrimmed,
} from "#/utils/formUtils"; } from "#/utils/formUtils";
export const Language = { const usernameValidator = nameValidator("Username");
emailLabel: "Email",
passwordLabel: "Password",
nameLabel: "Full Name",
usernameLabel: "Username",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
create: "Continue with email",
githubCreate: "Continue with GitHub",
welcomeMessage: <>Welcome to Coder</>,
firstNameLabel: "First name",
lastNameLabel: "Last name",
companyLabel: "Company",
jobTitleLabel: "Job title",
phoneNumberLabel: "Phone number",
countryLabel: "Country",
developersLabel: "Number of developers",
firstNameRequired: "Please enter your first name.",
phoneNumberRequired: "Please enter your phone number.",
jobTitleRequired: "Please enter your job title.",
companyNameRequired: "Please enter your company name.",
countryRequired: "Please select your country.",
developersRequired: "Please select the number of developers in your company.",
};
const usernameValidator = nameValidator(Language.usernameLabel);
const usernameFromEmail = (email: string): string => { const usernameFromEmail = (email: string): string => {
try { try {
const emailPrefix = email.split("@")[0]; const emailPrefix = email.split("@")[0];
@@ -69,22 +43,24 @@ const usernameFromEmail = (email: string): string => {
const validationSchema = Yup.object({ const validationSchema = Yup.object({
email: Yup.string() email: Yup.string()
.trim() .trim()
.email(Language.emailInvalid) .email("Please enter a valid email address.")
.required(Language.emailRequired), .required("Please enter an email address."),
password: Yup.string().required(Language.passwordRequired), password: Yup.string().required("Please enter a password."),
username: usernameValidator, username: usernameValidator,
trial: Yup.bool(), trial: Yup.bool(),
trial_info: Yup.object().when("trial", { trial_info: Yup.object().when("trial", {
is: true, is: true,
then: (schema) => then: (schema) =>
schema.shape({ schema.shape({
first_name: Yup.string().required(Language.firstNameRequired), first_name: Yup.string().required("Please enter your first name."),
last_name: Yup.string().required(Language.firstNameRequired), last_name: Yup.string().required("Please enter your last name."),
phone_number: Yup.string().required(Language.phoneNumberRequired), phone_number: Yup.string().required("Please enter your phone number."),
job_title: Yup.string().required(Language.jobTitleRequired), job_title: Yup.string().required("Please enter your job title."),
company_name: Yup.string().required(Language.companyNameRequired), company_name: Yup.string().required("Please enter your company name."),
country: Yup.string().required(Language.countryRequired), country: Yup.string().required("Please select your country."),
developers: Yup.string().required(Language.developersRequired), developers: Yup.string().required(
"Please select the number of developers in your company.",
),
}), }),
}), }),
}); });
@@ -163,7 +139,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
<Button className="w-full" asChild type="submit" size="lg"> <Button className="w-full" asChild type="submit" size="lg">
<a href="/api/v2/users/oauth2/github/callback"> <a href="/api/v2/users/oauth2/github/callback">
<ExternalImage src="/icon/github.svg" /> <ExternalImage src="/icon/github.svg" />
{Language.githubCreate} Continue with GitHub
</a> </a>
</Button> </Button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -185,13 +161,13 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
}} }}
autoComplete="email" autoComplete="email"
fullWidth fullWidth
label={Language.emailLabel} label="Email"
/> />
<PasswordField <PasswordField
{...getFieldHelpers("password")} {...getFieldHelpers("password")}
autoComplete="current-password" autoComplete="current-password"
fullWidth fullWidth
label={Language.passwordLabel} label="Password"
/> />
<label <label
htmlFor="trial" htmlFor="trial"
@@ -239,14 +215,14 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
id="trial_info.first_name" id="trial_info.first_name"
name="trial_info.first_name" name="trial_info.first_name"
fullWidth fullWidth
label={Language.firstNameLabel} label="First name"
/> />
<TextField <TextField
{...getFieldHelpers("trial_info.last_name")} {...getFieldHelpers("trial_info.last_name")}
id="trial_info.last_name" id="trial_info.last_name"
name="trial_info.last_name" name="trial_info.last_name"
fullWidth fullWidth
label={Language.lastNameLabel} label="Last name"
/> />
</Stack> </Stack>
<TextField <TextField
@@ -254,21 +230,21 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
id="trial_info.company_name" id="trial_info.company_name"
name="trial_info.company_name" name="trial_info.company_name"
fullWidth fullWidth
label={Language.companyLabel} label="Company"
/> />
<TextField <TextField
{...getFieldHelpers("trial_info.job_title")} {...getFieldHelpers("trial_info.job_title")}
id="trial_info.job_title" id="trial_info.job_title"
name="trial_info.job_title" name="trial_info.job_title"
fullWidth fullWidth
label={Language.jobTitleLabel} label="Job title"
/> />
<TextField <TextField
{...getFieldHelpers("trial_info.phone_number")} {...getFieldHelpers("trial_info.phone_number")}
id="trial_info.phone_number" id="trial_info.phone_number"
name="trial_info.phone_number" name="trial_info.phone_number"
fullWidth fullWidth
label={Language.phoneNumberLabel} label="Phone number"
/> />
<Autocomplete <Autocomplete
autoHighlight autoHighlight
@@ -287,7 +263,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
{...getFieldHelpers("trial_info.country")} {...getFieldHelpers("trial_info.country")}
id="trial_info.country" id="trial_info.country"
name="trial_info.country" name="trial_info.country"
label={Language.countryLabel} label="Country"
fullWidth fullWidth
inputProps={{ inputProps={{
...params.inputProps, ...params.inputProps,
@@ -301,7 +277,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
id="trial_info.developers" id="trial_info.developers"
name="trial_info.developers" name="trial_info.developers"
fullWidth fullWidth
label={Language.developersLabel} label="Number of developers"
select select
> >
{numberOfDevelopersOptions.map((opt) => ( {numberOfDevelopersOptions.map((opt) => (
@@ -356,7 +332,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
size="lg" size="lg"
> >
<Spinner loading={isLoading} /> <Spinner loading={isLoading} />
{Language.create} Continue with email
</Button> </Button>
</FormFields> </FormFields>
</VerticalForm> </VerticalForm>
+6 -21
View File
@@ -8,16 +8,6 @@ import {
formatTemplateBuildTime, formatTemplateBuildTime,
} from "#/utils/templates"; } from "#/utils/templates";
const Language = {
usedByLabel: "Used by",
buildTimeLabel: "Build time",
activeVersionLabel: "Active version",
lastUpdateLabel: "Last updated",
developerPlural: "developers",
developerSingular: "developer",
createdByLabel: "Created by",
};
interface TemplateStatsProps { interface TemplateStatsProps {
template: Template; template: Template;
activeVersion: TemplateVersion; activeVersion: TemplateVersion;
@@ -30,22 +20,20 @@ export const TemplateStats: FC<TemplateStatsProps> = ({
return ( return (
<Stats> <Stats>
<StatsItem <StatsItem
label={Language.usedByLabel} label="Used by"
value={ value={
<> <>
{formatTemplateActiveDevelopers(template.active_user_count)}{" "} {formatTemplateActiveDevelopers(template.active_user_count)}{" "}
{template.active_user_count === 1 {template.active_user_count === 1 ? "developer" : "developers"}
? Language.developerSingular
: Language.developerPlural}
</> </>
} }
/> />
<StatsItem <StatsItem
label={Language.buildTimeLabel} label="Build time"
value={formatTemplateBuildTime(template.build_time_stats.start.P50)} value={formatTemplateBuildTime(template.build_time_stats.start.P50)}
/> />
<StatsItem <StatsItem
label={Language.activeVersionLabel} label="Active version"
value={ value={
<Link to={`versions/${activeVersion.name}`}> <Link to={`versions/${activeVersion.name}`}>
{activeVersion.name} {activeVersion.name}
@@ -53,13 +41,10 @@ export const TemplateStats: FC<TemplateStatsProps> = ({
} }
/> />
<StatsItem <StatsItem
label={Language.lastUpdateLabel} label="Last updated"
value={createDayString(template.updated_at)} value={createDayString(template.updated_at)}
/> />
<StatsItem <StatsItem label="Created by" value={template.created_by_name} />
label={Language.createdByLabel}
value={template.created_by_name}
/>
</Stats> </Stats>
); );
}; };
@@ -11,13 +11,6 @@ import { TableLoader } from "#/components/TableLoader/TableLoader";
import { Timeline } from "#/components/Timeline/Timeline"; import { Timeline } from "#/components/Timeline/Timeline";
import { VersionRow } from "./VersionRow"; import { VersionRow } from "./VersionRow";
const Language = {
emptyMessage: "No versions found",
nameLabel: "Version name",
createdAtLabel: "Created at",
createdByLabel: "Created by",
};
interface VersionsTableProps { interface VersionsTableProps {
activeVersionId: string; activeVersionId: string;
versions?: TypesGen.TemplateVersion[]; versions?: TypesGen.TemplateVersion[];
@@ -75,7 +68,7 @@ export const VersionsTable: FC<VersionsTableProps> = ({
<TableRow> <TableRow>
<TableCell colSpan={999}> <TableCell colSpan={999}>
<div className="p-8"> <div className="p-8">
<EmptyState message={Language.emptyMessage} /> <EmptyState message="No versions found" />
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -4,6 +4,7 @@ import TextField from "@mui/material/TextField";
import { useFormik } from "formik"; import { useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
import * as Yup from "yup"; import * as Yup from "yup";
import { EnterpriseBadge } from "#/components/Badges/Badges";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import type { DialogProps } from "#/components/Dialogs/Dialog"; import type { DialogProps } from "#/components/Dialogs/Dialog";
import { FormFields } from "#/components/Form/Form"; import { FormFields } from "#/components/Form/Form";
@@ -18,18 +19,8 @@ import {
} from "#/components/HelpPopover/HelpPopover"; } from "#/components/HelpPopover/HelpPopover";
import { Stack } from "#/components/Stack/Stack"; import { Stack } from "#/components/Stack/Stack";
import type { PublishVersionData } from "#/pages/TemplateVersionEditorPage/types"; import type { PublishVersionData } from "#/pages/TemplateVersionEditorPage/types";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils"; import { getFormHelpers } from "#/utils/formUtils";
import { docs } from "../../utils/docs";
export const Language = {
versionNameLabel: "Version name",
messagePlaceholder: "Write a short message about the changes you made...",
defaultCheckboxLabel: "Promote to active version",
activeVersionHelpTitle: "Active versions",
activeVersionHelpText:
"Templates can enforce that the active version be used for all workspaces (enterprise-only)",
activeVersionHelpBody: "Review the documentation",
};
type PublishTemplateVersionDialogProps = DialogProps & { type PublishTemplateVersionDialogProps = DialogProps & {
defaultName: string; defaultName: string;
@@ -88,7 +79,7 @@ export const PublishTemplateVersionDialog: FC<
<FormFields> <FormFields>
<TextField <TextField
{...getFieldHelpers("name")} {...getFieldHelpers("name")}
label={Language.versionNameLabel} label="Version name"
autoFocus autoFocus
disabled={isPublishing} disabled={isPublishing}
/> />
@@ -96,7 +87,7 @@ export const PublishTemplateVersionDialog: FC<
<TextField <TextField
{...getFieldHelpers("message")} {...getFieldHelpers("message")}
label="Message" label="Message"
placeholder={Language.messagePlaceholder} placeholder="Write a short message about the changes you made..."
disabled={isPublishing} disabled={isPublishing}
multiline multiline
rows={5} rows={5}
@@ -104,7 +95,7 @@ export const PublishTemplateVersionDialog: FC<
<Stack direction="row"> <Stack direction="row">
<FormControlLabel <FormControlLabel
label={Language.defaultCheckboxLabel} label="Promote to active version"
control={ control={
<Checkbox <Checkbox
size="small" size="small"
@@ -128,11 +119,10 @@ export const PublishTemplateVersionDialog: FC<
* this prop may not need to be set when we switch away from MuiDialog * this prop may not need to be set when we switch away from MuiDialog
*/} */}
<HelpPopoverContent disablePortal> <HelpPopoverContent disablePortal>
<HelpPopoverTitle> <HelpPopoverTitle>Active versions</HelpPopoverTitle>
{Language.activeVersionHelpTitle}
</HelpPopoverTitle>
<HelpPopoverText> <HelpPopoverText>
{Language.activeVersionHelpText} Templates can enforce that the active version be used for
all workspaces <EnterpriseBadge />
</HelpPopoverText> </HelpPopoverText>
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
<HelpPopoverLink <HelpPopoverLink
@@ -140,7 +130,7 @@ export const PublishTemplateVersionDialog: FC<
"/admin/templates/managing-templates#template-update-policies", "/admin/templates/managing-templates#template-update-policies",
)} )}
> >
{Language.activeVersionHelpBody} Review the documentation
</HelpPopoverLink> </HelpPopoverLink>
</HelpPopoverLinksGroup> </HelpPopoverLinksGroup>
</HelpPopoverContent> </HelpPopoverContent>
@@ -24,7 +24,6 @@ import {
import { server } from "#/testHelpers/server"; import { server } from "#/testHelpers/server";
import type { FileTree } from "#/utils/filetree"; import type { FileTree } from "#/utils/filetree";
import type { MonacoEditorProps } from "./MonacoEditor"; import type { MonacoEditorProps } from "./MonacoEditor";
import { Language } from "./PublishTemplateVersionDialog";
import TemplateVersionEditorPage, { import TemplateVersionEditorPage, {
findEntrypointFile, findEntrypointFile,
getActivePath, getActivePath,
@@ -188,7 +187,7 @@ test("Do not mark as active if promote is not checked", async () => {
await user.clear(nameField); await user.clear(nameField);
await user.type(nameField, "v1.0"); await user.type(nameField, "v1.0");
await user.click( await user.click(
within(publishDialog).getByLabelText(Language.defaultCheckboxLabel), within(publishDialog).getByLabelText("Promote to active version"),
); );
await user.click( await user.click(
within(publishDialog).getByRole("button", { name: "Publish" }), within(publishDialog).getByRole("button", { name: "Publish" }),
@@ -52,32 +52,19 @@ import { EmptyTemplates } from "./EmptyTemplates";
import { TemplatesFilter } from "./TemplatesFilter"; import { TemplatesFilter } from "./TemplatesFilter";
import type { TemplateFilterState } from "./TemplatesPage"; import type { TemplateFilterState } from "./TemplatesPage";
const Language = {
developerCount: (activeCount: number): string => {
return `${formatTemplateActiveDevelopers(activeCount)} developer${
activeCount !== 1 ? "s" : ""
}`;
},
nameLabel: "Name",
buildTimeLabel: "Build time",
usedByLabel: "Used by",
lastUpdatedLabel: "Last updated",
templateTooltipTitle: "What is template?",
templateTooltipText:
"With templates you can create a common configuration for your workspaces using Terraform.",
templateTooltipLink: "Manage templates",
};
const TemplateHelpPopover: FC = () => { const TemplateHelpPopover: FC = () => {
return ( return (
<HelpPopover> <HelpPopover>
<HelpPopoverIconTrigger /> <HelpPopoverIconTrigger />
<HelpPopoverContent> <HelpPopoverContent>
<HelpPopoverTitle>{Language.templateTooltipTitle}</HelpPopoverTitle> <HelpPopoverTitle>What is a template?</HelpPopoverTitle>
<HelpPopoverText>{Language.templateTooltipText}</HelpPopoverText> <HelpPopoverText>
With templates you can create a common configuration for your
workspaces using Terraform.
</HelpPopoverText>
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/admin/templates")}> <HelpPopoverLink href={docs("/admin/templates")}>
{Language.templateTooltipLink} Manage templates
</HelpPopoverLink> </HelpPopoverLink>
</HelpPopoverLinksGroup> </HelpPopoverLinksGroup>
</HelpPopoverContent> </HelpPopoverContent>
@@ -145,6 +132,8 @@ const TemplateRow: FC<TemplateRowProps> = ({
); );
const navigate = useNavigate(); const navigate = useNavigate();
const developerCount = `${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count !== 1 ? "s" : ""}`;
const clickableRow = useClickableTableRow({ const clickableRow = useClickableTableRow({
onClick: () => navigate(templatePageLink), onClick: () => navigate(templatePageLink),
}); });
@@ -175,11 +164,11 @@ const TemplateRow: FC<TemplateRowProps> = ({
{showOrganizations ? ( {showOrganizations ? (
<AvatarData <AvatarData
title={template.organization_display_name} title={template.organization_display_name}
subtitle={`Used by ${Language.developerCount(template.active_user_count)}`} subtitle={`Used by ${developerCount}`}
avatar={<Avatar variant="icon" src={template.organization_icon} />} avatar={<Avatar variant="icon" src={template.organization_icon} />}
/> />
) : ( ) : (
Language.developerCount(template.active_user_count) developerCount
)} )}
</TableCell> </TableCell>
@@ -262,14 +251,12 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[35%]">{Language.nameLabel}</TableHead> <TableHead className="w-[35%]">Name</TableHead>
<TableHead className="w-[15%]"> <TableHead className="w-[15%]">
{showOrganizations ? "Organization" : Language.usedByLabel} {showOrganizations ? "Organization" : "Used by"}
</TableHead>
<TableHead className="w-[10%]">{Language.buildTimeLabel}</TableHead>
<TableHead className="w-[15%]">
{Language.lastUpdatedLabel}
</TableHead> </TableHead>
<TableHead className="w-[10%]">Build time</TableHead>
<TableHead className="w-[15%]">Last updated</TableHead>
<TableHead className="w-[1%]" /> <TableHead className="w-[1%]" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -11,7 +11,7 @@ import {
} from "#/testHelpers/entities"; } from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers"; import { renderWithAuth } from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server"; import { server } from "#/testHelpers/server";
import TerminalPage, { Language } from "./TerminalPage"; import TerminalPage from "./TerminalPage";
const renderTerminal = async ( const renderTerminal = async (
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`, route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
@@ -83,7 +83,7 @@ describe("TerminalPage", () => {
const { container } = await renderTerminal(); const { container } = await renderTerminal();
await expectTerminalText(container, Language.workspaceErrorMessagePrefix); await expectTerminalText(container, "Unable to fetch workspace: ");
}); });
it("shows reconnect message when websocket fails", async () => { it("shows reconnect message when websocket fails", async () => {
+2 -10
View File
@@ -36,12 +36,6 @@ import { getMatchingAgentOrFirst } from "#/utils/workspace";
import { TerminalAlerts } from "./TerminalAlerts"; import { TerminalAlerts } from "./TerminalAlerts";
import type { ConnectionStatus } from "./types"; import type { ConnectionStatus } from "./types";
export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
websocketErrorMessagePrefix: "WebSocket failed: ",
};
const TerminalPage: FC = () => { const TerminalPage: FC = () => {
// Maybe one day we'll support a light themed terminal, but terminal coloring // Maybe one day we'll support a light themed terminal, but terminal coloring
// is notably a pain because of assumptions certain programs might make about your // is notably a pain because of assumptions certain programs might make about your
@@ -271,16 +265,14 @@ const TerminalPage: FC = () => {
} }
if (workspace.error instanceof Error) { if (workspace.error instanceof Error) {
terminal.writeln( terminal.writeln(`Unable to fetch workspace: ${workspace.error.message}`);
Language.workspaceErrorMessagePrefix + workspace.error.message,
);
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
return; return;
} }
if (!workspaceAgent) { if (!workspaceAgent) {
terminal.writeln( terminal.writeln(
`${Language.workspaceAgentErrorMessagePrefix}no agent found with ID, is the workspace started?`, "Unable to fetch workspace agent: no agent found with ID, is the workspace started?",
); );
setConnectionStatus("disconnected"); setConnectionStatus("disconnected");
return; return;
@@ -13,15 +13,8 @@ import {
onChangeTrimmed, onChangeTrimmed,
} from "#/utils/formUtils"; } from "#/utils/formUtils";
export const Language = {
usernameLabel: "Username",
emailLabel: "Email",
nameLabel: "Name",
updateSettings: "Update account",
};
const validationSchema = Yup.object({ const validationSchema = Yup.object({
username: nameValidator(Language.usernameLabel), username: nameValidator("Username"),
name: Yup.string(), name: Yup.string(),
}); });
@@ -60,12 +53,7 @@ export const AccountForm: FC<AccountFormProps> = ({
<ErrorAlert error={updateProfileError} /> <ErrorAlert error={updateProfileError} />
)} )}
<TextField <TextField disabled fullWidth label="Email" value={email} />
disabled
fullWidth
label={Language.emailLabel}
value={email}
/>
<TextField <TextField
{...getFieldHelpers("username")} {...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
@@ -73,7 +61,7 @@ export const AccountForm: FC<AccountFormProps> = ({
autoComplete="username" autoComplete="username"
disabled={!editable} disabled={!editable}
fullWidth fullWidth
label={Language.usernameLabel} label="Username"
/> />
<TextField <TextField
{...getFieldHelpers("name")} {...getFieldHelpers("name")}
@@ -83,14 +71,14 @@ export const AccountForm: FC<AccountFormProps> = ({
e.target.value = e.target.value.trim(); e.target.value = e.target.value.trim();
form.handleChange(e); form.handleChange(e);
}} }}
label={Language.nameLabel} label="Name"
helperText='The human-readable name is optional and can be accessed in a template via the "data.coder_workspace_owner.me.full_name" property.' helperText='The human-readable name is optional and can be accessed in a template via the "data.coder_workspace_owner.me.full_name" property.'
/> />
<div> <div>
<Button disabled={isLoading} type="submit"> <Button disabled={isLoading} type="submit">
<Spinner loading={isLoading} /> <Spinner loading={isLoading} />
{Language.updateSettings} Update account
</Button> </Button>
</div> </div>
</FormFields> </FormFields>
@@ -2,7 +2,6 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { API } from "#/api/api"; import { API } from "#/api/api";
import { mockApiError } from "#/testHelpers/entities"; import { mockApiError } from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers"; import { renderWithAuth } from "#/testHelpers/renderHelpers";
import * as AccountForm from "./AccountForm";
import AccountPage from "./AccountPage"; import AccountPage from "./AccountPage";
const newData = { const newData = {
@@ -19,7 +18,7 @@ const fillAndSubmitForm = async () => {
fireEvent.change(screen.getByLabelText("Name"), { fireEvent.change(screen.getByLabelText("Name"), {
target: { value: newData.name }, target: { value: newData.name },
}); });
fireEvent.click(screen.getByText(AccountForm.Language.updateSettings)); fireEvent.click(screen.getByText("Update account"));
}; };
describe("AccountPage", () => { describe("AccountPage", () => {
@@ -2,7 +2,7 @@ import { fireEvent, screen, within } from "@testing-library/react";
import { API } from "#/api/api"; import { API } from "#/api/api";
import { MockGitSSHKey, mockApiError } from "#/testHelpers/entities"; import { MockGitSSHKey, mockApiError } from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers"; import { renderWithAuth } from "#/testHelpers/renderHelpers";
import SSHKeysPage, { Language as SSHKeysPageLanguage } from "./SSHKeysPage"; import SSHKeysPage from "./SSHKeysPage";
describe("SSH keys Page", () => { describe("SSH keys Page", () => {
it("shows the SSH key", async () => { it("shows the SSH key", async () => {
@@ -23,7 +23,7 @@ describe("SSH keys Page", () => {
fireEvent.click(regenerateButton); fireEvent.click(regenerateButton);
const confirmDialog = screen.getByRole("dialog"); const confirmDialog = screen.getByRole("dialog");
expect(confirmDialog).toHaveTextContent( expect(confirmDialog).toHaveTextContent(
SSHKeysPageLanguage.regenerateDialogMessage, "You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
); );
const newUserSSHKey = const newUserSSHKey =
@@ -35,7 +35,7 @@ describe("SSH keys Page", () => {
// Click on the "Confirm" button // Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByRole("button", { const confirmButton = within(confirmDialog).getByRole("button", {
name: SSHKeysPageLanguage.confirmLabel, name: "Confirm",
}); });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -59,7 +59,7 @@ describe("SSH keys Page", () => {
vi.spyOn(API, "regenerateUserSSHKey").mockRejectedValueOnce( vi.spyOn(API, "regenerateUserSSHKey").mockRejectedValueOnce(
mockApiError({ mockApiError({
message: SSHKeysPageLanguage.regenerationError, message: "Failed to regenerate SSH key",
}), }),
); );
@@ -68,17 +68,17 @@ describe("SSH keys Page", () => {
fireEvent.click(regenerateButton); fireEvent.click(regenerateButton);
const confirmDialog = screen.getByRole("dialog"); const confirmDialog = screen.getByRole("dialog");
expect(confirmDialog).toHaveTextContent( expect(confirmDialog).toHaveTextContent(
SSHKeysPageLanguage.regenerateDialogMessage, "You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
); );
// Click on the "Confirm" button // Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByRole("button", { const confirmButton = within(confirmDialog).getByRole("button", {
name: SSHKeysPageLanguage.confirmLabel, name: "Confirm",
}); });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
// Check if the error message is displayed // Check if the error message is displayed
await screen.findByText(SSHKeysPageLanguage.regenerationError); await screen.findByText("Failed to regenerate SSH key");
// Check if the API was called correctly // Check if the API was called correctly
expect(API.regenerateUserSSHKey).toBeCalledTimes(1); expect(API.regenerateUserSSHKey).toBeCalledTimes(1);
@@ -7,17 +7,6 @@ import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Section } from "../Section"; import { Section } from "../Section";
import { SSHKeysPageView } from "./SSHKeysPageView"; import { SSHKeysPageView } from "./SSHKeysPageView";
export const Language = {
title: "SSH keys",
regenerateDialogTitle: "Regenerate SSH key?",
regenerationError: "Failed to regenerate SSH key",
regenerationSuccess: "SSH Key regenerated successfully.",
regenerateDialogMessage:
"You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
confirmLabel: "Confirm",
cancelLabel: "Cancel",
};
const SSHKeysPage: FC = () => { const SSHKeysPage: FC = () => {
const [isConfirmingRegeneration, setIsConfirmingRegeneration] = const [isConfirmingRegeneration, setIsConfirmingRegeneration] =
useState(false); useState(false);
@@ -30,7 +19,7 @@ const SSHKeysPage: FC = () => {
return ( return (
<> <>
<Section title={Language.title}> <Section title="SSH keys">
<SSHKeysPageView <SSHKeysPageView
isLoading={userSSHKeyQuery.isLoading} isLoading={userSSHKeyQuery.isLoading}
getSSHKeyError={userSSHKeyQuery.error} getSSHKeyError={userSSHKeyQuery.error}
@@ -44,18 +33,21 @@ const SSHKeysPage: FC = () => {
hideCancel={false} hideCancel={false}
open={isConfirmingRegeneration} open={isConfirmingRegeneration}
confirmLoading={regenerateSSHKeyMutation.isPending} confirmLoading={regenerateSSHKeyMutation.isPending}
title={Language.regenerateDialogTitle} title="Regenerate SSH key?"
description={Language.regenerateDialogMessage} description="You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces."
confirmText={Language.confirmLabel} confirmText="Confirm"
onClose={() => setIsConfirmingRegeneration(false)} onClose={() => setIsConfirmingRegeneration(false)}
onConfirm={async () => { onConfirm={async () => {
try { try {
await regenerateSSHKeyMutation.mutateAsync(); await regenerateSSHKeyMutation.mutateAsync();
toast.success(Language.regenerationSuccess); toast.success("SSH Key regenerated successfully.");
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, Language.regenerationError), { toast.error(
description: getErrorDetail(error), getErrorMessage(error, "Failed to regenerate SSH key"),
}); {
description: getErrorDetail(error),
},
);
} finally { } finally {
setIsConfirmingRegeneration(false); setIsConfirmingRegeneration(false);
} }
@@ -16,27 +16,18 @@ interface SecurityFormValues {
confirm_password: string; confirm_password: string;
} }
export const Language = {
oldPasswordLabel: "Old Password",
newPasswordLabel: "New Password",
confirmPasswordLabel: "Confirm Password",
oldPasswordRequired: "Old password is required",
newPasswordRequired: "New password is required",
confirmPasswordRequired: "Password confirmation is required",
passwordMinLength: "Password must be at least 8 characters",
passwordMaxLength: "Password must be no more than 64 characters",
confirmPasswordMatch: "Password and confirmation must match",
updatePassword: "Update password",
};
const validationSchema = Yup.object({ const validationSchema = Yup.object({
old_password: Yup.string().trim().required(Language.oldPasswordRequired), old_password: Yup.string().trim().required("Old password is required"),
password: Yup.string().trim().required(Language.newPasswordRequired), password: Yup.string().trim().required("New password is required"),
confirm_password: Yup.string() confirm_password: Yup.string()
.trim() .trim()
.test("passwords-match", Language.confirmPasswordMatch, function (value) { .test(
return (this.parent as SecurityFormValues).password === value; "passwords-match",
}), "Password and confirmation must match",
function (value) {
return (this.parent as SecurityFormValues).password === value;
},
),
}); });
interface SecurityFormProps { interface SecurityFormProps {
@@ -80,27 +71,27 @@ export const SecurityForm: FC<SecurityFormProps> = ({
{...getFieldHelpers("old_password")} {...getFieldHelpers("old_password")}
autoComplete="old_password" autoComplete="old_password"
fullWidth fullWidth
label={Language.oldPasswordLabel} label="Old Password"
type="password" type="password"
/> />
<PasswordField <PasswordField
{...getFieldHelpers("password")} {...getFieldHelpers("password")}
autoComplete="password" autoComplete="password"
fullWidth fullWidth
label={Language.newPasswordLabel} label="New Password"
/> />
<TextField <TextField
{...getFieldHelpers("confirm_password")} {...getFieldHelpers("confirm_password")}
autoComplete="confirm_password" autoComplete="confirm_password"
fullWidth fullWidth
label={Language.confirmPasswordLabel} label="Confirm Password"
type="password" type="password"
/> />
<div> <div>
<Button disabled={isLoading} type="submit"> <Button disabled={isLoading} type="submit">
<Spinner loading={isLoading} /> <Spinner loading={isLoading} />
{Language.updatePassword} Update password
</Button> </Button>
</div> </div>
</FormFields> </FormFields>
@@ -7,7 +7,6 @@ import {
renderWithAuth, renderWithAuth,
waitForLoaderToBeRemoved, waitForLoaderToBeRemoved,
} from "#/testHelpers/renderHelpers"; } from "#/testHelpers/renderHelpers";
import { Language } from "./SecurityForm";
import SecurityPage from "./SecurityPage"; import SecurityPage from "./SecurityPage";
import * as SSO from "./SingleSignOnSection"; import * as SSO from "./SingleSignOnSection";
@@ -33,7 +32,7 @@ const fillAndSubmitSecurityForm = () => {
fireEvent.change(screen.getByLabelText("Confirm Password"), { fireEvent.change(screen.getByLabelText("Confirm Password"), {
target: { value: newSecurityFormValues.confirm_password }, target: { value: newSecurityFormValues.confirm_password },
}); });
fireEvent.click(screen.getByText(Language.updatePassword)); fireEvent.click(screen.getByText("Update password"));
}; };
beforeEach(() => { beforeEach(() => {
@@ -1,4 +1,4 @@
import type { FC, JSX } from "react"; import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated"; import type * as TypesGen from "#/api/typesGenerated";
import { CodeExample } from "#/components/CodeExample/CodeExample"; import { CodeExample } from "#/components/CodeExample/CodeExample";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
@@ -12,16 +12,6 @@ interface ResetPasswordDialogProps {
loading: boolean; loading: boolean;
} }
const Language = {
title: "Reset password",
message: (username?: string): JSX.Element => (
<>
You will need to send <strong>{username}</strong> the following password:
</>
),
confirmText: "Reset password",
};
export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({ export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({
open, open,
onClose, onClose,
@@ -32,7 +22,10 @@ export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({
}) => { }) => {
const description = ( const description = (
<> <>
<p>{Language.message(user?.username)}</p> <p>
You will need to send <strong>{user?.username}</strong> the following
password:
</p>
<CodeExample <CodeExample
secret={false} secret={false}
code={newPassword ?? ""} code={newPassword ?? ""}
@@ -48,9 +41,9 @@ export const ResetPasswordDialog: FC<ResetPasswordDialogProps> = ({
open={open} open={open}
onConfirm={onConfirm} onConfirm={onConfirm}
onClose={onClose} onClose={onClose}
title={Language.title} title="Reset password"
confirmLoading={loading} confirmLoading={loading}
confirmText={Language.confirmText} confirmText="Reset password"
description={description} description={description}
/> />
); );
@@ -12,16 +12,6 @@ import {
import { TableColumnHelpPopover } from "../../OrganizationSettingsPage/UserTable/TableColumnHelpPopover"; import { TableColumnHelpPopover } from "../../OrganizationSettingsPage/UserTable/TableColumnHelpPopover";
import { UsersTableBody } from "./UsersTableBody"; import { UsersTableBody } from "./UsersTableBody";
const Language = {
usernameLabel: "User",
rolesLabel: "Roles",
groupsLabel: "Groups",
aiAddonLabel: "AI add-on",
statusLabel: "Status",
lastSeenLabel: "Last Seen",
loginTypeLabel: "Login Type",
} as const;
interface UsersTableProps { interface UsersTableProps {
users: readonly TypesGen.User[] | undefined; users: readonly TypesGen.User[] | undefined;
roles: TypesGen.AssignableRoles[] | undefined; roles: TypesGen.AssignableRoles[] | undefined;
@@ -72,29 +62,29 @@ export const UsersTable: FC<UsersTableProps> = ({
<Table data-testid="users-table"> <Table data-testid="users-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-2/6">{Language.usernameLabel}</TableHead> <TableHead className="w-2/6">User</TableHead>
<TableHead className="w-2/6"> <TableHead className="w-2/6">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.rolesLabel}</span> <span>Roles</span>
<TableColumnHelpPopover variant="roles" /> <TableColumnHelpPopover variant="roles" />
</Stack> </Stack>
</TableHead> </TableHead>
<TableHead className="w-1/6"> <TableHead className="w-1/6">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.groupsLabel}</span> <span>Groups</span>
<TableColumnHelpPopover variant="groups" /> <TableColumnHelpPopover variant="groups" />
</Stack> </Stack>
</TableHead> </TableHead>
{showAISeatColumn && ( {showAISeatColumn && (
<TableHead className="w-1/6"> <TableHead className="w-1/6">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.aiAddonLabel}</span> <span>AI add-on</span>
<TableColumnHelpPopover variant="ai_addon" /> <TableColumnHelpPopover variant="ai_addon" />
</Stack> </Stack>
</TableHead> </TableHead>
)} )}
<TableHead className="w-1/6">{Language.loginTypeLabel}</TableHead> <TableHead className="w-1/6">Login Type</TableHead>
<TableHead className="w-1/6">{Language.statusLabel}</TableHead> <TableHead className="w-1/6">Status</TableHead>
{canEditUsers && <TableHead className="w-auto" />} {canEditUsers && <TableHead className="w-auto" />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -5,7 +5,6 @@ import { MockTemplate } from "#/testHelpers/entities";
import { render } from "#/testHelpers/renderHelpers"; import { render } from "#/testHelpers/renderHelpers";
import { timeZones } from "#/utils/timeZones"; import { timeZones } from "#/utils/timeZones";
import { import {
Language,
ttlShutdownAt, ttlShutdownAt,
validationSchema, validationSchema,
WorkspaceScheduleForm, WorkspaceScheduleForm,
@@ -71,7 +70,9 @@ describe("validationSchema", () => {
saturday: false, saturday: false,
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorNoDayOfWeek); expect(validate).toThrow(
"Must set at least one day of week if autostart is enabled.",
);
}); });
it("disallows empty startTime when autostart is enabled", () => { it("disallows empty startTime when autostart is enabled", () => {
@@ -87,7 +88,9 @@ describe("validationSchema", () => {
startTime: "", startTime: "",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorNoTime); expect(validate).toThrow(
"Start time is required when autostart is enabled.",
);
}); });
it("allows startTime 16:20", () => { it("allows startTime 16:20", () => {
@@ -105,7 +108,7 @@ describe("validationSchema", () => {
startTime: "9:30", startTime: "9:30",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTime); expect(validate).toThrow("Time must be in HH:mm format.");
}); });
it("disallows startTime to be HH:m", () => { it("disallows startTime to be HH:m", () => {
@@ -114,7 +117,7 @@ describe("validationSchema", () => {
startTime: "09:5", startTime: "09:5",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTime); expect(validate).toThrow("Time must be in HH:mm format.");
}); });
it("disallows an invalid startTime 24:01", () => { it("disallows an invalid startTime 24:01", () => {
@@ -123,7 +126,7 @@ describe("validationSchema", () => {
startTime: "24:01", startTime: "24:01",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTime); expect(validate).toThrow("Time must be in HH:mm format.");
}); });
it("disallows an invalid startTime 09:60", () => { it("disallows an invalid startTime 09:60", () => {
@@ -132,7 +135,7 @@ describe("validationSchema", () => {
startTime: "09:60", startTime: "09:60",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTime); expect(validate).toThrow("Time must be in HH:mm format.");
}); });
it("disallows an invalid timezone Canada/North", () => { it("disallows an invalid timezone Canada/North", () => {
@@ -141,7 +144,7 @@ describe("validationSchema", () => {
timezone: "Canada/North", timezone: "Canada/North",
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTimezone); expect(validate).toThrow("Invalid timezone.");
}); });
it("validation passes for all timezones", () => { it("validation passes for all timezones", () => {
@@ -179,7 +182,9 @@ describe("validationSchema", () => {
ttl: 24 * 30 + 1, ttl: 24 * 30 + 1,
}; };
const validate = () => validationSchema.validateSync(values); const validate = () => validationSchema.validateSync(values);
expect(validate).toThrow(Language.errorTtlMax); expect(validate).toThrow(
"Please enter a limit that is less than or equal to 30 days (720 hours).",
);
}); });
it("allows a ttl of 1.2 hours", () => { it("allows a ttl of 1.2 hours", () => {
@@ -34,34 +34,6 @@ import { timeZones } from "#/utils/timeZones";
// Need dayjs.tz functions for timezone validation // Need dayjs.tz functions for timezone validation
dayjs.extend(timezone); dayjs.extend(timezone);
export const Language = {
errorNoDayOfWeek:
"Must set at least one day of week if autostart is enabled.",
errorNoTime: "Start time is required when autostart is enabled.",
errorTime: "Time must be in HH:mm format.",
errorTimezone: "Invalid timezone.",
errorNoStop:
"Time until shutdown must be greater than zero when autostop is enabled.",
errorTtlMax:
"Please enter a limit that is less than or equal to 720 hours (30 days).",
daysOfWeekLabel: "Days of Week",
daySundayLabel: "Sun",
dayMondayLabel: "Mon",
dayTuesdayLabel: "Tue",
dayWednesdayLabel: "Wed",
dayThursdayLabel: "Thu",
dayFridayLabel: "Fri",
daySaturdayLabel: "Sat",
startTimeLabel: "Start time",
timezoneLabel: "Timezone",
ttlLabel: "Time until shutdown (hours)",
formTitle: "Workspace schedule",
startSection: "Start",
startSwitch: "Enable Autostart",
stopSection: "Stop",
stopSwitch: "Enable Autostop",
};
export interface WorkspaceScheduleFormProps { export interface WorkspaceScheduleFormProps {
template: Template; template: Template;
error?: unknown; error?: unknown;
@@ -93,7 +65,7 @@ export const validationSchema = Yup.object({
sunday: Yup.boolean(), sunday: Yup.boolean(),
monday: Yup.boolean().test( monday: Yup.boolean().test(
"at-least-one-day", "at-least-one-day",
Language.errorNoDayOfWeek, "Must set at least one day of week if autostart is enabled.",
function (value) { function (value) {
const parent = this.parent as WorkspaceScheduleFormValues; const parent = this.parent as WorkspaceScheduleFormValues;
@@ -121,14 +93,18 @@ export const validationSchema = Yup.object({
startTime: Yup.string() startTime: Yup.string()
.ensure() .ensure()
.test("required-if-autostart", Language.errorNoTime, function (value) { .test(
const parent = this.parent as WorkspaceScheduleFormValues; "required-if-autostart",
if (parent.autostartEnabled) { "Start time is required when autostart is enabled.",
return value !== ""; function (value) {
} const parent = this.parent as WorkspaceScheduleFormValues;
return true; if (parent.autostartEnabled) {
}) return value !== "";
.test("is-time-string", Language.errorTime, (value) => { }
return true;
},
)
.test("is-time-string", "Time must be in HH:mm format.", (value) => {
if (value === "") { if (value === "") {
return true; return true;
} }
@@ -142,7 +118,7 @@ export const validationSchema = Yup.object({
}), }),
timezone: Yup.string() timezone: Yup.string()
.ensure() .ensure()
.test("is-timezone", Language.errorTimezone, function (value) { .test("is-timezone", "Invalid timezone.", function (value) {
const parent = this.parent as WorkspaceScheduleFormValues; const parent = this.parent as WorkspaceScheduleFormValues;
if (!parent.startTime) { if (!parent.startTime) {
@@ -161,14 +137,21 @@ export const validationSchema = Yup.object({
}), }),
ttl: Yup.number() ttl: Yup.number()
.min(0) .min(0)
.max(24 * 30 /* 30 days */, Language.errorTtlMax) .max(
.test("positive-if-autostop", Language.errorNoStop, function (value) { 24 * 30 /* 30 days */,
const parent = this.parent as WorkspaceScheduleFormValues; "Please enter a limit that is less than or equal to 30 days (720 hours).",
if (parent.autostopEnabled) { )
return Boolean(value); .test(
} "positive-if-autostop",
return true; "Time until shutdown must be greater than zero when autostop is enabled.",
}), function (value) {
const parent = this.parent as WorkspaceScheduleFormValues;
if (parent.autostopEnabled) {
return Boolean(value);
}
return true;
},
),
}); });
export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
@@ -194,37 +177,37 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
{ {
value: form.values.monday, value: form.values.monday,
name: "monday", name: "monday",
label: Language.dayMondayLabel, label: "Mon",
}, },
{ {
value: form.values.tuesday, value: form.values.tuesday,
name: "tuesday", name: "tuesday",
label: Language.dayTuesdayLabel, label: "Tue",
}, },
{ {
value: form.values.wednesday, value: form.values.wednesday,
name: "wednesday", name: "wednesday",
label: Language.dayWednesdayLabel, label: "Wed",
}, },
{ {
value: form.values.thursday, value: form.values.thursday,
name: "thursday", name: "thursday",
label: Language.dayThursdayLabel, label: "Thu",
}, },
{ {
value: form.values.friday, value: form.values.friday,
name: "friday", name: "friday",
label: Language.dayFridayLabel, label: "Fri",
}, },
{ {
value: form.values.saturday, value: form.values.saturday,
name: "saturday", name: "saturday",
label: Language.daySaturdayLabel, label: "Sat",
}, },
{ {
value: form.values.sunday, value: form.values.sunday,
name: "sunday", name: "sunday",
label: Language.daySundayLabel, label: "Sun",
}, },
]; ];
@@ -268,7 +251,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
htmlFor="autostartEnabled" htmlFor="autostartEnabled"
className="font-medium cursor-pointer" className="font-medium cursor-pointer"
> >
{Language.startSwitch} Enable Autostart
</Label> </Label>
{!template.allow_user_autostart && ( {!template.allow_user_autostart && (
<span className="text-xs text-content-secondary mt-0.5"> <span className="text-xs text-content-secondary mt-0.5">
@@ -281,7 +264,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
<Label htmlFor="startTime">{Language.startTimeLabel}</Label> <Label htmlFor="startTime">Start time</Label>
<Input <Input
id="startTime" id="startTime"
name="startTime" name="startTime"
@@ -299,7 +282,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
)} )}
</div> </div>
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
<Label htmlFor="timezone">{Language.timezoneLabel}</Label> <Label htmlFor="timezone">Timezone</Label>
<Select <Select
value={form.values.timezone} value={form.values.timezone}
onValueChange={(value) => { onValueChange={(value) => {
@@ -328,7 +311,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
<fieldset className="border-0 p-0 m-0"> <fieldset className="border-0 p-0 m-0">
<legend className="text-xs text-content-secondary font-medium mb-1"> <legend className="text-xs text-content-secondary font-medium mb-1">
{Language.daysOfWeekLabel} Days of Week
</legend> </legend>
<div className="flex flex-row flex-wrap gap-x-4 gap-y-2 pt-1"> <div className="flex flex-row flex-wrap gap-x-4 gap-y-2 pt-1">
@@ -358,7 +341,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
{form.errors.monday && ( {form.errors.monday && (
<span className="text-xs text-content-destructive mt-1 block"> <span className="text-xs text-content-destructive mt-1 block">
{Language.errorNoDayOfWeek} Must set at least one day of week if autostart is enabled.
</span> </span>
)} )}
</fieldset> </fieldset>
@@ -395,7 +378,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
htmlFor="autostopEnabled" htmlFor="autostopEnabled"
className="font-medium cursor-pointer" className="font-medium cursor-pointer"
> >
{Language.stopSwitch} Enable Autostop
</Label> </Label>
{!template.allow_user_autostop && ( {!template.allow_user_autostop && (
<span className="text-xs text-content-secondary mt-0.5"> <span className="text-xs text-content-secondary mt-0.5">
@@ -407,7 +390,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="ttl">{Language.ttlLabel}</Label> <Label htmlFor="ttl">Time until shutdown (hours)</Label>
<Input <Input
id="ttl" id="ttl"
name="ttl" name="ttl"
@@ -465,7 +448,7 @@ export const ttlShutdownAt = (formTTL: number): string => {
return `Your workspace will shut down ${humanDuration(formTTL * 60 * 60 * 1000)} after its next start.`; return `Your workspace will shut down ${humanDuration(formTTL * 60 * 60 * 1000)} after its next start.`;
} catch (e) { } catch (e) {
if (e instanceof RangeError) { if (e instanceof RangeError) {
return Language.errorTtlMax; return "Please enter a limit that is less than or equal to 30 days (720 hours).";
} }
throw e; throw e;
} }
@@ -14,10 +14,7 @@ import {
} from "./formToRequest"; } from "./formToRequest";
import { scheduleToAutostart } from "./schedule"; import { scheduleToAutostart } from "./schedule";
import { ttlMsToAutostop } from "./ttl"; import { ttlMsToAutostop } from "./ttl";
import { import type { WorkspaceScheduleFormValues } from "./WorkspaceScheduleForm";
Language as FormLanguage,
type WorkspaceScheduleFormValues,
} from "./WorkspaceScheduleForm";
import WorkspaceSchedulePage from "./WorkspaceSchedulePage"; import WorkspaceSchedulePage from "./WorkspaceSchedulePage";
const validValues: WorkspaceScheduleFormValues = { const validValues: WorkspaceScheduleFormValues = {
@@ -266,9 +263,7 @@ describe("WorkspaceSchedulePage", () => {
path: "/:username/:workspace/schedule", path: "/:username/:workspace/schedule",
}); });
const user = userEvent.setup(); const user = userEvent.setup();
const autostopToggle = await screen.findByLabelText( const autostopToggle = await screen.findByLabelText("Enable Autostop");
FormLanguage.stopSwitch,
);
// enable autostop // enable autostop
await user.click(autostopToggle); await user.click(autostopToggle);
// find helper text that describes the mock template's 24 hour default // find helper text that describes the mock template's 24 hour default
@@ -287,9 +282,7 @@ describe("WorkspaceSchedulePage", () => {
path: "/:username/:workspace/schedule", path: "/:username/:workspace/schedule",
}); });
const user = userEvent.setup(); const user = userEvent.setup();
const autostopToggle = await screen.findByLabelText( const autostopToggle = await screen.findByLabelText("Enable Autostop");
FormLanguage.stopSwitch,
);
await user.click(autostopToggle); await user.click(autostopToggle);
const submitButton = await screen.findByRole("button", { const submitButton = await screen.findByRole("button", {
name: /save/i, name: /save/i,
@@ -322,9 +315,7 @@ describe("WorkspaceSchedulePage", () => {
], ],
}); });
const user = userEvent.setup(); const user = userEvent.setup();
const autostopToggle = await screen.findByLabelText( const autostopToggle = await screen.findByLabelText("Enable Autostop");
FormLanguage.stopSwitch,
);
await user.click(autostopToggle); await user.click(autostopToggle);
const submitButton = await screen.findByRole("button", { const submitButton = await screen.findByRole("button", {
name: /save/i, name: /save/i,
@@ -349,9 +340,7 @@ describe("WorkspaceSchedulePage", () => {
], ],
}); });
const user = userEvent.setup(); const user = userEvent.setup();
const autostartToggle = await screen.findByLabelText( const autostartToggle = await screen.findByLabelText("Enable Autostart");
FormLanguage.startSwitch,
);
await user.click(autostartToggle); await user.click(autostartToggle);
const submitButton = await screen.findByRole("button", { const submitButton = await screen.findByRole("button", {
name: /save/i, name: /save/i,
@@ -10,28 +10,22 @@ import {
} from "#/components/HelpPopover/HelpPopover"; } from "#/components/HelpPopover/HelpPopover";
import { docs } from "#/utils/docs"; import { docs } from "#/utils/docs";
const Language = {
workspaceTooltipTitle: "What is a workspace?",
workspaceTooltipText:
"A workspace is your development environment in the cloud. It includes the infrastructure and tools you need to work on your project.",
workspaceTooltipLink1: "Create Workspaces",
workspaceTooltipLink2: "Connect with SSH",
workspaceTooltipLink3: "Editors and IDEs",
};
export const WorkspaceHelpPopover: FC = () => { export const WorkspaceHelpPopover: FC = () => {
return ( return (
<HelpPopover> <HelpPopover>
<HelpPopoverIconTrigger /> <HelpPopoverIconTrigger />
<HelpPopoverContent> <HelpPopoverContent>
<HelpPopoverTitle>{Language.workspaceTooltipTitle}</HelpPopoverTitle> <HelpPopoverTitle>What is a workspace?</HelpPopoverTitle>
<HelpPopoverText>{Language.workspaceTooltipText}</HelpPopoverText> <HelpPopoverText>
A workspace is your development environment in the cloud. It includes
the infrastructure and tools you need to work on your project.
</HelpPopoverText>
<HelpPopoverLinksGroup> <HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/user-guides")}> <HelpPopoverLink href={docs("/user-guides")}>
{Language.workspaceTooltipLink1} Create Workspaces
</HelpPopoverLink> </HelpPopoverLink>
<HelpPopoverLink href={docs("/user-guides/workspace-access")}> <HelpPopoverLink href={docs("/user-guides/workspace-access")}>
{Language.workspaceTooltipLink2} Connect with SSH
</HelpPopoverLink> </HelpPopoverLink>
</HelpPopoverLinksGroup> </HelpPopoverLinksGroup>
</HelpPopoverContent> </HelpPopoverContent>
@@ -33,15 +33,6 @@ import {
import { WorkspaceHelpPopover } from "./WorkspaceHelpPopover"; import { WorkspaceHelpPopover } from "./WorkspaceHelpPopover";
import { WorkspacesButton } from "./WorkspacesButton"; import { WorkspacesButton } from "./WorkspacesButton";
const Language = {
pageTitle: "Workspaces",
yourWorkspacesButton: "Your workspaces",
allWorkspacesButton: "All workspaces",
runningWorkspacesButton: "Running workspaces",
seeAllTemplates: "See all templates",
template: "Template",
};
type TemplateQuery = UseQueryResult<Template[]>; type TemplateQuery = UseQueryResult<Template[]>;
interface WorkspacesPageViewProps { interface WorkspacesPageViewProps {
error: unknown; error: unknown;
@@ -109,7 +100,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
> >
<PageHeaderTitle> <PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>{Language.pageTitle}</span> <span>Workspaces</span>
<WorkspaceHelpPopover /> <WorkspaceHelpPopover />
</Stack> </Stack>
</PageHeaderTitle> </PageHeaderTitle>
+8 -20
View File
@@ -8,21 +8,6 @@ import type {
import * as Yup from "yup"; import * as Yup from "yup";
import { isApiValidationError, mapApiErrorToFieldErrors } from "#/api/errors"; import { isApiValidationError, mapApiErrorToFieldErrors } from "#/api/errors";
const Language = {
nameRequired: (name: string): string => {
return name ? `Please enter a ${name.toLowerCase()}.` : "Required";
},
nameInvalidChars: (): string => {
return "Special characters (e.g.: !, @, #) are not supported";
},
nameTooLong: (name: string, len: number): string => {
return `${name} cannot be longer than ${len} characters`;
},
displayNameInvalidChars: (name: string): string => {
return `${name} must start and end with non-whitespace character`;
},
};
interface GetFormHelperOptions { interface GetFormHelperOptions {
helperText?: ReactNode; helperText?: ReactNode;
/** /**
@@ -118,16 +103,19 @@ const displayNameRE = /^[^\s](.*[^\s])?$/;
// REMARK: see #1756 for name/username semantics // REMARK: see #1756 for name/username semantics
export const nameValidator = (name: string): Yup.StringSchema => export const nameValidator = (name: string): Yup.StringSchema =>
Yup.string() Yup.string()
.required(Language.nameRequired(name)) .required(`Please enter a ${name.toLowerCase()}.`)
.matches(usernameRE, Language.nameInvalidChars()) .matches(usernameRE, "Special characters (e.g.: !, @, #) are not supported")
.max(maxLenName, Language.nameTooLong(name, maxLenName)); .max(maxLenName, `${name} cannot be longer than ${maxLenName} characters`);
export const displayNameValidator = (displayName: string): Yup.StringSchema => export const displayNameValidator = (displayName: string): Yup.StringSchema =>
Yup.string() Yup.string()
.matches(displayNameRE, Language.displayNameInvalidChars(displayName)) .matches(
displayNameRE,
`${displayName} must start and end with non-whitespace character`,
)
.max( .max(
displayNameMaxLength, displayNameMaxLength,
Language.nameTooLong(displayName, displayNameMaxLength), `${displayName} cannot be longer than ${displayNameMaxLength} characters`,
) )
.optional(); .optional();
+4 -13
View File
@@ -54,15 +54,6 @@ export const extractTimezone = (
return defaultTZ; return defaultTZ;
}; };
/** Language used in the schedule components */
const Language = {
manual: "Manual",
workspaceShuttingDownLabel: "Workspace is shutting down",
afterStart: "after start",
autostartLabel: "Starts at",
autostopLabel: "Stops at",
};
export const autostartDisplay = (schedule: string | undefined): string => { export const autostartDisplay = (schedule: string | undefined): string => {
if (schedule) { if (schedule) {
return ( return (
@@ -74,7 +65,7 @@ export const autostartDisplay = (schedule: string | undefined): string => {
.replace("At", "") .replace("At", "")
); );
} }
return Language.manual; return "Manual";
}; };
const isShuttingDown = (workspace: Workspace, deadline?: Dayjs): boolean => { const isShuttingDown = (workspace: Workspace, deadline?: Dayjs): boolean => {
@@ -135,7 +126,7 @@ export const autostopDisplay = (
if (isShuttingDown(workspace, deadline)) { if (isShuttingDown(workspace, deadline)) {
return { return {
message: Language.workspaceShuttingDownLabel, message: "Workspace is shutting down",
}; };
} }
let title = ( let title = (
@@ -173,14 +164,14 @@ export const autostopDisplay = (
// If the workspace is not on, and the ttl is 0 or undefined, then the // If the workspace is not on, and the ttl is 0 or undefined, then the
// workspace is set to manually shutdown. // workspace is set to manually shutdown.
return { return {
message: Language.manual, message: "Manual",
}; };
} }
// The workspace has a ttl set, but is either in an unknown state or is // The workspace has a ttl set, but is either in an unknown state or is
// not running. Therefore, we derive from workspace.ttl. // not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds"); const duration = dayjs.duration(ttl, "milliseconds");
return { return {
message: `Stop ${duration.humanize()} ${Language.afterStart}`, message: `Stop ${duration.humanize()} after start`,
}; };
}; };