feat: handle update build for dynamic params (#18226)

resolves coder/preview#110

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
This commit is contained in:
Jaayden Halko
2025-06-05 15:35:23 -05:00
committed by GitHub
parent c339066be3
commit 508fba83dd
15 changed files with 431 additions and 132 deletions
+82 -10
View File
@@ -24,7 +24,10 @@ import type dayjs from "dayjs";
import userAgentParser from "ua-parser-js";
import { OneWayWebSocket } from "../utils/OneWayWebSocket";
import { delay } from "../utils/delay";
import type { PostWorkspaceUsageRequest } from "./typesGenerated";
import type {
DynamicParametersRequest,
PostWorkspaceUsageRequest,
} from "./typesGenerated";
import * as TypesGen from "./typesGenerated";
const getMissingParameters = (
@@ -73,8 +76,10 @@ const getMissingParameters = (
if (templateParameter.options.length === 0) {
continue;
}
// Check if there is a new value
// For multi-select, extra steps are necessary to JSON parse the value.
if (templateParameter.form_type === "multi-select") {
continue;
}
let buildParameter = newBuildParameters.find(
(p) => p.name === templateParameter.name,
);
@@ -231,7 +236,7 @@ export const watchWorkspaceAgentLogs = (
/**
* WebSocket compression in Safari (confirmed in 16.5) is broken when
* the server sends large messages. The following error is seen:
* WebSocket connection to 'wss://...' failed: The operation couldnt be completed.
* WebSocket connection to 'wss://...' failed: The operation couldn't be completed.
*/
if (userAgentParser(navigator.userAgent).browser.name === "Safari") {
searchParams.set("no_compression", "");
@@ -990,6 +995,17 @@ class ApiMethods {
return response.data;
};
getTemplateVersionDynamicParameters = async (
versionId: string,
data: TypesGen.DynamicParametersRequest,
): Promise<TypesGen.DynamicParametersResponse> => {
const response = await this.axios.post(
`/api/v2/templateversions/${versionId}/dynamic-parameters/evaluate`,
data,
);
return response.data;
};
getTemplateVersionRichParameters = async (
versionId: string,
): Promise<TypesGen.TemplateVersionParameter[]> => {
@@ -2132,6 +2148,38 @@ class ApiMethods {
await this.axios.delete(`/api/v2/licenses/${licenseId}`);
};
getDynamicParameters = async (
templateVersionId: string,
ownerId: string,
oldBuildParameters: TypesGen.WorkspaceBuildParameter[],
) => {
const request: DynamicParametersRequest = {
id: 1,
owner_id: ownerId,
inputs: Object.fromEntries(
new Map(oldBuildParameters.map((param) => [param.name, param.value])),
),
};
const dynamicParametersResponse =
await this.getTemplateVersionDynamicParameters(
templateVersionId,
request,
);
return dynamicParametersResponse.parameters.map((p) => ({
...p,
description_plaintext: p.description || "",
default_value: p.default_value?.valid ? p.default_value.value : "",
options: p.options
? p.options.map((opt) => ({
...opt,
value: opt.value?.valid ? opt.value.value : "",
}))
: [],
}));
};
/** Steps to change the workspace version
* - Get the latest template to access the latest active version
* - Get the current build parameters
@@ -2145,11 +2193,23 @@ class ApiMethods {
workspace: TypesGen.Workspace,
templateVersionId: string,
newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [],
isDynamicParametersEnabled = false,
): Promise<TypesGen.WorkspaceBuild> => {
const [currentBuildParameters, templateParameters] = await Promise.all([
this.getWorkspaceBuildParameters(workspace.latest_build.id),
this.getTemplateVersionRichParameters(templateVersionId),
]);
const currentBuildParameters = await this.getWorkspaceBuildParameters(
workspace.latest_build.id,
);
let templateParameters: TypesGen.TemplateVersionParameter[] = [];
if (isDynamicParametersEnabled) {
templateParameters = await this.getDynamicParameters(
templateVersionId,
workspace.owner_id,
currentBuildParameters,
);
} else {
templateParameters =
await this.getTemplateVersionRichParameters(templateVersionId);
}
const missingParameters = getMissingParameters(
currentBuildParameters,
@@ -2180,6 +2240,7 @@ class ApiMethods {
updateWorkspace = async (
workspace: TypesGen.Workspace,
newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [],
isDynamicParametersEnabled = false,
): Promise<TypesGen.WorkspaceBuild> => {
const [template, oldBuildParameters] = await Promise.all([
this.getTemplate(workspace.template_id),
@@ -2187,8 +2248,19 @@ class ApiMethods {
]);
const activeVersionId = template.active_version_id;
const templateParameters =
await this.getTemplateVersionRichParameters(activeVersionId);
let templateParameters: TypesGen.TemplateVersionParameter[] = [];
if (isDynamicParametersEnabled) {
templateParameters = await this.getDynamicParameters(
activeVersionId,
workspace.owner_id,
oldBuildParameters,
);
} else {
templateParameters =
await this.getTemplateVersionRichParameters(activeVersionId);
}
const missingParameters = getMissingParameters(
oldBuildParameters,
+19 -3
View File
@@ -163,6 +163,7 @@ export const updateDeadline = (
export const changeVersion = (
workspace: Workspace,
queryClient: QueryClient,
isDynamicParametersEnabled: boolean,
) => {
return {
mutationFn: ({
@@ -172,7 +173,12 @@ export const changeVersion = (
versionId: string;
buildParameters?: WorkspaceBuildParameter[];
}) => {
return API.changeWorkspaceVersion(workspace, versionId, buildParameters);
return API.changeWorkspaceVersion(
workspace,
versionId,
buildParameters,
isDynamicParametersEnabled,
);
},
onSuccess: async (build: WorkspaceBuild) => {
await updateWorkspaceBuild(build, queryClient);
@@ -185,8 +191,18 @@ export const updateWorkspace = (
queryClient: QueryClient,
) => {
return {
mutationFn: (buildParameters?: WorkspaceBuildParameter[]) => {
return API.updateWorkspace(workspace, buildParameters);
mutationFn: ({
buildParameters,
isDynamicParametersEnabled,
}: {
buildParameters?: WorkspaceBuildParameter[];
isDynamicParametersEnabled: boolean;
}) => {
return API.updateWorkspace(
workspace,
buildParameters,
isDynamicParametersEnabled,
);
},
onSuccess: async (build: WorkspaceBuild) => {
await updateWorkspaceBuild(build, queryClient);
+3 -3
View File
@@ -45,7 +45,7 @@ export const DialogContent = forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-4
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6
border border-solid border-border bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%]
data-[state=open]:animate-in data-[state=closed]:animate-out
@@ -68,7 +68,7 @@ export const DialogHeader: FC<HTMLAttributes<HTMLDivElement>> = ({
}) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"flex flex-col space-y-5 text-center sm:text-left",
className,
)}
{...props}
@@ -108,7 +108,7 @@ export const DialogDescription = forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-content-secondary", className)}
className={cn("text-sm text-content-secondary font-medium", className)}
{...props}
/>
));
@@ -0,0 +1,42 @@
import { useQuery } from "react-query";
export const optOutKey = (id: string): string => `parameters.${id}.optOut`;
interface UseDynamicParametersOptOutOptions {
templateId: string | undefined;
templateUsesClassicParameters: boolean | undefined;
enabled: boolean;
}
export const useDynamicParametersOptOut = ({
templateId,
templateUsesClassicParameters,
enabled,
}: UseDynamicParametersOptOutOptions) => {
return useQuery({
enabled: !!templateId && enabled,
queryKey: ["dynamicParametersOptOut", templateId],
queryFn: () => {
if (!templateId) {
// This should not happen if enabled is working correctly,
// but as a type guard and sanity check.
throw new Error("templateId is required");
}
const localStorageKey = optOutKey(templateId);
const storedOptOutString = localStorage.getItem(localStorageKey);
let optedOut: boolean;
if (storedOptOutString !== null) {
optedOut = storedOptOutString === "true";
} else {
optedOut = Boolean(templateUsesClassicParameters);
}
return {
templateId,
optedOut,
};
},
});
};
@@ -0,0 +1,71 @@
import type { TemplateVersionParameter } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
type UpdateBuildParametersDialogExperimentalProps = {
open: boolean;
onClose: () => void;
missedParameters: TemplateVersionParameter[];
workspaceOwnerName: string;
workspaceName: string;
templateVersionId: string | undefined;
};
export const UpdateBuildParametersDialogExperimental: FC<
UpdateBuildParametersDialogExperimentalProps
> = ({
missedParameters,
open,
onClose,
workspaceOwnerName,
workspaceName,
templateVersionId,
}) => {
const navigate = useNavigate();
const handleGoToParameters = () => {
onClose();
navigate(
`/@${workspaceOwnerName}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`,
);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update workspace parameters</DialogTitle>
<DialogDescription>
This template has{" "}
<strong className="text-content-primary">
{missedParameters.length} new parameter
{missedParameters.length === 1 ? "" : "s"}
</strong>{" "}
that must be configured to complete the update.
</DialogDescription>
<DialogDescription>
Would you like to go to the workspace parameters page to review and
update these parameters before continuing?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onClose} variant="outline">
Cancel
</Button>
<Button onClick={handleGoToParameters}>
Go to workspace parameters
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -21,12 +21,15 @@ import {
SettingsIcon,
TrashIcon,
} from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useDynamicParametersOptOut } from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import { type FC, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog";
import { DownloadLogsDialog } from "./DownloadLogsDialog";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { UpdateBuildParametersDialogExperimental } from "./UpdateBuildParametersDialogExperimental";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
@@ -40,6 +43,15 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
disabled,
}) => {
const queryClient = useQueryClient();
const { experiments } = useDashboard();
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const optOutQuery = useDynamicParametersOptOut({
templateId: workspace.template_id,
templateUsesClassicParameters:
workspace.template_use_classic_parameter_flow,
enabled: isDynamicParametersEnabled,
});
// Permissions
const { data: permissions } = useQuery(workspacePermissions(workspace));
@@ -50,7 +62,7 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
// Change version
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const changeVersionMutation = useMutation(
changeVersion(workspace, queryClient),
changeVersion(workspace, queryClient, optOutQuery.data?.optedOut === false),
);
// Delete
@@ -142,25 +154,46 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
onClose={() => setIsDownloadDialogOpen(false)}
/>
<UpdateBuildParametersDialog
missedParameters={
changeVersionMutation.error instanceof MissingBuildParameters
? changeVersionMutation.error.parameters
: []
}
open={changeVersionMutation.error instanceof MissingBuildParameters}
onClose={() => {
changeVersionMutation.reset();
}}
onUpdate={(buildParameters) => {
if (changeVersionMutation.error instanceof MissingBuildParameters) {
changeVersionMutation.mutate({
versionId: changeVersionMutation.error.versionId,
buildParameters,
});
{!isDynamicParametersEnabled || optOutQuery.data?.optedOut ? (
<UpdateBuildParametersDialog
missedParameters={
changeVersionMutation.error instanceof MissingBuildParameters
? changeVersionMutation.error.parameters
: []
}
}}
/>
open={changeVersionMutation.error instanceof MissingBuildParameters}
onClose={() => {
changeVersionMutation.reset();
}}
onUpdate={(buildParameters) => {
if (changeVersionMutation.error instanceof MissingBuildParameters) {
changeVersionMutation.mutate({
versionId: changeVersionMutation.error.versionId,
buildParameters,
});
}
}}
/>
) : (
<UpdateBuildParametersDialogExperimental
missedParameters={
changeVersionMutation.error instanceof MissingBuildParameters
? changeVersionMutation.error.parameters
: []
}
open={changeVersionMutation.error instanceof MissingBuildParameters}
onClose={() => {
changeVersionMutation.reset();
}}
workspaceOwnerName={workspace.owner_name}
workspaceName={workspace.name}
templateVersionId={
changeVersionMutation.error instanceof MissingBuildParameters
? changeVersionMutation.error?.versionId
: undefined
}
/>
)}
<ChangeWorkspaceVersionDialog
workspace={workspace}
@@ -6,9 +6,14 @@ import type {
WorkspaceBuild,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Loader } from "components/Loader/Loader";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useDynamicParametersOptOut } from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import { UpdateBuildParametersDialog } from "modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog";
import { UpdateBuildParametersDialogExperimental } from "modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental";
import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
@@ -51,8 +56,21 @@ export const useWorkspaceUpdate = ({
setIsConfirmingUpdate(true);
};
const { experiments } = useDashboard();
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const optOutQuery = useDynamicParametersOptOut({
templateId: workspace.template_id,
templateUsesClassicParameters:
workspace.template_use_classic_parameter_flow,
enabled: isDynamicParametersEnabled,
});
const confirmUpdate = (buildParameters: WorkspaceBuildParameter[] = []) => {
updateWorkspaceMutation.mutate(buildParameters);
updateWorkspaceMutation.mutate({
buildParameters,
isDynamicParametersEnabled: optOutQuery.data?.optedOut === false,
});
setIsConfirmingUpdate(false);
};
@@ -67,6 +85,7 @@ export const useWorkspaceUpdate = ({
latestVersion,
},
missingBuildParameters: {
workspace,
error: updateWorkspaceMutation.error,
onClose: () => {
updateWorkspaceMutation.reset();
@@ -134,22 +153,57 @@ const UpdateConfirmationDialog: FC<UpdateConfirmationDialogProps> = ({
};
type MissingBuildParametersDialogProps = {
workspace: Workspace;
error: unknown;
onClose: () => void;
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void;
};
const MissingBuildParametersDialog: FC<MissingBuildParametersDialogProps> = ({
workspace,
error,
...dialogProps
}) => {
return (
const { experiments } = useDashboard();
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const optOutQuery = useDynamicParametersOptOut({
templateId: workspace.template_id,
templateUsesClassicParameters:
workspace.template_use_classic_parameter_flow,
enabled: isDynamicParametersEnabled,
});
const missedParameters =
error instanceof MissingBuildParameters ? error.parameters : [];
const versionId =
error instanceof MissingBuildParameters ? error.versionId : undefined;
const isOpen = error instanceof MissingBuildParameters;
if (optOutQuery.isError) {
return <ErrorAlert error={optOutQuery.error} />;
}
if (isDynamicParametersEnabled && !optOutQuery.data) {
return <Loader />;
}
// If dynamic parameters experiment is not enabled, or if opted out, use classic dialog
const shouldUseClassicDialog =
!isDynamicParametersEnabled || optOutQuery.data?.optedOut;
return shouldUseClassicDialog ? (
<UpdateBuildParametersDialog
missedParameters={
error instanceof MissingBuildParameters ? error.parameters : []
}
open={error instanceof MissingBuildParameters}
missedParameters={missedParameters}
open={isOpen}
{...dialogProps}
/>
) : (
<UpdateBuildParametersDialogExperimental
missedParameters={missedParameters}
open={isOpen}
onClose={dialogProps.onClose}
workspaceOwnerName={workspace.owner_name}
workspaceName={workspace.name}
templateVersionId={versionId}
/>
);
};
@@ -2,6 +2,10 @@ import { templateByName } from "api/queries/templates";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
optOutKey,
useDynamicParametersOptOut,
} from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
@@ -11,39 +15,26 @@ import { ExperimentalFormContext } from "./ExperimentalFormContext";
const CreateWorkspaceExperimentRouter: FC = () => {
const { experiments } = useDashboard();
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const { organization: organizationName = "default", template: templateName } =
useParams() as { organization?: string; template: string };
const templateQuery = useQuery({
...templateByName(organizationName, templateName),
enabled: dynamicParametersEnabled,
enabled: isDynamicParametersEnabled,
});
const optOutQuery = useQuery({
const optOutQuery = useDynamicParametersOptOut({
templateId: templateQuery.data?.id,
templateUsesClassicParameters:
templateQuery.data?.use_classic_parameter_flow,
enabled: !!templateQuery.data,
queryKey: [organizationName, "template", templateQuery.data?.id, "optOut"],
queryFn: () => {
const templateId = templateQuery.data?.id;
const localStorageKey = optOutKey(templateId ?? "");
const storedOptOutString = localStorage.getItem(localStorageKey);
let optOutResult: boolean;
if (storedOptOutString !== null) {
optOutResult = storedOptOutString === "true";
} else {
optOutResult = !!templateQuery.data?.use_classic_parameter_flow;
}
return {
templateId: templateId,
optedOut: optOutResult,
};
},
});
if (dynamicParametersEnabled) {
if (isDynamicParametersEnabled) {
if (templateQuery.isError) {
return <ErrorAlert error={templateQuery.error} />;
}
if (optOutQuery.isError) {
return <ErrorAlert error={optOutQuery.error} />;
}
@@ -77,5 +68,3 @@ const CreateWorkspaceExperimentRouter: FC = () => {
};
export default CreateWorkspaceExperimentRouter;
const optOutKey = (id: string) => `parameters.${id}.optOut`;
@@ -305,16 +305,20 @@ describe("WorkspacePage", () => {
// Check if the update was called using the values from the form
await waitFor(() => {
expect(API.updateWorkspace).toBeCalledWith(MockOutdatedWorkspace, [
{
name: MockTemplateVersionParameter1.name,
value: "some-value",
},
{
name: MockTemplateVersionParameter2.name,
value: "2",
},
]);
expect(API.updateWorkspace).toBeCalledWith(
MockOutdatedWorkspace,
[
{
name: MockTemplateVersionParameter1.name,
value: "some-value",
},
{
name: MockTemplateVersionParameter2.name,
value: "2",
},
],
false,
);
});
});
@@ -1,8 +1,11 @@
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
optOutKey,
useDynamicParametersOptOut,
} from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import type { FC } from "react";
import { useQuery } from "react-query";
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import WorkspaceParametersPage from "./WorkspaceParametersPage";
@@ -11,45 +14,21 @@ import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperi
const WorkspaceParametersExperimentRouter: FC = () => {
const { experiments } = useDashboard();
const workspace = useWorkspaceSettings();
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const optOutQuery = useQuery({
enabled: dynamicParametersEnabled,
queryKey: [
"workspace",
workspace.id,
"template_id",
workspace.template_id,
"optOut",
],
queryFn: () => {
const templateId = workspace.template_id;
const workspaceId = workspace.id;
const localStorageKey = optOutKey(templateId);
const storedOptOutString = localStorage.getItem(localStorageKey);
let optOutResult: boolean;
if (storedOptOutString !== null) {
optOutResult = storedOptOutString === "true";
} else {
optOutResult = Boolean(workspace.template_use_classic_parameter_flow);
}
return {
templateId,
workspaceId,
optedOut: optOutResult,
};
},
const optOutQuery = useDynamicParametersOptOut({
templateId: workspace.template_id,
templateUsesClassicParameters:
workspace.template_use_classic_parameter_flow,
enabled: isDynamicParametersEnabled,
});
if (dynamicParametersEnabled) {
if (optOutQuery.isLoading) {
return <Loader />;
if (isDynamicParametersEnabled) {
if (optOutQuery.isError) {
return <ErrorAlert error={optOutQuery.error} />;
}
if (!optOutQuery.data) {
return <ErrorAlert error={optOutQuery.error} />;
return <Loader />;
}
const toggleOptedOut = () => {
@@ -79,5 +58,3 @@ const WorkspaceParametersExperimentRouter: FC = () => {
};
export default WorkspaceParametersExperimentRouter;
const optOutKey = (id: string) => `parameters.${id}.optOut`;
@@ -24,7 +24,7 @@ import type { FC } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
@@ -40,6 +40,8 @@ const WorkspaceParametersPageExperimental: FC = () => {
const workspace = useWorkspaceSettings();
const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext);
const [searchParams] = useSearchParams();
const templateVersionId = searchParams.get("templateVersionId") ?? undefined;
// autofill the form with the workspace build parameters from the latest build
const {
@@ -107,10 +109,11 @@ const WorkspaceParametersPageExperimental: FC = () => {
});
useEffect(() => {
if (!workspace.latest_build.template_version_id) return;
if (!templateVersionId && !workspace.latest_build.template_version_id)
return;
const socket = API.templateVersionDynamicParameters(
workspace.latest_build.template_version_id,
templateVersionId ?? workspace.latest_build.template_version_id,
{
onMessage,
onError: (error) => {
@@ -136,12 +139,17 @@ const WorkspaceParametersPageExperimental: FC = () => {
return () => {
socket.close();
};
}, [workspace.latest_build.template_version_id, onMessage]);
}, [
templateVersionId,
workspace.latest_build.template_version_id,
onMessage,
]);
const updateParameters = useMutation({
mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
API.postWorkspaceBuild(workspace.id, {
transition: "start",
template_version_id: templateVersionId,
rich_parameter_values: buildParameters,
}),
onSuccess: () => {
@@ -250,6 +258,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
templateVersionId={templateVersionId}
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
@@ -5,6 +5,7 @@ import type {
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
@@ -30,6 +31,7 @@ type WorkspaceParametersPageViewExperimentalProps = {
rich_parameter_values: WorkspaceBuildParameter[];
}) => void;
sendMessage: (formValues: Record<string, string>) => void;
templateVersionId: string | undefined;
};
export const WorkspaceParametersPageViewExperimental: FC<
@@ -44,6 +46,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
onSubmit,
sendMessage,
onCancel,
templateVersionId,
}) => {
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
@@ -152,6 +155,15 @@ export const WorkspaceParametersPageViewExperimental: FC<
</div>
)}
{(templateVersionId || workspace.latest_build.template_version_id) && (
<div className="flex flex-col gap-2">
<Label className="text-sm text-content-secondary">Version ID</Label>
<p className="m-0 text-sm font-medium">
{templateVersionId ?? workspace.latest_build.template_version_id}
</p>
</div>
)}
<form onSubmit={form.handleSubmit} className="flex flex-col gap-8">
{standardParameters.length > 0 && (
<section className="flex flex-col gap-9">
@@ -236,10 +248,21 @@ export const WorkspaceParametersPageViewExperimental: FC<
</Button>
<Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
disabled={
isSubmitting ||
disabled ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||
parameters.some((parameter) =>
parameter.diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
),
)
}
>
<Spinner loading={isSubmitting} />
Submit and restart
Update and restart
</Button>
</div>
</form>
@@ -123,8 +123,8 @@ describe("WorkspacesPage", () => {
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
});
it("warns about and updates running workspaces", async () => {
@@ -160,9 +160,9 @@ describe("WorkspacesPage", () => {
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
});
it("warns about and ignores dormant workspaces", async () => {
@@ -199,8 +199,8 @@ describe("WorkspacesPage", () => {
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
});
it("warns about running workspaces and then dormant workspaces", async () => {
@@ -241,9 +241,9 @@ describe("WorkspacesPage", () => {
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
});
});
@@ -37,6 +37,8 @@ function useSafeSearchParams() {
}
const WorkspacesPage: FC = () => {
const { experiments } = useDashboard();
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const queryClient = useQueryClient();
// If we use a useSearchParams for each hook, the values will not be in sync.
// So we have to use a single one, centralizing the values, and pass it to
@@ -162,7 +164,10 @@ const WorkspacesPage: FC = () => {
checkedWorkspaces={checkedWorkspaces}
open={confirmingBatchAction === "update"}
onConfirm={async () => {
await batchActions.updateAll(checkedWorkspaces);
await batchActions.updateAll({
workspaces: checkedWorkspaces,
isDynamicParametersEnabled,
});
setConfirmingBatchAction(null);
}}
onClose={() => {
@@ -45,11 +45,15 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const updateAllMutation = useMutation({
mutationFn: (workspaces: readonly Workspace[]) => {
mutationFn: (payload: {
workspaces: readonly Workspace[];
isDynamicParametersEnabled: boolean;
}) => {
const { workspaces, isDynamicParametersEnabled } = payload;
return Promise.all(
workspaces
.filter((w) => w.outdated && !w.dormant_at)
.map((w) => API.updateWorkspace(w)),
.map((w) => API.updateWorkspace(w, [], isDynamicParametersEnabled)),
);
},
onSuccess,