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