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
Next Next commit
feat: add experimental workspace parameters page for dynamic params
  • Loading branch information
jaaydenh committed May 19, 2025
commit dd83a00de9c98392e5c320d7f3725e732330724c
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react";
import { useQuery } from "react-query";
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import WorkspaceParametersPage from "./WorkspaceParametersPage";
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";

const WorkspaceParametersExperimentRouter: FC = () => {
const { experiments } = useDashboard();
const workspace = useWorkspaceSettings();
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");

const optOutQuery = useQuery(
dynamicParametersEnabled
? {
queryKey: [
"workspace",
workspace.id,
"template_id",
workspace.template_id,
"optOut",
],
queryFn: () => ({
templateId: workspace.template_id,
workspaceId: workspace.id,
optedOut:
localStorage.getItem(optOutKey(workspace.template_id)) === "true",
}),
}
: { enabled: false },
);

if (dynamicParametersEnabled) {
if (optOutQuery.isLoading) {
return <Loader />;
}
if (!optOutQuery.data) {
return <ErrorAlert error={optOutQuery.error} />;
}

const toggleOptedOut = () => {
const key = optOutKey(optOutQuery.data.templateId);
const current = localStorage.getItem(key) === "true";
localStorage.setItem(key, (!current).toString());
optOutQuery.refetch();
};

return (
<ExperimentalFormContext.Provider value={{ toggleOptedOut }}>
{optOutQuery.data.optedOut ? (
<WorkspaceParametersPage />
) : (
<WorkspaceParametersPageExperimental />
)}
</ExperimentalFormContext.Provider>
);
}

return <WorkspaceParametersPage />;
};

export default WorkspaceParametersExperimentRouter;

const optOutKey = (id: string) => `parameters.${id}.optOut`;
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { isApiValidationError } from "api/errors";
import { checkAuthorization } from "api/queries/authCheck";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
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 } from "react";
import { type FC, useContext } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
Expand All @@ -18,6 +19,7 @@ import {
type WorkspacePermissions,
workspaceChecks,
} from "../../../modules/workspaces/permissions";
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import {
WorkspaceParametersForm,
Expand Down Expand Up @@ -112,9 +114,23 @@ export const WorkspaceParametersPageView: FC<
isSubmitting,
onCancel,
}) => {
const experimentalFormContext = useContext(ExperimentalFormContext);
return (
<>
<PageHeader css={{ paddingTop: 0 }}>
<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>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { API } from "api/api";
import { DetailedError } from "api/errors";
import { checkAuthorization } from "api/queries/authCheck";
import type {
DynamicParametersRequest,
DynamicParametersResponse,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
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 type { FC } from "react";
import {
useCallback,
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 { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import {
type WorkspacePermissions,
workspaceChecks,
} from "../../../modules/workspaces/permissions";
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental";

const WorkspaceParametersPageExperimental: FC = () => {
const workspace = useWorkspaceSettings();
const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext);

const [currentResponse, setCurrentResponse] =
useState<DynamicParametersResponse | null>(null);
const [wsResponseId, setWSResponseId] = useState<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;
});
}, []);

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

const socket = API.templateVersionDynamicParameters(
workspace.owner_id,
workspace.latest_build.template_version_id,
{
onMessage,
onError: (error) => {
setWsError(error);
},
onClose: () => {
if (ws.current === socket) {
setWsError(
new DetailedError(
"Websocket connection for dynamic parameters unexpectedly closed.",
"Refresh the page to reset the form.",
),
);
}
},
},
);

ws.current = socket;

return () => {
socket.close();
};
}, [
workspace.owner_id,
workspace.latest_build.template_version_id,
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, {
transition: "start",
rich_parameter_values: buildParameters,
}),
onSuccess: () => {
navigate(`/@${workspace.owner_name}/${workspace.name}`);
},
});

const checks = workspace ? workspaceChecks(workspace) : {};
const permissionsQuery = useQuery({
...checkAuthorization({ checks }),
enabled: workspace !== undefined,
});
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion);

const handleSubmit = (values: {
rich_parameter_values: WorkspaceBuildParameter[];
}) => {
if (!currentResponse || !currentResponse.parameters) {
return;
}

// Only submit mutable parameters
const onlyMutableValues = currentResponse.parameters
.filter((p) => p.mutable)
.map((p) => {
const value = values.rich_parameter_values.find(
(v) => v.name === p.name,
);
if (!value) {
throw new Error(`Missing value for parameter ${p.name}`);
}
return value;
});

updateParameters.mutate(onlyMutableValues);
};

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

const error = wsError || updateParameters.error;

if (
!currentResponse ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
return <Loader />;
}

return (
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<Helmet>
<title>{pageTitle(workspace.name, "Parameters")}</title>
</Helmet>

<header className="flex flex-col items-start gap-2">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<FeatureStageBadge contentType={"beta"} />
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Go back to the classic workspace parameters view
</Button>
)}
</header>

{Boolean(error) && <ErrorAlert error={error} />}

{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
workspace={workspace}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={currentResponse.diagnostics}
isSubmitting={updateParameters.isLoading}
onSubmit={handleSubmit}
onCancel={() =>
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
sendMessage={sendMessage}
/>
) : (
<EmptyState
className="border border-border border-solid rounded-md"
message="This workspace has no parameters"
cta={
<Link
href={docs("/admin/templates/extending-templates/parameters")}
>
Learn more about parameters
</Link>
}
/>
)}
</div>
);
};

export default WorkspaceParametersPageExperimental;
Loading