Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec979f1aea |
@@ -0,0 +1,270 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type {
|
||||
PreviewParameter,
|
||||
WorkspaceBuildParameter,
|
||||
} from "api/typesGenerated";
|
||||
import type { RefObject } from "react";
|
||||
import { useSyncFormParameters } from "./useSyncFormParameters";
|
||||
|
||||
/**
|
||||
* Creates a minimal PreviewParameter with the given name and value.
|
||||
* Other required fields are filled with sensible defaults.
|
||||
*/
|
||||
function makeParam(
|
||||
name: string,
|
||||
value: string,
|
||||
valid: boolean,
|
||||
): PreviewParameter {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: "",
|
||||
type: "string",
|
||||
form_type: "input",
|
||||
styling: {},
|
||||
mutable: true,
|
||||
default_value: { value: "", valid: true },
|
||||
icon: "",
|
||||
options: [],
|
||||
validations: [],
|
||||
required: false,
|
||||
order: 0,
|
||||
ephemeral: false,
|
||||
value: { value, valid },
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
parameters: readonly PreviewParameter[];
|
||||
formValues: readonly WorkspaceBuildParameter[];
|
||||
setFieldValue: (field: string, value: WorkspaceBuildParameter[]) => void;
|
||||
lastSentValues?: RefObject<Map<string, string>>;
|
||||
};
|
||||
|
||||
function renderSyncHook(initialProps: Props) {
|
||||
return renderHook((props: Props) => useSyncFormParameters(props), {
|
||||
initialProps,
|
||||
});
|
||||
}
|
||||
|
||||
describe("useSyncFormParameters", () => {
|
||||
it("updates form values when server returns new valid values", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const parameters = [
|
||||
makeParam("region", "us-east-1", true),
|
||||
makeParam("size", "large", true),
|
||||
];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "region", value: "us-west-2" },
|
||||
{ name: "size", value: "small" },
|
||||
];
|
||||
|
||||
renderSyncHook({ parameters, formValues, setFieldValue });
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledTimes(1);
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
{ name: "region", value: "us-east-1" },
|
||||
{ name: "size", value: "large" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves existing form value when param.value.valid is false", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const parameters = [makeParam("region", "", false)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "region", value: "us-west-2" },
|
||||
];
|
||||
|
||||
renderSyncHook({ parameters, formValues, setFieldValue });
|
||||
|
||||
// The form value should stay at "us-west-2" since the server
|
||||
// value is invalid. Because the form already holds "us-west-2",
|
||||
// the hook should not call setFieldValue (no change detected).
|
||||
expect(setFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses empty string for invalid value when no existing form value exists", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const parameters = [makeParam("new_param", "", false)];
|
||||
// The form has no entry for "new_param" yet.
|
||||
const formValues: WorkspaceBuildParameter[] = [];
|
||||
|
||||
renderSyncHook({ parameters, formValues, setFieldValue });
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
{ name: "new_param", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves form value when server echoes back stale sent value", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
|
||||
// The user typed "hello w" and the server echoes "hello"
|
||||
// (the value we previously sent). The form has already
|
||||
// moved on to "hello w", so the hook should keep it.
|
||||
const lastSentValues: RefObject<Map<string, string>> = {
|
||||
current: new Map([["greeting", "hello"]]),
|
||||
};
|
||||
const parameters = [makeParam("greeting", "hello", true)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "greeting", value: "hello w" },
|
||||
];
|
||||
|
||||
renderSyncHook({
|
||||
parameters,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
lastSentValues,
|
||||
});
|
||||
|
||||
// The form already holds the desired value "hello w", so
|
||||
// setFieldValue should not be called (no change).
|
||||
expect(setFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies server transformation even when form has diverged", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
|
||||
// The server returned "HELLO" (a transformation of the
|
||||
// sent value "hello"). Even though the form has "hello w",
|
||||
// the server value differs from what we sent, so it should
|
||||
// be applied.
|
||||
const lastSentValues: RefObject<Map<string, string>> = {
|
||||
current: new Map([["greeting", "hello"]]),
|
||||
};
|
||||
const parameters = [makeParam("greeting", "HELLO", true)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "greeting", value: "hello w" },
|
||||
];
|
||||
|
||||
renderSyncHook({
|
||||
parameters,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
lastSentValues,
|
||||
});
|
||||
|
||||
// The server transformed the value, so the hook applies it.
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
{ name: "greeting", value: "HELLO" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies server value when form matches lastSentValues (no user divergence)", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
|
||||
// The user has not typed anything new since we sent "hello",
|
||||
// so the server echo should be applied as-is.
|
||||
const lastSentValues: RefObject<Map<string, string>> = {
|
||||
current: new Map([["greeting", "hello"]]),
|
||||
};
|
||||
const parameters = [makeParam("greeting", "hello", true)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "greeting", value: "hello" },
|
||||
];
|
||||
|
||||
renderSyncHook({
|
||||
parameters,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
lastSentValues,
|
||||
});
|
||||
|
||||
// No change because form already matches the server value.
|
||||
expect(setFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behaves identically to basic sync when lastSentValues is not provided", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const parameters = [makeParam("region", "us-east-1", true)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "region", value: "us-west-2" },
|
||||
];
|
||||
|
||||
// No lastSentValues provided — backward-compatible path.
|
||||
renderSyncHook({ parameters, formValues, setFieldValue });
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
{ name: "region", value: "us-east-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call setFieldValue when form values already match parameters", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const parameters = [makeParam("region", "us-east-1", true)];
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "region", value: "us-east-1" },
|
||||
];
|
||||
|
||||
renderSyncHook({ parameters, formValues, setFieldValue });
|
||||
|
||||
expect(setFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-syncs when parameters change across renders", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "region", value: "us-west-2" },
|
||||
];
|
||||
const initialParams = [makeParam("region", "us-west-2", true)];
|
||||
|
||||
const { rerender } = renderSyncHook({
|
||||
parameters: initialParams,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
});
|
||||
|
||||
// Initial render: no change because form matches.
|
||||
expect(setFieldValue).not.toHaveBeenCalled();
|
||||
|
||||
// Server pushes a new parameter value.
|
||||
const updatedParams = [makeParam("region", "eu-west-1", true)];
|
||||
rerender({
|
||||
parameters: updatedParams,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
});
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
{ name: "region", value: "eu-west-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles multiple parameters with mixed stale and fresh responses", () => {
|
||||
const setFieldValue = vi.fn();
|
||||
|
||||
const lastSentValues: RefObject<Map<string, string>> = {
|
||||
current: new Map([
|
||||
["greeting", "hello"],
|
||||
["farewell", "bye"],
|
||||
]),
|
||||
};
|
||||
|
||||
const parameters = [
|
||||
// Stale: server echoes "hello" but form has "hello w".
|
||||
makeParam("greeting", "hello", true),
|
||||
// Fresh transformation: server returns "GOODBYE" (not "bye").
|
||||
makeParam("farewell", "GOODBYE", true),
|
||||
];
|
||||
|
||||
const formValues: WorkspaceBuildParameter[] = [
|
||||
{ name: "greeting", value: "hello w" },
|
||||
{ name: "farewell", value: "bye" },
|
||||
];
|
||||
|
||||
renderSyncHook({
|
||||
parameters,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
lastSentValues,
|
||||
});
|
||||
|
||||
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
|
||||
// Stale response: form value preserved.
|
||||
{ name: "greeting", value: "hello w" },
|
||||
// Server transformation: applied.
|
||||
{ name: "farewell", value: "GOODBYE" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { PreviewParameter } from "api/typesGenerated";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
|
||||
type UseSyncFormParametersProps = {
|
||||
parameters: readonly PreviewParameter[];
|
||||
@@ -9,12 +9,19 @@ type UseSyncFormParametersProps = {
|
||||
field: string,
|
||||
value: TypesGen.WorkspaceBuildParameter[],
|
||||
) => void;
|
||||
// A ref holding the most recent parameter values sent to the
|
||||
// WebSocket. Used to detect stale responses: when the server
|
||||
// echoes back the same value we sent but the form has already
|
||||
// moved on (the user kept typing), we preserve the form value
|
||||
// instead of overwriting it.
|
||||
lastSentValues?: RefObject<Map<string, string>>;
|
||||
};
|
||||
|
||||
export function useSyncFormParameters({
|
||||
parameters,
|
||||
formValues,
|
||||
setFieldValue,
|
||||
lastSentValues,
|
||||
}: UseSyncFormParametersProps) {
|
||||
// Form values only needs to be updated when parameters change
|
||||
// Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values
|
||||
@@ -22,6 +29,7 @@ export function useSyncFormParameters({
|
||||
|
||||
formValuesRef.current = formValues;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: lastSentValues is a stable ref whose .current is read lazily inside the effect.
|
||||
useEffect(() => {
|
||||
if (!parameters) return;
|
||||
const currentFormValues = formValuesRef.current;
|
||||
@@ -43,6 +51,24 @@ export function useSyncFormParameters({
|
||||
}
|
||||
}
|
||||
|
||||
// Detect stale WebSocket responses. If the server
|
||||
// returned the exact value we last sent but the form
|
||||
// already holds something newer (the user kept typing
|
||||
// after the request was fired), preserve the form value
|
||||
// to avoid overwriting in-progress input.
|
||||
if (param.value.valid && lastSentValues?.current) {
|
||||
const sentValue = lastSentValues.current.get(param.name);
|
||||
const formValue = currentFormValuesMap.get(param.name);
|
||||
if (
|
||||
sentValue !== undefined &&
|
||||
param.value.value === sentValue &&
|
||||
formValue !== undefined &&
|
||||
formValue !== sentValue
|
||||
) {
|
||||
return { name: param.name, value: formValue };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: param.name,
|
||||
value: param.value.valid ? param.value.value : "",
|
||||
|
||||
@@ -114,6 +114,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
const [showPresetParameters, setShowPresetParameters] = useState(false);
|
||||
const id = useId();
|
||||
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastSentValuesRef = useRef<Map<string, string>>(new Map());
|
||||
const rerollSuggestedName = useCallback(() => {
|
||||
setSuggestedName(() => generateWorkspaceName());
|
||||
}, []);
|
||||
@@ -236,6 +237,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
lastSentValuesRef.current = new Map(Object.entries(formInputs));
|
||||
sendMessage(formInputs, ownerId);
|
||||
},
|
||||
500,
|
||||
@@ -346,6 +348,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
parameters,
|
||||
formValues: form.values.rich_parameter_values ?? [],
|
||||
setFieldValue: form.setFieldValue,
|
||||
lastSentValues: lastSentValuesRef,
|
||||
});
|
||||
|
||||
const disabled =
|
||||
|
||||
+5
-1
@@ -16,7 +16,7 @@ import {
|
||||
getInitialParameterValues,
|
||||
useValidationSchemaForDynamicParameters,
|
||||
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
@@ -69,6 +69,8 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
workspace.template_require_active_version &&
|
||||
!canChangeVersions;
|
||||
|
||||
const lastSentValuesRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Debounce websocket sends to avoid stale responses overwriting
|
||||
// the form while the user is still typing.
|
||||
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
|
||||
@@ -81,6 +83,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
}
|
||||
}
|
||||
formInputs[parameter.name] = value;
|
||||
lastSentValuesRef.current = new Map(Object.entries(formInputs));
|
||||
sendMessage(formInputs);
|
||||
},
|
||||
500,
|
||||
@@ -102,6 +105,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
parameters,
|
||||
formValues: form.values.rich_parameter_values ?? [],
|
||||
setFieldValue: form.setFieldValue,
|
||||
lastSentValues: lastSentValuesRef,
|
||||
});
|
||||
|
||||
// True when the form holds values the backend hasn't evaluated
|
||||
|
||||
Reference in New Issue
Block a user