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 16:35:23 -04:00
committed by GitHub
co-authored by blink-so[bot]
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 userAgentParser from "ua-parser-js";
import { OneWayWebSocket } from "../utils/OneWayWebSocket"; import { OneWayWebSocket } from "../utils/OneWayWebSocket";
import { delay } from "../utils/delay"; import { delay } from "../utils/delay";
import type { PostWorkspaceUsageRequest } from "./typesGenerated"; import type {
DynamicParametersRequest,
PostWorkspaceUsageRequest,
} from "./typesGenerated";
import * as TypesGen from "./typesGenerated"; import * as TypesGen from "./typesGenerated";
const getMissingParameters = ( const getMissingParameters = (
@@ -73,8 +76,10 @@ const getMissingParameters = (
if (templateParameter.options.length === 0) { if (templateParameter.options.length === 0) {
continue; continue;
} }
// For multi-select, extra steps are necessary to JSON parse the value.
// Check if there is a new value if (templateParameter.form_type === "multi-select") {
continue;
}
let buildParameter = newBuildParameters.find( let buildParameter = newBuildParameters.find(
(p) => p.name === templateParameter.name, (p) => p.name === templateParameter.name,
); );
@@ -231,7 +236,7 @@ export const watchWorkspaceAgentLogs = (
/** /**
* WebSocket compression in Safari (confirmed in 16.5) is broken when * WebSocket compression in Safari (confirmed in 16.5) is broken when
* the server sends large messages. The following error is seen: * the server sends large messages. The following error is seen:
* WebSocket connection to 'wss://...' failed: The operation couldn’t be completed. * WebSocket connection to 'wss://...' failed: The operation couldn't be completed.
*/ */
if (userAgentParser(navigator.userAgent).browser.name === "Safari") { if (userAgentParser(navigator.userAgent).browser.name === "Safari") {
searchParams.set("no_compression", ""); searchParams.set("no_compression", "");
@@ -990,6 +995,17 @@ class ApiMethods {
return response.data; 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 ( getTemplateVersionRichParameters = async (
versionId: string, versionId: string,
): Promise<TypesGen.TemplateVersionParameter[]> => { ): Promise<TypesGen.TemplateVersionParameter[]> => {
@@ -2132,6 +2148,38 @@ class ApiMethods {
await this.axios.delete(`/api/v2/licenses/${licenseId}`); 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 /** Steps to change the workspace version
* - Get the latest template to access the latest active version * - Get the latest template to access the latest active version
* - Get the current build parameters * - Get the current build parameters
@@ -2145,11 +2193,23 @@ class ApiMethods {
workspace: TypesGen.Workspace, workspace: TypesGen.Workspace,
templateVersionId: string, templateVersionId: string,
newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [],
isDynamicParametersEnabled = false,
): Promise<TypesGen.WorkspaceBuild> => { ): Promise<TypesGen.WorkspaceBuild> => {
const [currentBuildParameters, templateParameters] = await Promise.all([ const currentBuildParameters = await this.getWorkspaceBuildParameters(
this.getWorkspaceBuildParameters(workspace.latest_build.id), workspace.latest_build.id,
this.getTemplateVersionRichParameters(templateVersionId), );
]);
let templateParameters: TypesGen.TemplateVersionParameter[] = [];
if (isDynamicParametersEnabled) {
templateParameters = await this.getDynamicParameters(
templateVersionId,
workspace.owner_id,
currentBuildParameters,
);
} else {
templateParameters =
await this.getTemplateVersionRichParameters(templateVersionId);
}
const missingParameters = getMissingParameters( const missingParameters = getMissingParameters(
currentBuildParameters, currentBuildParameters,
@@ -2180,6 +2240,7 @@ class ApiMethods {
updateWorkspace = async ( updateWorkspace = async (
workspace: TypesGen.Workspace, workspace: TypesGen.Workspace,
newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [],
isDynamicParametersEnabled = false,
): Promise<TypesGen.WorkspaceBuild> => { ): Promise<TypesGen.WorkspaceBuild> => {
const [template, oldBuildParameters] = await Promise.all([ const [template, oldBuildParameters] = await Promise.all([
this.getTemplate(workspace.template_id), this.getTemplate(workspace.template_id),
@@ -2187,8 +2248,19 @@ class ApiMethods {
]); ]);
const activeVersionId = template.active_version_id; 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( const missingParameters = getMissingParameters(
oldBuildParameters, oldBuildParameters,
+19 -3
View File
@@ -163,6 +163,7 @@ export const updateDeadline = (
export const changeVersion = ( export const changeVersion = (
workspace: Workspace, workspace: Workspace,
queryClient: QueryClient, queryClient: QueryClient,
isDynamicParametersEnabled: boolean,
) => { ) => {
return { return {
mutationFn: ({ mutationFn: ({
@@ -172,7 +173,12 @@ export const changeVersion = (
versionId: string; versionId: string;
buildParameters?: WorkspaceBuildParameter[]; buildParameters?: WorkspaceBuildParameter[];
}) => { }) => {
return API.changeWorkspaceVersion(workspace, versionId, buildParameters); return API.changeWorkspaceVersion(
workspace,
versionId,
buildParameters,
isDynamicParametersEnabled,
);
}, },
onSuccess: async (build: WorkspaceBuild) => { onSuccess: async (build: WorkspaceBuild) => {
await updateWorkspaceBuild(build, queryClient); await updateWorkspaceBuild(build, queryClient);
@@ -185,8 +191,18 @@ export const updateWorkspace = (
queryClient: QueryClient, queryClient: QueryClient,
) => { ) => {
return { return {
mutationFn: (buildParameters?: WorkspaceBuildParameter[]) => { mutationFn: ({
return API.updateWorkspace(workspace, buildParameters); buildParameters,
isDynamicParametersEnabled,
}: {
buildParameters?: WorkspaceBuildParameter[];
isDynamicParametersEnabled: boolean;
}) => {
return API.updateWorkspace(
workspace,
buildParameters,
isDynamicParametersEnabled,
);
}, },
onSuccess: async (build: WorkspaceBuild) => { onSuccess: async (build: WorkspaceBuild) => {
await updateWorkspaceBuild(build, queryClient); await updateWorkspaceBuild(build, queryClient);
+3 -3
View File
@@ -45,7 +45,7 @@ export const DialogContent = forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 border border-solid border-border bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%] translate-x-[-50%] translate-y-[-50%]
data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out
@@ -68,7 +68,7 @@ export const DialogHeader: FC<HTMLAttributes<HTMLDivElement>> = ({
}) => ( }) => (
<div <div
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -108,7 +108,7 @@ export const DialogDescription = forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-content-secondary", className)} className={cn("text-sm text-content-secondary font-medium", className)}
{...props} {...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, SettingsIcon,
TrashIcon, TrashIcon,
} from "lucide-react"; } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useDynamicParametersOptOut } from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog";
import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { DownloadLogsDialog } from "./DownloadLogsDialog";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { UpdateBuildParametersDialogExperimental } from "./UpdateBuildParametersDialogExperimental";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
@@ -40,6 +43,15 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
disabled, disabled,
}) => { }) => {
const queryClient = useQueryClient(); 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 // Permissions
const { data: permissions } = useQuery(workspacePermissions(workspace)); const { data: permissions } = useQuery(workspacePermissions(workspace));
@@ -50,7 +62,7 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
// Change version // Change version
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const changeVersionMutation = useMutation( const changeVersionMutation = useMutation(
changeVersion(workspace, queryClient), changeVersion(workspace, queryClient, optOutQuery.data?.optedOut === false),
); );
// Delete // Delete
@@ -142,25 +154,46 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
onClose={() => setIsDownloadDialogOpen(false)} onClose={() => setIsDownloadDialogOpen(false)}
/> />
<UpdateBuildParametersDialog {!isDynamicParametersEnabled || optOutQuery.data?.optedOut ? (
missedParameters={ <UpdateBuildParametersDialog
changeVersionMutation.error instanceof MissingBuildParameters missedParameters={
? changeVersionMutation.error.parameters 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,
});
} }
}} 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 <ChangeWorkspaceVersionDialog
workspace={workspace} workspace={workspace}
@@ -6,9 +6,14 @@ import type {
WorkspaceBuild, WorkspaceBuild,
WorkspaceBuildParameter, WorkspaceBuildParameter,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Loader } from "components/Loader/Loader";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown"; 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 { UpdateBuildParametersDialog } from "modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog";
import { UpdateBuildParametersDialogExperimental } from "modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
@@ -51,8 +56,21 @@ export const useWorkspaceUpdate = ({
setIsConfirmingUpdate(true); 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[] = []) => { const confirmUpdate = (buildParameters: WorkspaceBuildParameter[] = []) => {
updateWorkspaceMutation.mutate(buildParameters); updateWorkspaceMutation.mutate({
buildParameters,
isDynamicParametersEnabled: optOutQuery.data?.optedOut === false,
});
setIsConfirmingUpdate(false); setIsConfirmingUpdate(false);
}; };
@@ -67,6 +85,7 @@ export const useWorkspaceUpdate = ({
latestVersion, latestVersion,
}, },
missingBuildParameters: { missingBuildParameters: {
workspace,
error: updateWorkspaceMutation.error, error: updateWorkspaceMutation.error,
onClose: () => { onClose: () => {
updateWorkspaceMutation.reset(); updateWorkspaceMutation.reset();
@@ -134,22 +153,57 @@ const UpdateConfirmationDialog: FC<UpdateConfirmationDialogProps> = ({
}; };
type MissingBuildParametersDialogProps = { type MissingBuildParametersDialogProps = {
workspace: Workspace;
error: unknown; error: unknown;
onClose: () => void; onClose: () => void;
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void;
}; };
const MissingBuildParametersDialog: FC<MissingBuildParametersDialogProps> = ({ const MissingBuildParametersDialog: FC<MissingBuildParametersDialogProps> = ({
workspace,
error, error,
...dialogProps ...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 <UpdateBuildParametersDialog
missedParameters={ missedParameters={missedParameters}
error instanceof MissingBuildParameters ? error.parameters : [] open={isOpen}
}
open={error instanceof MissingBuildParameters}
{...dialogProps} {...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 { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard"; import { useDashboard } from "modules/dashboard/useDashboard";
import {
optOutKey,
useDynamicParametersOptOut,
} from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import type { FC } from "react"; import type { FC } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@@ -11,39 +15,26 @@ import { ExperimentalFormContext } from "./ExperimentalFormContext";
const CreateWorkspaceExperimentRouter: FC = () => { const CreateWorkspaceExperimentRouter: FC = () => {
const { experiments } = useDashboard(); const { experiments } = useDashboard();
const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const { organization: organizationName = "default", template: templateName } = const { organization: organizationName = "default", template: templateName } =
useParams() as { organization?: string; template: string }; useParams() as { organization?: string; template: string };
const templateQuery = useQuery({ const templateQuery = useQuery({
...templateByName(organizationName, templateName), ...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, 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) { if (optOutQuery.isError) {
return <ErrorAlert error={optOutQuery.error} />; return <ErrorAlert error={optOutQuery.error} />;
} }
@@ -77,5 +68,3 @@ const CreateWorkspaceExperimentRouter: FC = () => {
}; };
export default CreateWorkspaceExperimentRouter; 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 // Check if the update was called using the values from the form
await waitFor(() => { await waitFor(() => {
expect(API.updateWorkspace).toBeCalledWith(MockOutdatedWorkspace, [ expect(API.updateWorkspace).toBeCalledWith(
{ MockOutdatedWorkspace,
name: MockTemplateVersionParameter1.name, [
value: "some-value", {
}, name: MockTemplateVersionParameter1.name,
{ value: "some-value",
name: MockTemplateVersionParameter2.name, },
value: "2", {
}, name: MockTemplateVersionParameter2.name,
]); value: "2",
},
],
false,
);
}); });
}); });
@@ -1,8 +1,11 @@
import { ErrorAlert } from "components/Alert/ErrorAlert"; import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard"; import { useDashboard } from "modules/dashboard/useDashboard";
import {
optOutKey,
useDynamicParametersOptOut,
} from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut";
import type { FC } from "react"; import type { FC } from "react";
import { useQuery } from "react-query";
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import WorkspaceParametersPage from "./WorkspaceParametersPage"; import WorkspaceParametersPage from "./WorkspaceParametersPage";
@@ -11,45 +14,21 @@ import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperi
const WorkspaceParametersExperimentRouter: FC = () => { const WorkspaceParametersExperimentRouter: FC = () => {
const { experiments } = useDashboard(); const { experiments } = useDashboard();
const workspace = useWorkspaceSettings(); const workspace = useWorkspaceSettings();
const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const optOutQuery = useQuery({ const optOutQuery = useDynamicParametersOptOut({
enabled: dynamicParametersEnabled, templateId: workspace.template_id,
queryKey: [ templateUsesClassicParameters:
"workspace", workspace.template_use_classic_parameter_flow,
workspace.id, enabled: isDynamicParametersEnabled,
"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,
};
},
}); });
if (dynamicParametersEnabled) { if (isDynamicParametersEnabled) {
if (optOutQuery.isLoading) { if (optOutQuery.isError) {
return <Loader />; return <ErrorAlert error={optOutQuery.error} />;
} }
if (!optOutQuery.data) { if (!optOutQuery.data) {
return <ErrorAlert error={optOutQuery.error} />; return <Loader />;
} }
const toggleOptedOut = () => { const toggleOptedOut = () => {
@@ -79,5 +58,3 @@ const WorkspaceParametersExperimentRouter: FC = () => {
}; };
export default WorkspaceParametersExperimentRouter; 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 { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useMutation, useQuery } from "react-query"; 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 { docs } from "utils/docs";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters"; import type { AutofillBuildParameter } from "utils/richParameters";
@@ -40,6 +40,8 @@ const WorkspaceParametersPageExperimental: FC = () => {
const workspace = useWorkspaceSettings(); const workspace = useWorkspaceSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext); 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 // autofill the form with the workspace build parameters from the latest build
const { const {
@@ -107,10 +109,11 @@ const WorkspaceParametersPageExperimental: FC = () => {
}); });
useEffect(() => { useEffect(() => {
if (!workspace.latest_build.template_version_id) return; if (!templateVersionId && !workspace.latest_build.template_version_id)
return;
const socket = API.templateVersionDynamicParameters( const socket = API.templateVersionDynamicParameters(
workspace.latest_build.template_version_id, templateVersionId ?? workspace.latest_build.template_version_id,
{ {
onMessage, onMessage,
onError: (error) => { onError: (error) => {
@@ -136,12 +139,17 @@ const WorkspaceParametersPageExperimental: FC = () => {
return () => { return () => {
socket.close(); socket.close();
}; };
}, [workspace.latest_build.template_version_id, onMessage]); }, [
templateVersionId,
workspace.latest_build.template_version_id,
onMessage,
]);
const updateParameters = useMutation({ const updateParameters = useMutation({
mutationFn: (buildParameters: WorkspaceBuildParameter[]) => mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
API.postWorkspaceBuild(workspace.id, { API.postWorkspaceBuild(workspace.id, {
transition: "start", transition: "start",
template_version_id: templateVersionId,
rich_parameter_values: buildParameters, rich_parameter_values: buildParameters,
}), }),
onSuccess: () => { onSuccess: () => {
@@ -250,6 +258,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
{sortedParams.length > 0 ? ( {sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental <WorkspaceParametersPageViewExperimental
templateVersionId={templateVersionId}
workspace={workspace} workspace={workspace}
autofillParameters={autofillParameters} autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions} canChangeVersions={canChangeVersions}
@@ -5,6 +5,7 @@ import type {
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert"; import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button"; import { Button } from "components/Button/Button";
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 { useFormik } from "formik"; import { useFormik } from "formik";
@@ -30,6 +31,7 @@ type WorkspaceParametersPageViewExperimentalProps = {
rich_parameter_values: WorkspaceBuildParameter[]; rich_parameter_values: WorkspaceBuildParameter[];
}) => void; }) => void;
sendMessage: (formValues: Record<string, string>) => void; sendMessage: (formValues: Record<string, string>) => void;
templateVersionId: string | undefined;
}; };
export const WorkspaceParametersPageViewExperimental: FC< export const WorkspaceParametersPageViewExperimental: FC<
@@ -44,6 +46,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
onSubmit, onSubmit,
sendMessage, sendMessage,
onCancel, onCancel,
templateVersionId,
}) => { }) => {
const autofillByName = Object.fromEntries( const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]), autofillParameters.map((param) => [param.name, param]),
@@ -152,6 +155,15 @@ export const WorkspaceParametersPageViewExperimental: FC<
</div> </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"> <form onSubmit={form.handleSubmit} className="flex flex-col gap-8">
{standardParameters.length > 0 && ( {standardParameters.length > 0 && (
<section className="flex flex-col gap-9"> <section className="flex flex-col gap-9">
@@ -236,10 +248,21 @@ export const WorkspaceParametersPageViewExperimental: FC<
</Button> </Button>
<Button <Button
type="submit" 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} /> <Spinner loading={isSubmitting} />
Submit and restart Update and restart
</Button> </Button>
</div> </div>
</form> </form>
@@ -123,8 +123,8 @@ describe("WorkspacesPage", () => {
await waitFor(() => { await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2); expect(updateWorkspace).toHaveBeenCalledTimes(2);
}); });
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
}); });
it("warns about and updates running workspaces", async () => { it("warns about and updates running workspaces", async () => {
@@ -160,9 +160,9 @@ describe("WorkspacesPage", () => {
await waitFor(() => { await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3); expect(updateWorkspace).toHaveBeenCalledTimes(3);
}); });
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
}); });
it("warns about and ignores dormant workspaces", async () => { it("warns about and ignores dormant workspaces", async () => {
@@ -199,8 +199,8 @@ describe("WorkspacesPage", () => {
await waitFor(() => { await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2); expect(updateWorkspace).toHaveBeenCalledTimes(2);
}); });
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
}); });
it("warns about running workspaces and then dormant workspaces", async () => { it("warns about running workspaces and then dormant workspaces", async () => {
@@ -241,9 +241,9 @@ describe("WorkspacesPage", () => {
await waitFor(() => { await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3); expect(updateWorkspace).toHaveBeenCalledTimes(3);
}); });
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
}); });
}); });
@@ -37,6 +37,8 @@ function useSafeSearchParams() {
} }
const WorkspacesPage: FC = () => { const WorkspacesPage: FC = () => {
const { experiments } = useDashboard();
const isDynamicParametersEnabled = experiments.includes("dynamic-parameters");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// If we use a useSearchParams for each hook, the values will not be in sync. // 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 // So we have to use a single one, centralizing the values, and pass it to
@@ -162,7 +164,10 @@ const WorkspacesPage: FC = () => {
checkedWorkspaces={checkedWorkspaces} checkedWorkspaces={checkedWorkspaces}
open={confirmingBatchAction === "update"} open={confirmingBatchAction === "update"}
onConfirm={async () => { onConfirm={async () => {
await batchActions.updateAll(checkedWorkspaces); await batchActions.updateAll({
workspaces: checkedWorkspaces,
isDynamicParametersEnabled,
});
setConfirmingBatchAction(null); setConfirmingBatchAction(null);
}} }}
onClose={() => { onClose={() => {
@@ -45,11 +45,15 @@ export function useBatchActions(options: UseBatchActionsProps) {
}); });
const updateAllMutation = useMutation({ const updateAllMutation = useMutation({
mutationFn: (workspaces: readonly Workspace[]) => { mutationFn: (payload: {
workspaces: readonly Workspace[];
isDynamicParametersEnabled: boolean;
}) => {
const { workspaces, isDynamicParametersEnabled } = payload;
return Promise.all( return Promise.all(
workspaces workspaces
.filter((w) => w.outdated && !w.dormant_at) .filter((w) => w.outdated && !w.dormant_at)
.map((w) => API.updateWorkspace(w)), .map((w) => API.updateWorkspace(w, [], isDynamicParametersEnabled)),
); );
}, },
onSuccess, onSuccess,