{canBeUpdated &&
- (isUpdating
- ? buttonMapping[ButtonTypesEnum.updating]
- : buttonMapping[ButtonTypesEnum.update])}
- {isRestarting && buttonMapping[ButtonTypesEnum.restarting]}
+ (isUpdating ? buttonMapping.updating : buttonMapping.update)}
+
+ {isRestarting && buttonMapping.restarting}
+
{!isRestarting &&
actionsByStatus.map((action) => (
= ({
ref={menuTriggerRef}
onClick={() => setIsMenuOpen(true)}
>
-
+
+
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts
index d6f2704a18f80..19274346d539a 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts
@@ -1,31 +1,27 @@
-import { Workspace, WorkspaceStatus } from "api/typesGenerated";
-import { ReactNode } from "react";
+import { type Workspace, type WorkspaceStatus } from "api/typesGenerated";
+import { type ReactElement } from "react";
-// the button types we have
-export enum ButtonTypesEnum {
- start = "start",
- starting = "starting",
- stop = "stop",
- stopping = "stopping",
- restart = "restart",
- restarting = "restarting",
- deleting = "deleting",
- update = "update",
- updating = "updating",
- activate = "activate",
- activating = "activating",
- // disabled buttons
- canceling = "canceling",
- deleted = "deleted",
- pending = "pending",
-}
+/**
+ * Buttons supported by workspace actions. Canceling, Deleted, and Pending
+ * should all be associated with disabled states
+ */
+export type ButtonType =
+ | Exclude
+ | "start"
+ | "stop"
+ | "restart"
+ | "restarting"
+ | "update"
+ | "updating"
+ | "activate"
+ | "activating";
export type ButtonMapping = {
- [key in ButtonTypesEnum]: ReactNode;
+ [key in ButtonType]: ReactElement;
};
interface WorkspaceAbilities {
- actions: ButtonTypesEnum[];
+ actions: readonly ButtonType[];
canCancel: boolean;
canAcceptJobs: boolean;
}
@@ -36,7 +32,7 @@ export const actionsByWorkspaceStatus = (
): WorkspaceAbilities => {
if (workspace.dormant_at) {
return {
- actions: [ButtonTypesEnum.activate],
+ actions: ["activate"],
canCancel: false,
canAcceptJobs: false,
};
@@ -44,35 +40,35 @@ export const actionsByWorkspaceStatus = (
return statusToActions[status];
};
-const statusToActions: Record = {
+const statusToActions = {
starting: {
- actions: [ButtonTypesEnum.starting],
+ actions: ["starting"],
canCancel: true,
canAcceptJobs: false,
},
running: {
- actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart],
+ actions: ["stop", "restart"],
canCancel: false,
canAcceptJobs: true,
},
stopping: {
- actions: [ButtonTypesEnum.stopping],
+ actions: ["stopping"],
canCancel: true,
canAcceptJobs: false,
},
stopped: {
- actions: [ButtonTypesEnum.start],
+ actions: ["start"],
canCancel: false,
canAcceptJobs: true,
},
canceled: {
- actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
+ actions: ["start", "stop"],
canCancel: false,
canAcceptJobs: true,
},
// in the case of an error
failed: {
- actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
+ actions: ["start", "stop"],
canCancel: false,
canAcceptJobs: true,
},
@@ -80,23 +76,23 @@ const statusToActions: Record = {
* disabled states
*/
canceling: {
- actions: [ButtonTypesEnum.canceling],
+ actions: ["canceling"],
canCancel: false,
canAcceptJobs: false,
},
deleting: {
- actions: [ButtonTypesEnum.deleting],
+ actions: ["deleting"],
canCancel: true,
canAcceptJobs: false,
},
deleted: {
- actions: [ButtonTypesEnum.deleted],
+ actions: ["deleted"],
canCancel: false,
canAcceptJobs: false,
},
pending: {
- actions: [ButtonTypesEnum.pending],
+ actions: ["pending"],
canCancel: false,
canAcceptJobs: false,
},
-};
+} as const satisfies Record;
diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
index f332ea78500f2..e7fb62eb47591 100644
--- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
@@ -2,7 +2,7 @@ import { useActor } from "@xstate/react";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import dayjs from "dayjs";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
-import { FC, useEffect, useState } from "react";
+import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate } from "react-router-dom";
import {
@@ -13,7 +13,7 @@ import {
} from "utils/schedule";
import { StateFrom } from "xstate";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
-import { Workspace, WorkspaceErrors } from "./Workspace";
+import { Workspace } from "./Workspace";
import { pageTitle } from "utils/page";
import { getFaviconByStatus, hasJobError } from "utils/workspace";
import {
@@ -56,11 +56,6 @@ export const WorkspaceReadyPage = ({
isLoadingMoreBuilds,
hasMoreBuilds,
}: WorkspaceReadyPageProps): JSX.Element => {
- const [_, bannerSend] = useActor(
- workspaceState.children["scheduleBannerMachine"],
- );
- const { buildInfo } = useDashboard();
- const featureVisibility = useFeatureVisibility();
const {
workspace,
template,
@@ -72,17 +67,22 @@ export const WorkspaceReadyPage = ({
permissions,
missedParameters,
} = workspaceState.context;
+
+ // Breaks the rules of hooks, but if we're throwing an error, the rules won't
+ // even have a chance to matter. Best to get the check out of the way early
+ // for better type narrowing
if (workspace === undefined) {
throw Error("Workspace is undefined");
}
- const deadline = getDeadline(workspace);
- const canUpdateWorkspace = Boolean(permissions?.updateWorkspace);
- const canUpdateTemplate = Boolean(permissions?.updateTemplate);
- const canRetryDebugMode =
- Boolean(permissions?.viewDeploymentValues) &&
- Boolean(deploymentValues?.enable_terraform_debug_mode);
- const favicon = getFaviconByStatus(workspace.latest_build);
+
+ const { buildInfo } = useDashboard();
+ const featureVisibility = useFeatureVisibility();
const navigate = useNavigate();
+
+ const [_, bannerSend] = useActor(
+ workspaceState.children["scheduleBannerMachine"],
+ );
+
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false);
const [confirmingRestart, setConfirmingRestart] = useState<{
@@ -90,29 +90,19 @@ export const WorkspaceReadyPage = ({
buildParameters?: TypesGen.WorkspaceBuildParameter[];
}>({ open: false });
- const { data: allVersions } = useQuery({
+ const { data: allTemplateVersions } = useQuery({
...templateVersions(workspace.template_id),
enabled: changeVersionDialogOpen,
});
- const { data: latestVersion } = useQuery({
+
+ const { data: latestTemplateVersion } = useQuery({
...templateVersion(workspace.template_active_version_id),
enabled: workspace.outdated,
});
- const [faviconTheme, setFaviconTheme] = useState<"light" | "dark">("dark");
- useEffect(() => {
- if (typeof window === "undefined" || !window.matchMedia) {
- return;
- }
- const isDark = window.matchMedia("(prefers-color-scheme: dark)");
- // We want the favicon the opposite of the theme.
- setFaviconTheme(isDark ? "light" : "dark");
- }, []);
+
const buildLogs = useWorkspaceBuildLogs(workspace.latest_build.id);
- const shouldDisplayBuildLogs =
- hasJobError(workspace) ||
- ["canceling", "deleting", "pending", "starting", "stopping"].includes(
- workspace.latest_build.status,
- );
+ const faviconTheme = useFaviconTheme();
+
const {
mutate: mutateRestartWorkspace,
error: restartBuildError,
@@ -120,11 +110,26 @@ export const WorkspaceReadyPage = ({
} = useMutation({
mutationFn: restartWorkspace,
});
+
// keep banner machine in sync with workspace
useEffect(() => {
bannerSend({ type: "REFRESH_WORKSPACE", workspace });
}, [bannerSend, workspace]);
+ const favicon = getFaviconByStatus(workspace.latest_build);
+ const deadline = getDeadline(workspace);
+ const canUpdateWorkspace = Boolean(permissions?.updateWorkspace);
+ const canUpdateTemplate = Boolean(permissions?.updateTemplate);
+ const canRetryDebugMode =
+ Boolean(permissions?.viewDeploymentValues) &&
+ Boolean(deploymentValues?.enable_terraform_debug_mode);
+
+ const shouldDisplayBuildLogs =
+ hasJobError(workspace) ||
+ ["canceling", "deleting", "pending", "starting", "stopping"].includes(
+ workspace.latest_build.status,
+ );
+
return (
<>
@@ -188,15 +193,15 @@ export const WorkspaceReadyPage = ({
isLoadingMoreBuilds={isLoadingMoreBuilds}
hasMoreBuilds={hasMoreBuilds}
canUpdateWorkspace={canUpdateWorkspace}
- updateMessage={latestVersion?.message}
+ updateMessage={latestTemplateVersion?.message}
canRetryDebugMode={canRetryDebugMode}
canChangeVersions={canUpdateTemplate}
hideSSHButton={featureVisibility["browser_only"]}
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
workspaceErrors={{
- [WorkspaceErrors.GET_BUILDS_ERROR]: buildsError,
- [WorkspaceErrors.BUILD_ERROR]: buildError || restartBuildError,
- [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
+ getBuildsError: buildsError,
+ buildError: buildError || restartBuildError,
+ cancellationError: cancellationError,
}}
buildInfo={buildInfo}
sshPrefix={sshPrefix}
@@ -234,9 +239,9 @@ export const WorkspaceReadyPage = ({
}}
/>
workspace.latest_build.template_version_id === v.id,
)}
open={changeVersionDialogOpen}
@@ -266,8 +271,8 @@ export const WorkspaceReadyPage = ({
Restarting your workspace will stop all running processes and{" "}
delete non-persistent data.
- {latestVersion?.message && (
- {latestVersion.message}
+ {latestTemplateVersion?.message && (
+ {latestTemplateVersion.message}
)}
}
@@ -296,6 +301,21 @@ export const WorkspaceReadyPage = ({
);
};
+function useFaviconTheme() {
+ const [faviconTheme, setFaviconTheme] = useState<"light" | "dark">("dark");
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) {
+ return;
+ }
+ const isDark = window.matchMedia("(prefers-color-scheme: dark)");
+ // We want the favicon the opposite of the theme.
+ setFaviconTheme(isDark ? "light" : "dark");
+ }, []);
+
+ return faviconTheme;
+}
+
const WarningDialog: FC<
Pick<
ConfirmDialogProps,
diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx
index e281a740ed9ce..ebb284e29608f 100644
--- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx
+++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx
@@ -9,7 +9,7 @@ import { useFormik } from "formik";
import { FC } from "react";
import {
getInitialRichParameterValues,
- useValidationSchemaForRichParameters,
+ validateRichParameters,
} from "utils/richParameters";
import * as Yup from "yup";
import { getFormHelpers } from "utils/formUtils";
@@ -46,7 +46,7 @@ export const WorkspaceParametersForm: FC<{
),
},
validationSchema: Yup.object({
- rich_parameter_values: useValidationSchemaForRichParameters(
+ rich_parameter_values: validateRichParameters(
templateVersionRichParameters,
),
}),
diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx
index c7656c8ea04d2..b8edaac6e7df2 100644
--- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx
+++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx
@@ -1,4 +1,4 @@
-import { getWorkspaceParameters, postWorkspaceBuild } from "api/api";
+import { getWorkspaceParameters } from "api/api";
import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
@@ -13,7 +13,10 @@ import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { FC } from "react";
import { isApiValidationError } from "api/errors";
import { ErrorAlert } from "components/Alert/ErrorAlert";
-import { WorkspaceBuildParameter } from "api/typesGenerated";
+import {
+ updateWorkspaceParameters,
+ workspaceParameters,
+} from "api/queries/workspaces";
import { EmptyState } from "components/EmptyState/EmptyState";
import Button from "@mui/material/Button";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
@@ -21,17 +24,11 @@ import { docs } from "utils/docs";
const WorkspaceParametersPage = () => {
const workspace = useWorkspaceSettings();
- const parameters = useQuery({
- queryKey: ["workspace", workspace.id, "parameters"],
- queryFn: () => getWorkspaceParameters(workspace),
- });
+ const parameters = useQuery(workspaceParameters(workspace));
const navigate = useNavigate();
+
const updateParameters = useMutation({
- mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
- postWorkspaceBuild(workspace.id, {
- transition: "start",
- rich_parameter_values: buildParameters,
- }),
+ ...updateWorkspaceParameters(workspace.id),
onSuccess: () => {
navigate(`/${workspace.owner_name}/${workspace.name}`);
},
diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts
index 8bc5dabba16c4..cc16cfeab9c2d 100644
--- a/site/src/testHelpers/handlers.ts
+++ b/site/src/testHelpers/handlers.ts
@@ -71,6 +71,12 @@ export const handlers = [
return res(ctx.status(200), ctx.json([M.MockTemplate]));
},
),
+ rest.post(
+ "/api/v2/organizations/:organizationId/members/:userId/workspaces",
+ async (req, res, ctx) => {
+ return res(ctx.status(200), ctx.json(M.MockWorkspace));
+ },
+ ),
// templates
rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => {
diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts
index d63da7c4ee530..64a9e0d5638bd 100644
--- a/site/src/utils/richParameters.ts
+++ b/site/src/utils/richParameters.ts
@@ -5,24 +5,27 @@ import {
import * as Yup from "yup";
export const getInitialRichParameterValues = (
- templateParameters: TemplateVersionParameter[],
- buildParameters?: WorkspaceBuildParameter[],
+ templateParameters: readonly TemplateVersionParameter[],
+ buildParameters?: readonly WorkspaceBuildParameter[],
): WorkspaceBuildParameter[] => {
- return templateParameters.map((parameter) => {
- const existentBuildParameter = buildParameters?.find(
- (p) => p.name === parameter.name,
+ return templateParameters.map((templateParam) => {
+ const matchedBuildParam = buildParameters?.find(
+ (buildParam) => buildParam.name === templateParam.name,
);
- const shouldReturnTheDefaultValue =
- !existentBuildParameter ||
- !isValidValue(parameter, existentBuildParameter) ||
- parameter.ephemeral;
- if (shouldReturnTheDefaultValue) {
- return {
- name: parameter.name,
- value: parameter.default_value,
- };
+
+ const shouldOverrideTemplate =
+ matchedBuildParam !== undefined &&
+ isValidValue(templateParam, matchedBuildParam) &&
+ !templateParam.ephemeral;
+
+ if (shouldOverrideTemplate) {
+ return matchedBuildParam;
}
- return existentBuildParameter;
+
+ return {
+ name: templateParam.name,
+ value: templateParam.default_value,
+ };
});
};
@@ -38,7 +41,7 @@ const isValidValue = (
return true;
};
-export const useValidationSchemaForRichParameters = (
+export const validateRichParameters = (
templateParameters?: TemplateVersionParameter[],
lastBuildParameters?: WorkspaceBuildParameter[],
): Yup.AnySchema => {