Skip to content

feat: add experimental workspace parameters page for dynamic params #17841

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
chore: get parity with create workspace page
  • Loading branch information
jaaydenh committed May 19, 2025
commit 0c7f4c14f411900291e70ad4a7becf942c4d50a7
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,28 @@ const WorkspaceParametersExperimentRouter: FC = () => {
workspace.template_id,
"optOut",
],
queryFn: () => ({
templateId: workspace.template_id,
workspaceId: workspace.id,
optedOut:
localStorage.getItem(optOutKey(workspace.template_id)) === "true",
}),
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,
};
},
}
: { enabled: false },
);
Expand All @@ -43,7 +59,12 @@ const WorkspaceParametersExperimentRouter: FC = () => {

const toggleOptedOut = () => {
const key = optOutKey(optOutQuery.data.templateId);
const current = localStorage.getItem(key) === "true";
const storedValue = localStorage.getItem(key);

const current = storedValue
? storedValue === "true"
: Boolean(workspace.template_use_classic_parameter_flow);

localStorage.setItem(key, (!current).toString());
optOutQuery.refetch();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button as ShadcnButton } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Loader } from "components/Loader/Loader";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { ExternalLinkIcon } from "lucide-react";
import { type FC, useContext } from "react";
import { Helmet } from "react-helmet-async";
Expand Down Expand Up @@ -116,27 +115,25 @@ export const WorkspaceParametersPageView: FC<
}) => {
const experimentalFormContext = useContext(ExperimentalFormContext);
return (
<>
<PageHeader
css={{ paddingTop: 0 }}
actions={
experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters ✨
</ShadcnButton>
)
}
>
<PageHeaderTitle>Workspace parameters</PageHeaderTitle>
</PageHeader>
<div className="flex flex-col gap-10">
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row justify-between items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
</span>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters ✨
</ShadcnButton>
)}
</header>

{submitError && !isApiValidationError(submitError) && (
{submitError && !isApiValidationError(submitError) ? (
<ErrorAlert error={submitError} css={{ marginBottom: 48 }} />
)}
) : null}

{data ? (
data.templateVersionRichParameters.length > 0 ? (
Expand Down Expand Up @@ -177,7 +174,7 @@ export const WorkspaceParametersPageView: FC<
) : (
<Loader />
)}
</>
</div>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { EmptyState } from "components/EmptyState/EmptyState";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import { useEffectEvent } from "hooks/hookPolyfills";
import type { FC } from "react";
import {
useCallback,
Expand Down Expand Up @@ -39,21 +40,31 @@ const WorkspaceParametersPageExperimental: FC = () => {
const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext);

const [currentResponse, setCurrentResponse] =
const [latestResponse, setLatestResponse] =
useState<DynamicParametersResponse | null>(null);
const [wsResponseId, setWSResponseId] = useState<number>(-1);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);

const onMessage = useCallback((response: DynamicParametersResponse) => {
setCurrentResponse((prev) => {
if (prev?.id === response.id) {
return prev;
}
return response;
});
const sendMessage = useCallback((formValues: Record<string, string>) => {
const request: DynamicParametersRequest = {
id: wsResponseId.current + 1,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
wsResponseId.current = wsResponseId.current + 1;
}
}, []);

const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}

setLatestResponse(response);
});

useEffect(() => {
if (!workspace.latest_build.template_version_id) return;

Expand All @@ -63,7 +74,9 @@ const WorkspaceParametersPageExperimental: FC = () => {
{
onMessage,
onError: (error) => {
setWsError(error);
if (ws.current === socket) {
setWsError(error);
}
},
onClose: () => {
if (ws.current === socket) {
Expand All @@ -89,20 +102,6 @@ const WorkspaceParametersPageExperimental: FC = () => {
onMessage,
]);

const sendMessage = useCallback((formValues: Record<string, string>) => {
setWSResponseId((prevId) => {
const request: DynamicParametersRequest = {
id: prevId + 1,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
return prevId + 1;
}
return prevId;
});
}, []);

const updateParameters = useMutation({
mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
API.postWorkspaceBuild(workspace.id, {
Expand All @@ -125,12 +124,12 @@ const WorkspaceParametersPageExperimental: FC = () => {
const handleSubmit = (values: {
rich_parameter_values: WorkspaceBuildParameter[];
}) => {
if (!currentResponse || !currentResponse.parameters) {
if (!latestResponse || !latestResponse.parameters) {
return;
}

// Only submit mutable parameters
const onlyMutableValues = currentResponse.parameters
const onlyMutableValues = latestResponse.parameters
.filter((p) => p.mutable)
.map((p) => {
const value = values.rich_parameter_values.find(
Expand All @@ -146,16 +145,16 @@ const WorkspaceParametersPageExperimental: FC = () => {
};

const sortedParams = useMemo(() => {
if (!currentResponse?.parameters) {
if (!latestResponse?.parameters) {
return [];
}
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
}, [currentResponse?.parameters]);
return [...latestResponse.parameters].sort((a, b) => a.order - b.order);
}, [latestResponse?.parameters]);

const error = wsError || updateParameters.error;

if (
!currentResponse ||
!latestResponse ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
return <Loader />;
Expand Down Expand Up @@ -190,7 +189,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
workspace={workspace}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={currentResponse.diagnostics}
diagnostics={latestResponse.diagnostics}
isSubmitting={updateParameters.isLoading}
onSubmit={handleSubmit}
onCancel={() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type * as TypesGen from "api/typesGenerated";
import type {
PreviewParameter,
Workspace,
Expand All @@ -7,14 +8,13 @@ import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { useDebouncedFunction } from "hooks/debounce";
import {
DynamicParameter,
getInitialParameterValues,
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";

import { useEffect, useRef } from "react";
export type WorkspaceParametersPageViewExperimentalProps = {
workspace: Workspace;
parameters: PreviewParameter[];
Expand Down Expand Up @@ -60,37 +60,17 @@ export const WorkspaceParametersPageViewExperimental: FC<
workspace.template_require_active_version &&
!canChangeVersions;

const { debounced: handleChangeDebounced } = useDebouncedFunction(
async (
parameter: PreviewParameter,
parameterField: string,
value: string,
) => {
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
},
500,
);

const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
value: string,
) => {
if (parameter.form_type === "input" || parameter.form_type === "textarea") {
handleChangeDebounced(parameter, parameterField, value);
} else {
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
}
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
};

// Send the changed parameter and all touched parameters to the websocket
Expand All @@ -114,6 +94,12 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendMessage(formInputs);
};

useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});

return (
<>
{disabled && (
Expand Down Expand Up @@ -171,6 +157,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
autofill={false}
disabled={isDisabled}
/>
);
Expand Down Expand Up @@ -201,6 +188,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
autofill={false}
disabled={isDisabled}
/>
);
Expand All @@ -225,3 +213,52 @@ export const WorkspaceParametersPageViewExperimental: FC<
</>
);
};

type UseSyncFormParametersProps = {
parameters: readonly PreviewParameter[];
formValues: readonly TypesGen.WorkspaceBuildParameter[];
setFieldValue: (
field: string,
value: TypesGen.WorkspaceBuildParameter[],
) => void;
};

function useSyncFormParameters({
parameters,
formValues,
setFieldValue,
}: 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
const formValuesRef = useRef(formValues);

useEffect(() => {
formValuesRef.current = formValues;
}, [formValues]);

useEffect(() => {
if (!parameters) return;
const currentFormValues = formValuesRef.current;

const newParameterValues = parameters.map((param) => {
return {
name: param.name,
value: param.value.valid ? param.value.value : "",
};
});

const isChanged =
currentFormValues.length !== newParameterValues.length ||
newParameterValues.some(
(p) =>
!currentFormValues.find(
(formValue) =>
formValue.name === p.name && formValue.value === p.value,
),
);

if (isChanged) {
setFieldValue("rich_parameter_values", newParameterValues);
}
}, [parameters, setFieldValue]);
}
Loading