mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: remove Language objects (#23866)
This commit is contained in:
@@ -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>© {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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+14
-9
@@ -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", () => {
|
||||||
|
|||||||
+44
-61
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-16
@@ -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,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user