Skip to content

Commit c3911dc

Browse files
committed
feat: add experimental workspace parameters page for dynamic params
1 parent c718392 commit c3911dc

File tree

5 files changed

+536
-5
lines changed

5 files changed

+536
-5
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ErrorAlert } from "components/Alert/ErrorAlert";
2+
import { Loader } from "components/Loader/Loader";
3+
import { useDashboard } from "modules/dashboard/useDashboard";
4+
import type { FC } from "react";
5+
import { useQuery } from "react-query";
6+
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
7+
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
8+
import WorkspaceParametersPage from "./WorkspaceParametersPage";
9+
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";
10+
11+
const WorkspaceParametersExperimentRouter: FC = () => {
12+
const { experiments } = useDashboard();
13+
const workspace = useWorkspaceSettings();
14+
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");
15+
16+
const optOutQuery = useQuery(
17+
dynamicParametersEnabled
18+
? {
19+
queryKey: [
20+
"workspace",
21+
workspace.id,
22+
"template_id",
23+
workspace.template_id,
24+
"optOut",
25+
],
26+
queryFn: () => ({
27+
templateId: workspace.template_id,
28+
workspaceId: workspace.id,
29+
optedOut:
30+
localStorage.getItem(optOutKey(workspace.template_id)) === "true",
31+
}),
32+
}
33+
: { enabled: false },
34+
);
35+
36+
if (dynamicParametersEnabled) {
37+
if (optOutQuery.isLoading) {
38+
return <Loader />;
39+
}
40+
if (!optOutQuery.data) {
41+
return <ErrorAlert error={optOutQuery.error} />;
42+
}
43+
44+
const toggleOptedOut = () => {
45+
const key = optOutKey(optOutQuery.data.templateId);
46+
const current = localStorage.getItem(key) === "true";
47+
localStorage.setItem(key, (!current).toString());
48+
optOutQuery.refetch();
49+
};
50+
51+
return (
52+
<ExperimentalFormContext.Provider value={{ toggleOptedOut }}>
53+
{optOutQuery.data.optedOut ? (
54+
<WorkspaceParametersPage />
55+
) : (
56+
<WorkspaceParametersPageExperimental />
57+
)}
58+
</ExperimentalFormContext.Provider>
59+
);
60+
}
61+
62+
return <WorkspaceParametersPage />;
63+
};
64+
65+
export default WorkspaceParametersExperimentRouter;
66+
67+
const optOutKey = (id: string) => `parameters.${id}.optOut`;

site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { isApiValidationError } from "api/errors";
44
import { checkAuthorization } from "api/queries/authCheck";
55
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
66
import { ErrorAlert } from "components/Alert/ErrorAlert";
7+
import { Button as ShadcnButton } from "components/Button/Button";
78
import { EmptyState } from "components/EmptyState/EmptyState";
89
import { Loader } from "components/Loader/Loader";
910
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
1011
import { ExternalLinkIcon } from "lucide-react";
11-
import type { FC } from "react";
12+
import { type FC, useContext } from "react";
1213
import { Helmet } from "react-helmet-async";
1314
import { useMutation, useQuery } from "react-query";
1415
import { useNavigate } from "react-router-dom";
@@ -18,6 +19,7 @@ import {
1819
type WorkspacePermissions,
1920
workspaceChecks,
2021
} from "../../../modules/workspaces/permissions";
22+
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
2123
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
2224
import {
2325
WorkspaceParametersForm,
@@ -112,9 +114,23 @@ export const WorkspaceParametersPageView: FC<
112114
isSubmitting,
113115
onCancel,
114116
}) => {
117+
const experimentalFormContext = useContext(ExperimentalFormContext);
115118
return (
116119
<>
117-
<PageHeader css={{ paddingTop: 0 }}>
120+
<PageHeader
121+
css={{ paddingTop: 0 }}
122+
actions={
123+
experimentalFormContext && (
124+
<ShadcnButton
125+
size="sm"
126+
variant="outline"
127+
onClick={experimentalFormContext.toggleOptedOut}
128+
>
129+
Try out the new workspace parameters ✨
130+
</ShadcnButton>
131+
)
132+
}
133+
>
118134
<PageHeaderTitle>Workspace parameters</PageHeaderTitle>
119135
</PageHeader>
120136

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { API } from "api/api";
2+
import { DetailedError } from "api/errors";
3+
import { checkAuthorization } from "api/queries/authCheck";
4+
import type {
5+
DynamicParametersRequest,
6+
DynamicParametersResponse,
7+
WorkspaceBuildParameter,
8+
} from "api/typesGenerated";
9+
import { ErrorAlert } from "components/Alert/ErrorAlert";
10+
import { Button } from "components/Button/Button";
11+
import { EmptyState } from "components/EmptyState/EmptyState";
12+
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
13+
import { Link } from "components/Link/Link";
14+
import { Loader } from "components/Loader/Loader";
15+
import type { FC } from "react";
16+
import {
17+
useCallback,
18+
useContext,
19+
useEffect,
20+
useMemo,
21+
useRef,
22+
useState,
23+
} from "react";
24+
import { Helmet } from "react-helmet-async";
25+
import { useMutation, useQuery } from "react-query";
26+
import { useNavigate } from "react-router-dom";
27+
import { docs } from "utils/docs";
28+
import { pageTitle } from "utils/page";
29+
import {
30+
type WorkspacePermissions,
31+
workspaceChecks,
32+
} from "../../../modules/workspaces/permissions";
33+
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
34+
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
35+
import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental";
36+
37+
const WorkspaceParametersPageExperimental: FC = () => {
38+
const workspace = useWorkspaceSettings();
39+
const navigate = useNavigate();
40+
const experimentalFormContext = useContext(ExperimentalFormContext);
41+
42+
const [currentResponse, setCurrentResponse] =
43+
useState<DynamicParametersResponse | null>(null);
44+
const [wsResponseId, setWSResponseId] = useState<number>(-1);
45+
const ws = useRef<WebSocket | null>(null);
46+
const [wsError, setWsError] = useState<Error | null>(null);
47+
48+
const onMessage = useCallback((response: DynamicParametersResponse) => {
49+
setCurrentResponse((prev) => {
50+
if (prev?.id === response.id) {
51+
return prev;
52+
}
53+
return response;
54+
});
55+
}, []);
56+
57+
useEffect(() => {
58+
if (!workspace.latest_build.template_version_id) return;
59+
60+
const socket = API.templateVersionDynamicParameters(
61+
workspace.owner_id,
62+
workspace.latest_build.template_version_id,
63+
{
64+
onMessage,
65+
onError: (error) => {
66+
setWsError(error);
67+
},
68+
onClose: () => {
69+
if (ws.current === socket) {
70+
setWsError(
71+
new DetailedError(
72+
"Websocket connection for dynamic parameters unexpectedly closed.",
73+
"Refresh the page to reset the form.",
74+
),
75+
);
76+
}
77+
},
78+
},
79+
);
80+
81+
ws.current = socket;
82+
83+
return () => {
84+
socket.close();
85+
};
86+
}, [
87+
workspace.owner_id,
88+
workspace.latest_build.template_version_id,
89+
onMessage,
90+
]);
91+
92+
const sendMessage = useCallback((formValues: Record<string, string>) => {
93+
setWSResponseId((prevId) => {
94+
const request: DynamicParametersRequest = {
95+
id: prevId + 1,
96+
inputs: formValues,
97+
};
98+
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
99+
ws.current.send(JSON.stringify(request));
100+
return prevId + 1;
101+
}
102+
return prevId;
103+
});
104+
}, []);
105+
106+
const updateParameters = useMutation({
107+
mutationFn: (buildParameters: WorkspaceBuildParameter[]) =>
108+
API.postWorkspaceBuild(workspace.id, {
109+
transition: "start",
110+
rich_parameter_values: buildParameters,
111+
}),
112+
onSuccess: () => {
113+
navigate(`/@${workspace.owner_name}/${workspace.name}`);
114+
},
115+
});
116+
117+
const checks = workspace ? workspaceChecks(workspace) : {};
118+
const permissionsQuery = useQuery({
119+
...checkAuthorization({ checks }),
120+
enabled: workspace !== undefined,
121+
});
122+
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
123+
const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion);
124+
125+
const handleSubmit = (values: {
126+
rich_parameter_values: WorkspaceBuildParameter[];
127+
}) => {
128+
if (!currentResponse || !currentResponse.parameters) {
129+
return;
130+
}
131+
132+
// Only submit mutable parameters
133+
const onlyMutableValues = currentResponse.parameters
134+
.filter((p) => p.mutable)
135+
.map((p) => {
136+
const value = values.rich_parameter_values.find(
137+
(v) => v.name === p.name,
138+
);
139+
if (!value) {
140+
throw new Error(`Missing value for parameter ${p.name}`);
141+
}
142+
return value;
143+
});
144+
145+
updateParameters.mutate(onlyMutableValues);
146+
};
147+
148+
const sortedParams = useMemo(() => {
149+
if (!currentResponse?.parameters) {
150+
return [];
151+
}
152+
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
153+
}, [currentResponse?.parameters]);
154+
155+
const error = wsError || updateParameters.error;
156+
157+
if (
158+
!currentResponse ||
159+
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
160+
) {
161+
return <Loader />;
162+
}
163+
164+
return (
165+
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
166+
<Helmet>
167+
<title>{pageTitle(workspace.name, "Parameters")}</title>
168+
</Helmet>
169+
170+
<header className="flex flex-col items-start gap-2">
171+
<span className="flex flex-row items-center gap-2">
172+
<h1 className="text-3xl m-0">Workspace parameters</h1>
173+
<FeatureStageBadge contentType={"beta"} />
174+
</span>
175+
{experimentalFormContext && (
176+
<Button
177+
size="sm"
178+
variant="outline"
179+
onClick={experimentalFormContext.toggleOptedOut}
180+
>
181+
Go back to the classic workspace parameters view
182+
</Button>
183+
)}
184+
</header>
185+
186+
{Boolean(error) && <ErrorAlert error={error} />}
187+
188+
{sortedParams.length > 0 ? (
189+
<WorkspaceParametersPageViewExperimental
190+
workspace={workspace}
191+
canChangeVersions={canChangeVersions}
192+
parameters={sortedParams}
193+
diagnostics={currentResponse.diagnostics}
194+
isSubmitting={updateParameters.isLoading}
195+
onSubmit={handleSubmit}
196+
onCancel={() =>
197+
navigate(`/@${workspace.owner_name}/${workspace.name}`)
198+
}
199+
sendMessage={sendMessage}
200+
/>
201+
) : (
202+
<EmptyState
203+
className="border border-border border-solid rounded-md"
204+
message="This workspace has no parameters"
205+
cta={
206+
<Link
207+
href={docs("/admin/templates/extending-templates/parameters")}
208+
>
209+
Learn more about parameters
210+
</Link>
211+
}
212+
/>
213+
)}
214+
</div>
215+
);
216+
};
217+
218+
export default WorkspaceParametersPageExperimental;

0 commit comments

Comments
 (0)