Skip to content

Commit e1b7a3f

Browse files
committed
wip: session launch link modal
1 parent b7fdf5a commit e1b7a3f

3 files changed

Lines changed: 459 additions & 11 deletions

File tree

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
/*!
2+
* Copyright 2025 - Swiss Data Science Center (SDSC)
3+
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
4+
* Eidgenössische Technische Hochschule Zürich (ETHZ).
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import cx from "classnames";
20+
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
21+
import { Link45deg, Plus, XLg } from "react-bootstrap-icons";
22+
import {
23+
Controller,
24+
useFieldArray,
25+
useForm,
26+
type Control,
27+
type FieldErrors,
28+
type UseFieldArrayRemove,
29+
type UseFormRegister,
30+
} from "react-hook-form";
31+
import {
32+
Button,
33+
Form,
34+
Modal,
35+
ModalBody,
36+
ModalFooter,
37+
ModalHeader,
38+
} from "reactstrap";
39+
import { generatePath } from "react-router";
40+
41+
import { SuccessAlert } from "../../../components/Alert";
42+
import { CommandCopy } from "../../../components/commandCopy/CommandCopy";
43+
import { RtkErrorAlert } from "../../../components/errors/RtkErrorAlert";
44+
import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
45+
import AppContext from "../../../utils/context/appContext";
46+
47+
import { Project } from "../../projectsV2/api/projectV2.api";
48+
import type {
49+
SessionLauncher,
50+
SessionLauncherPatch,
51+
} from "../api/sessionLaunchersV2.api";
52+
import { usePatchSessionLaunchersByLauncherIdMutation as useUpdateSessionLauncherMutation } from "../api/sessionLaunchersV2.api";
53+
54+
import { Input } from "reactstrap";
55+
56+
function SessionLaunchLink({
57+
launcher,
58+
project,
59+
}: Required<Pick<SessionLaunchLinkModalProps, "launcher" | "project">>) {
60+
const startPath = generatePath(
61+
ABSOLUTE_ROUTES.v2.projects.show.sessions.start,
62+
{
63+
launcherId: launcher.id,
64+
namespace: project.namespace,
65+
slug: project.slug,
66+
}
67+
);
68+
const { params } = useContext(AppContext);
69+
const baseUrl = params?.BASE_URL ?? window.location.href;
70+
const url = new URL(startPath, baseUrl);
71+
return (
72+
<div className="mb-2">
73+
<h4 className="my-auto">
74+
<Link45deg className={cx("bi", "me-1")} />
75+
Session Launch Link
76+
</h4>
77+
<p className="mb-2">A session launch link leads directly to a session.</p>
78+
<CommandCopy command={url.toString()} noMargin />
79+
</div>
80+
);
81+
}
82+
83+
interface EnvVariable {
84+
name: string;
85+
value: string;
86+
}
87+
88+
interface EnvVariablesForm {
89+
envVariables: EnvVariable[];
90+
}
91+
92+
function getLauncherDefaultValues(launcher: SessionLauncher): EnvVariablesForm {
93+
if (launcher.env_variables == null) return { envVariables: [] };
94+
const envVariables = launcher.env_variables.map((env) => ({
95+
name: env.name,
96+
value: env.value ?? "",
97+
}));
98+
return { envVariables };
99+
}
100+
101+
function getPatchFromForm(
102+
form: EnvVariablesForm
103+
):
104+
| { error: string; env_variables: null }
105+
| { error: null; env_variables: SessionLauncherPatch["env_variables"] } {
106+
const env_variables = form.envVariables.map((env) => ({
107+
name: env.name,
108+
value: env.value.length < 1 ? undefined : env.value,
109+
}));
110+
return {
111+
error: null,
112+
env_variables,
113+
};
114+
}
115+
116+
function validateEnvVariableName(name: string): string | boolean {
117+
if (name.toUpperCase().startsWith("RENKU")) {
118+
return "Variable names cannot start with 'RENKU'.";
119+
}
120+
return true;
121+
}
122+
123+
function AddEnvVariableButton({
124+
onAddEnvVariable,
125+
}: {
126+
onAddEnvVariable: () => void;
127+
}) {
128+
return (
129+
<div>
130+
<Button
131+
data-cy="add-env-variable-button"
132+
color="outline-secondary"
133+
size="sm"
134+
className="me-2"
135+
onClick={onAddEnvVariable}
136+
>
137+
<Plus className="bi" />
138+
</Button>
139+
<span className="text-secondary">Add new environment variable</span>
140+
</div>
141+
);
142+
}
143+
144+
interface EnvVariablesFormContentProps {
145+
control: Control<EnvVariablesForm, unknown>;
146+
errors: FieldErrors<EnvVariablesForm>;
147+
index: number;
148+
register: UseFormRegister<EnvVariablesForm>;
149+
remove: UseFieldArrayRemove;
150+
}
151+
152+
function EditEnvVariablesFormContent({
153+
control,
154+
errors,
155+
index,
156+
remove,
157+
}: EnvVariablesFormContentProps) {
158+
const onRemove = useCallback(() => {
159+
remove(index);
160+
}, [remove, index]);
161+
const error = errors.envVariables ? errors.envVariables[index] : undefined;
162+
return (
163+
<div className={cx("d-flex", "gap-3", "mb-3")}>
164+
<div className="flex-grow-1">
165+
<Controller
166+
control={control}
167+
name={`envVariables.${index}.name`}
168+
render={({ field }) => {
169+
const { ref, ...fieldProps } = field;
170+
return (
171+
<Input
172+
bsSize="sm"
173+
className={cx(error?.name && "is-invalid")}
174+
placeholder="MY_ENV_VAR"
175+
type="text"
176+
data-cy={`env-variables-input_${index}-name`}
177+
{...fieldProps}
178+
innerRef={ref}
179+
/>
180+
);
181+
}}
182+
rules={{
183+
required: {
184+
message: "A name is required.",
185+
value: true,
186+
},
187+
maxLength: {
188+
message: "Name can be at most 256 characters.",
189+
value: 256,
190+
},
191+
pattern: {
192+
value: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
193+
message:
194+
"A variable name is made up of letters, numbers and '_'.",
195+
},
196+
validate: {
197+
startWithRenku: (value) => validateEnvVariableName(value),
198+
},
199+
}}
200+
/>
201+
<div className="invalid-feedback">
202+
{error?.name?.message ?? "Please input valid name."}
203+
</div>
204+
</div>
205+
<div className="flex-grow-1">
206+
<Controller
207+
control={control}
208+
name={`envVariables.${index}.value`}
209+
render={({ field }) => {
210+
const { ref, ...fieldProps } = field;
211+
return (
212+
<Input
213+
bsSize="sm"
214+
className={cx(error?.value && "is-invalid")}
215+
placeholder="value"
216+
type="text"
217+
data-cy={`env-variables-input_${index}-value`}
218+
{...fieldProps}
219+
innerRef={ref}
220+
/>
221+
);
222+
}}
223+
rules={{
224+
maxLength: {
225+
message: "Value can be at most 500 characters.",
226+
value: 500,
227+
},
228+
}}
229+
/>
230+
<div className="invalid-feedback">
231+
{error?.value?.message ?? "Please input valid value."}
232+
</div>
233+
</div>
234+
<div>
235+
<Button
236+
data-cy={`env-variables-input_${index}-remove`}
237+
color="outline-danger"
238+
onClick={onRemove}
239+
size="sm"
240+
>
241+
<XLg className="bi" />
242+
</Button>
243+
</div>
244+
</div>
245+
);
246+
}
247+
248+
interface SessionLaunchLinkModalProps {
249+
isOpen: boolean;
250+
launcher: SessionLauncher;
251+
project: Project;
252+
toggle: () => void;
253+
}
254+
255+
export default function SessionLaunchLinkModal({
256+
isOpen,
257+
launcher,
258+
project,
259+
toggle,
260+
}: SessionLaunchLinkModalProps) {
261+
const [updateSessionLauncher, result] = useUpdateSessionLauncherMutation();
262+
const defaultValues = useMemo(
263+
() => getLauncherDefaultValues(launcher),
264+
[launcher]
265+
);
266+
267+
const {
268+
control,
269+
formState: { errors, isDirty },
270+
handleSubmit,
271+
register,
272+
reset,
273+
} = useForm<EnvVariablesForm>({
274+
defaultValues,
275+
});
276+
const { fields, append, remove } = useFieldArray({
277+
control, // control props comes from useForm (optional: if you are using FormProvider)
278+
name: "envVariables", // unique name for your Field Array
279+
});
280+
281+
const onAddEnvVariable = useCallback(() => {
282+
append({ name: "", value: "" });
283+
}, [append]);
284+
285+
const onSubmit = useCallback(
286+
(data: EnvVariablesForm) => {
287+
const { error, env_variables } = getPatchFromForm(data);
288+
if (error == null)
289+
updateSessionLauncher({
290+
launcherId: launcher.id,
291+
sessionLauncherPatch: {
292+
env_variables,
293+
},
294+
});
295+
},
296+
[launcher.id, updateSessionLauncher]
297+
);
298+
299+
useEffect(() => {
300+
if (!isOpen) {
301+
reset();
302+
result.reset();
303+
}
304+
}, [isOpen, reset, result]);
305+
306+
useEffect(() => {
307+
reset(defaultValues);
308+
}, [launcher, reset, defaultValues]);
309+
310+
const [hasAddedDefaultValue, setHasAddedDefaultValue] = useState(false);
311+
useEffect(() => {
312+
if (
313+
!isDirty &&
314+
fields.length < 1 &&
315+
result.isUninitialized &&
316+
!hasAddedDefaultValue
317+
) {
318+
append({ name: "", value: "" });
319+
setHasAddedDefaultValue(true);
320+
}
321+
}, [append, fields, hasAddedDefaultValue, isDirty, result]);
322+
323+
return (
324+
<Modal
325+
backdrop="static"
326+
centered
327+
fullscreen="lg"
328+
isOpen={isOpen}
329+
size="lg"
330+
toggle={toggle}
331+
scrollable
332+
>
333+
<ModalHeader toggle={toggle}>
334+
Session launch link for {launcher.name}
335+
</ModalHeader>
336+
<ModalBody>
337+
<SessionLaunchLink launcher={launcher} project={project} />
338+
{result.isSuccess ? (
339+
<ConfirmationUpdate />
340+
) : fields.length < 1 ? (
341+
<>
342+
<p className="fst-italic">
343+
No environment variables have been defined.
344+
</p>
345+
<AddEnvVariableButton onAddEnvVariable={onAddEnvVariable} />
346+
</>
347+
) : (
348+
<>
349+
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
350+
{result.error && <RtkErrorAlert error={result.error} />}
351+
{fields.map((field, index) => (
352+
<EditEnvVariablesFormContent
353+
key={field.id} // important to include key with field's id
354+
errors={errors}
355+
index={index}
356+
control={control}
357+
register={register}
358+
remove={remove}
359+
/>
360+
))}
361+
</Form>
362+
<AddEnvVariableButton onAddEnvVariable={onAddEnvVariable} />
363+
</>
364+
)}
365+
</ModalBody>
366+
<ModalFooter>
367+
<Button
368+
data-cy="close-cancel-button"
369+
color="outline-primary"
370+
onClick={toggle}
371+
>
372+
<XLg className={cx("bi", "me-1")} />
373+
Close
374+
</Button>
375+
</ModalFooter>
376+
</Modal>
377+
);
378+
}
379+
380+
const ConfirmationUpdate = () => {
381+
return (
382+
<div data-cy="session-launcher-update-success">
383+
<SuccessAlert dismissible={false} timeout={0}>
384+
<p className="fw-bold">Session launcher updated successfully!</p>
385+
<p className="mb-0">
386+
The changes will take effect the next time you launch a session with
387+
this launcher. Current sessions will not be affected.
388+
</p>
389+
</SuccessAlert>
390+
</div>
391+
);
392+
};

0 commit comments

Comments
 (0)