mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat: create experimental CreateWorkspacePage and dynamic-parameters experiment (#17240)
The purpose of the PR is to make a copy of the CreateWorkspacePage and create an experimental version that will use when the dynamic-parameters experiment is enabled. The Figma designs for this page are still in progress but this first PR will start to move to the new designs. Figma design: https://www.figma.com/design/SMg6H8VKXnPSkE6h9KPoAD/UX-Presets?node-id=2121-2383&t=CtgPUz8eNsTI5b1t-1 Much of the existing code will be left behind and will slowly migrated over the course of several PRs to make sure no existing functionality is forgotten in the migration to dynamic paramaters.
This commit is contained in:
7
coderd/apidoc/docs.go
generated
7
coderd/apidoc/docs.go
generated
@ -12097,10 +12097,12 @@ const docTemplate = `{
|
||||
"auto-fill-parameters",
|
||||
"notifications",
|
||||
"workspace-usage",
|
||||
"web-push"
|
||||
"web-push",
|
||||
"dynamic-parameters"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
@ -12111,7 +12113,8 @@ const docTemplate = `{
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentNotifications",
|
||||
"ExperimentWorkspaceUsage",
|
||||
"ExperimentWebPush"
|
||||
"ExperimentWebPush",
|
||||
"ExperimentDynamicParameters"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
|
7
coderd/apidoc/swagger.json
generated
7
coderd/apidoc/swagger.json
generated
@ -10833,10 +10833,12 @@
|
||||
"auto-fill-parameters",
|
||||
"notifications",
|
||||
"workspace-usage",
|
||||
"web-push"
|
||||
"web-push",
|
||||
"dynamic-parameters"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
@ -10847,7 +10849,8 @@
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentNotifications",
|
||||
"ExperimentWorkspaceUsage",
|
||||
"ExperimentWebPush"
|
||||
"ExperimentWebPush",
|
||||
"ExperimentDynamicParameters"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
|
@ -3194,6 +3194,7 @@ const (
|
||||
ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events.
|
||||
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
|
||||
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
|
||||
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
|
||||
)
|
||||
|
||||
// ExperimentsAll should include all experiments that are safe for
|
||||
|
1
docs/reference/api/schemas.md
generated
1
docs/reference/api/schemas.md
generated
@ -2845,6 +2845,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `notifications` |
|
||||
| `workspace-usage` |
|
||||
| `web-push` |
|
||||
| `dynamic-parameters` |
|
||||
|
||||
## codersdk.ExternalAuth
|
||||
|
||||
|
1
site/src/api/typesGenerated.ts
generated
1
site/src/api/typesGenerated.ts
generated
@ -749,6 +749,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning";
|
||||
// From codersdk/deployment.go
|
||||
export type Experiment =
|
||||
| "auto-fill-parameters"
|
||||
| "dynamic-parameters"
|
||||
| "example"
|
||||
| "notifications"
|
||||
| "web-push"
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import type { FC } from "react";
|
||||
import CreateWorkspacePage from "./CreateWorkspacePage";
|
||||
import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental";
|
||||
|
||||
const CreateWorkspaceExperimentRouter: FC = () => {
|
||||
const { experiments } = useDashboard();
|
||||
|
||||
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");
|
||||
|
||||
if (dynamicParametersEnabled) {
|
||||
return <CreateWorkspacePageExperimental />;
|
||||
}
|
||||
|
||||
return <CreateWorkspacePage />;
|
||||
};
|
||||
|
||||
export default CreateWorkspaceExperimentRouter;
|
@ -0,0 +1,327 @@
|
||||
import { API } from "api/api";
|
||||
import type { ApiErrorResponse } from "api/errors";
|
||||
import { checkAuthorization } from "api/queries/authCheck";
|
||||
import {
|
||||
richParameters,
|
||||
templateByName,
|
||||
templateVersionExternalAuth,
|
||||
templateVersionPresets,
|
||||
} from "api/queries/templates";
|
||||
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
|
||||
import type {
|
||||
TemplateVersionParameter,
|
||||
UserParameter,
|
||||
Workspace,
|
||||
} from "api/typesGenerated";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import {
|
||||
type WorkspacePermissions,
|
||||
workspacePermissionChecks,
|
||||
} from "modules/permissions/workspaces";
|
||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
import { paramsUsedToCreateWorkspace } from "utils/workspace";
|
||||
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
|
||||
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
|
||||
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
|
||||
|
||||
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
|
||||
|
||||
const CreateWorkspacePageExperimental: FC = () => {
|
||||
const { organization: organizationName = "default", template: templateName } =
|
||||
useParams() as { organization?: string; template: string };
|
||||
const { user: me } = useAuthenticated();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { experiments } = useDashboard();
|
||||
|
||||
const customVersionId = searchParams.get("version") ?? undefined;
|
||||
const defaultName = searchParams.get("name");
|
||||
const disabledParams = searchParams.get("disable_params")?.split(",");
|
||||
const [mode, setMode] = useState(() => getWorkspaceMode(searchParams));
|
||||
const [autoCreateError, setAutoCreateError] =
|
||||
useState<ApiErrorResponse | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const autoCreateWorkspaceMutation = useMutation(
|
||||
autoCreateWorkspace(queryClient),
|
||||
);
|
||||
const createWorkspaceMutation = useMutation(createWorkspace(queryClient));
|
||||
|
||||
const templateQuery = useQuery(
|
||||
templateByName(organizationName, templateName),
|
||||
);
|
||||
const templateVersionPresetsQuery = useQuery({
|
||||
...templateVersionPresets(templateQuery.data?.active_version_id ?? ""),
|
||||
enabled: templateQuery.data !== undefined,
|
||||
});
|
||||
const permissionsQuery = useQuery(
|
||||
templateQuery.data
|
||||
? checkAuthorization({
|
||||
checks: workspacePermissionChecks(templateQuery.data.organization_id),
|
||||
})
|
||||
: { enabled: false },
|
||||
);
|
||||
const realizedVersionId =
|
||||
customVersionId ?? templateQuery.data?.active_version_id;
|
||||
const organizationId = templateQuery.data?.organization_id;
|
||||
const richParametersQuery = useQuery({
|
||||
...richParameters(realizedVersionId ?? ""),
|
||||
enabled: realizedVersionId !== undefined,
|
||||
});
|
||||
const realizedParameters = richParametersQuery.data
|
||||
? richParametersQuery.data.filter(paramsUsedToCreateWorkspace)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
externalAuth,
|
||||
externalAuthPollingState,
|
||||
startPollingExternalAuth,
|
||||
isLoadingExternalAuth,
|
||||
} = useExternalAuth(realizedVersionId);
|
||||
|
||||
const isLoadingFormData =
|
||||
templateQuery.isLoading ||
|
||||
permissionsQuery.isLoading ||
|
||||
richParametersQuery.isLoading;
|
||||
const loadFormDataError =
|
||||
templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error;
|
||||
|
||||
const title = autoCreateWorkspaceMutation.isLoading
|
||||
? "Creating workspace..."
|
||||
: "Create workspace";
|
||||
|
||||
const onCreateWorkspace = useCallback(
|
||||
(workspace: Workspace) => {
|
||||
navigate(`/@${workspace.owner_name}/${workspace.name}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// Auto fill parameters
|
||||
const autofillEnabled = experiments.includes("auto-fill-parameters");
|
||||
const userParametersQuery = useQuery({
|
||||
queryKey: ["userParameters"],
|
||||
queryFn: () => API.getUserParameters(templateQuery.data!.id),
|
||||
enabled: autofillEnabled && templateQuery.isSuccess,
|
||||
});
|
||||
const autofillParameters = getAutofillParameters(
|
||||
searchParams,
|
||||
userParametersQuery.data ? userParametersQuery.data : [],
|
||||
);
|
||||
|
||||
const autoCreationStartedRef = useRef(false);
|
||||
const automateWorkspaceCreation = useEffectEvent(async () => {
|
||||
if (autoCreationStartedRef.current || !organizationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
autoCreationStartedRef.current = true;
|
||||
const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({
|
||||
organizationId,
|
||||
templateName,
|
||||
buildParameters: autofillParameters,
|
||||
workspaceName: defaultName ?? generateWorkspaceName(),
|
||||
templateVersionId: realizedVersionId,
|
||||
match: searchParams.get("match"),
|
||||
});
|
||||
|
||||
onCreateWorkspace(newWorkspace);
|
||||
} catch {
|
||||
setMode("form");
|
||||
}
|
||||
});
|
||||
|
||||
const hasAllRequiredExternalAuth = Boolean(
|
||||
!isLoadingExternalAuth &&
|
||||
externalAuth?.every((auth) => auth.optional || auth.authenticated),
|
||||
);
|
||||
|
||||
let autoCreateReady =
|
||||
mode === "auto" &&
|
||||
(!autofillEnabled || userParametersQuery.isSuccess) &&
|
||||
hasAllRequiredExternalAuth;
|
||||
|
||||
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
|
||||
if (
|
||||
mode === "auto" &&
|
||||
!isLoadingExternalAuth &&
|
||||
!hasAllRequiredExternalAuth
|
||||
) {
|
||||
// Prevent suddenly resuming auto-mode if the user connects to all of the required
|
||||
// external auth providers.
|
||||
setMode("form");
|
||||
// Ensure this is always false, so that we don't ever let `automateWorkspaceCreation`
|
||||
// fire when we're trying to disable it.
|
||||
autoCreateReady = false;
|
||||
// Show an error message to explain _why_ the workspace was not created automatically.
|
||||
const subject =
|
||||
externalAuth?.length === 1
|
||||
? "an external authentication provider that is"
|
||||
: "external authentication providers that are";
|
||||
setAutoCreateError({
|
||||
message: `This template requires ${subject} not connected.`,
|
||||
detail:
|
||||
"Auto-creation has been disabled. Please connect all required external authentication providers before continuing.",
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoCreateReady) {
|
||||
void automateWorkspaceCreation();
|
||||
}
|
||||
}, [automateWorkspaceCreation, autoCreateReady]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(title)}</title>
|
||||
</Helmet>
|
||||
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<CreateWorkspacePageViewExperimental
|
||||
mode={mode}
|
||||
defaultName={defaultName}
|
||||
disabledParams={disabledParams}
|
||||
defaultOwner={me}
|
||||
autofillParameters={autofillParameters}
|
||||
error={
|
||||
createWorkspaceMutation.error ||
|
||||
autoCreateError ||
|
||||
loadFormDataError ||
|
||||
autoCreateWorkspaceMutation.error
|
||||
}
|
||||
resetMutation={createWorkspaceMutation.reset}
|
||||
template={templateQuery.data!}
|
||||
versionId={realizedVersionId}
|
||||
externalAuth={externalAuth ?? []}
|
||||
externalAuthPollingState={externalAuthPollingState}
|
||||
startPollingExternalAuth={startPollingExternalAuth}
|
||||
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
|
||||
permissions={permissionsQuery.data as WorkspacePermissions}
|
||||
parameters={realizedParameters as TemplateVersionParameter[]}
|
||||
presets={templateVersionPresetsQuery.data ?? []}
|
||||
creatingWorkspace={createWorkspaceMutation.isLoading}
|
||||
onCancel={() => {
|
||||
navigate(-1);
|
||||
}}
|
||||
onSubmit={async (request, owner) => {
|
||||
if (realizedVersionId) {
|
||||
request = {
|
||||
...request,
|
||||
template_id: undefined,
|
||||
template_version_id: realizedVersionId,
|
||||
};
|
||||
}
|
||||
|
||||
const workspace = await createWorkspaceMutation.mutateAsync({
|
||||
...request,
|
||||
userId: owner.id,
|
||||
});
|
||||
onCreateWorkspace(workspace);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useExternalAuth = (versionId: string | undefined) => {
|
||||
const [externalAuthPollingState, setExternalAuthPollingState] =
|
||||
useState<ExternalAuthPollingState>("idle");
|
||||
|
||||
const startPollingExternalAuth = useCallback(() => {
|
||||
setExternalAuthPollingState("polling");
|
||||
}, []);
|
||||
|
||||
const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery(
|
||||
versionId
|
||||
? {
|
||||
...templateVersionExternalAuth(versionId),
|
||||
refetchInterval:
|
||||
externalAuthPollingState === "polling" ? 1000 : false,
|
||||
}
|
||||
: { enabled: false },
|
||||
);
|
||||
|
||||
const allSignedIn = externalAuth?.every((it) => it.authenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (allSignedIn) {
|
||||
setExternalAuthPollingState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (externalAuthPollingState !== "polling") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for a maximum of one minute
|
||||
const quitPolling = setTimeout(
|
||||
() => setExternalAuthPollingState("abandoned"),
|
||||
60_000,
|
||||
);
|
||||
return () => {
|
||||
clearTimeout(quitPolling);
|
||||
};
|
||||
}, [externalAuthPollingState, allSignedIn]);
|
||||
|
||||
return {
|
||||
startPollingExternalAuth,
|
||||
externalAuth,
|
||||
externalAuthPollingState,
|
||||
isLoadingExternalAuth,
|
||||
};
|
||||
};
|
||||
|
||||
const getAutofillParameters = (
|
||||
urlSearchParams: URLSearchParams,
|
||||
userParameters: UserParameter[],
|
||||
): AutofillBuildParameter[] => {
|
||||
const userParamMap = userParameters.reduce((acc, param) => {
|
||||
acc.set(param.name, param);
|
||||
return acc;
|
||||
}, new Map<string, UserParameter>());
|
||||
|
||||
const buildValues: AutofillBuildParameter[] = Array.from(
|
||||
urlSearchParams.keys(),
|
||||
)
|
||||
.filter((key) => key.startsWith("param."))
|
||||
.map((key) => {
|
||||
const name = key.replace("param.", "");
|
||||
const value = urlSearchParams.get(key) ?? "";
|
||||
// URL should take precedence over user parameters
|
||||
userParamMap.delete(name);
|
||||
return { name, value, source: "url" };
|
||||
});
|
||||
|
||||
for (const param of userParamMap.values()) {
|
||||
buildValues.push({
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
source: "user_history",
|
||||
});
|
||||
}
|
||||
return buildValues;
|
||||
};
|
||||
|
||||
export default CreateWorkspacePageExperimental;
|
||||
|
||||
function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode {
|
||||
const paramMode = params.get("mode");
|
||||
if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) {
|
||||
return paramMode as CreateWorkspaceMode;
|
||||
}
|
||||
|
||||
return "form";
|
||||
}
|
@ -0,0 +1,446 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { SelectFilter } from "components/Filter/SelectFilter";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { type FormikContextType, useFormik } from "formik";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { WorkspacePermissions } from "modules/permissions/workspaces";
|
||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "utils/formUtils";
|
||||
import {
|
||||
type AutofillBuildParameter,
|
||||
getInitialRichParameterValues,
|
||||
useValidationSchemaForRichParameters,
|
||||
} from "utils/richParameters";
|
||||
import * as Yup from "yup";
|
||||
import type {
|
||||
CreateWorkspaceMode,
|
||||
ExternalAuthPollingState,
|
||||
} from "./CreateWorkspacePage";
|
||||
import { ExternalAuthButton } from "./ExternalAuthButton";
|
||||
|
||||
export const Language = {
|
||||
duplicationWarning:
|
||||
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
|
||||
} as const;
|
||||
|
||||
export interface CreateWorkspacePageViewExperimentalProps {
|
||||
mode: CreateWorkspaceMode;
|
||||
defaultName?: string | null;
|
||||
disabledParams?: string[];
|
||||
error: unknown;
|
||||
resetMutation: () => void;
|
||||
defaultOwner: TypesGen.User;
|
||||
template: TypesGen.Template;
|
||||
versionId?: string;
|
||||
externalAuth: TypesGen.TemplateVersionExternalAuth[];
|
||||
externalAuthPollingState: ExternalAuthPollingState;
|
||||
startPollingExternalAuth: () => void;
|
||||
hasAllRequiredExternalAuth: boolean;
|
||||
parameters: TypesGen.TemplateVersionParameter[];
|
||||
autofillParameters: AutofillBuildParameter[];
|
||||
presets: TypesGen.Preset[];
|
||||
permissions: WorkspacePermissions;
|
||||
creatingWorkspace: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (
|
||||
req: TypesGen.CreateWorkspaceRequest,
|
||||
owner: TypesGen.User,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspacePageViewExperimental: FC<
|
||||
CreateWorkspacePageViewExperimentalProps
|
||||
> = ({
|
||||
mode,
|
||||
defaultName,
|
||||
disabledParams,
|
||||
error,
|
||||
resetMutation,
|
||||
defaultOwner,
|
||||
template,
|
||||
versionId,
|
||||
externalAuth,
|
||||
externalAuthPollingState,
|
||||
startPollingExternalAuth,
|
||||
hasAllRequiredExternalAuth,
|
||||
parameters,
|
||||
autofillParameters,
|
||||
presets = [],
|
||||
permissions,
|
||||
creatingWorkspace,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [owner, setOwner] = useState(defaultOwner);
|
||||
const [suggestedName, setSuggestedName] = useState(() =>
|
||||
generateWorkspaceName(),
|
||||
);
|
||||
const id = useId();
|
||||
|
||||
const rerollSuggestedName = useCallback(() => {
|
||||
setSuggestedName(() => generateWorkspaceName());
|
||||
}, []);
|
||||
|
||||
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
|
||||
useFormik<TypesGen.CreateWorkspaceRequest>({
|
||||
initialValues: {
|
||||
name: defaultName ?? "",
|
||||
template_id: template.id,
|
||||
rich_parameter_values: getInitialRichParameterValues(
|
||||
parameters,
|
||||
autofillParameters,
|
||||
),
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: nameValidator("Workspace Name"),
|
||||
rich_parameter_values: useValidationSchemaForRichParameters(parameters),
|
||||
}),
|
||||
enableReinitialize: true,
|
||||
onSubmit: (request) => {
|
||||
if (!hasAllRequiredExternalAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(request, owner);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
|
||||
form,
|
||||
error,
|
||||
);
|
||||
|
||||
const autofillByName = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
autofillParameters.map((param) => [param.name, param]),
|
||||
),
|
||||
[autofillParameters],
|
||||
);
|
||||
|
||||
const [presetOptions, setPresetOptions] = useState([
|
||||
{ label: "None", value: "" },
|
||||
]);
|
||||
useEffect(() => {
|
||||
setPresetOptions([
|
||||
{ label: "None", value: "" },
|
||||
...presets.map((preset) => ({
|
||||
label: preset.Name,
|
||||
value: preset.ID,
|
||||
})),
|
||||
]);
|
||||
}, [presets]);
|
||||
|
||||
const [selectedPresetIndex, setSelectedPresetIndex] = useState(0);
|
||||
const [presetParameterNames, setPresetParameterNames] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedPresetOption = presetOptions[selectedPresetIndex];
|
||||
let selectedPreset: TypesGen.Preset | undefined;
|
||||
for (const preset of presets) {
|
||||
if (preset.ID === selectedPresetOption.value) {
|
||||
selectedPreset = preset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedPreset || !selectedPreset.Parameters) {
|
||||
setPresetParameterNames([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPresetParameterNames(selectedPreset.Parameters.map((p) => p.Name));
|
||||
|
||||
for (const presetParameter of selectedPreset.Parameters) {
|
||||
const parameterIndex = parameters.findIndex(
|
||||
(p) => p.name === presetParameter.Name,
|
||||
);
|
||||
if (parameterIndex === -1) continue;
|
||||
|
||||
const parameterField = `rich_parameter_values.${parameterIndex}`;
|
||||
|
||||
form.setFieldValue(parameterField, {
|
||||
name: presetParameter.Name,
|
||||
value: presetParameter.Value,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
presetOptions,
|
||||
selectedPresetIndex,
|
||||
presets,
|
||||
parameters,
|
||||
form.setFieldValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute sticky top-5 ml-10">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
className="flex items-center gap-2 bg-transparent border-none text-content-secondary hover:text-content-primary translate-y-12"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 max-w-screen-sm mx-auto">
|
||||
<header className="flex flex-col gap-2 mt-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="md"
|
||||
src={template.icon}
|
||||
fallback={template.name}
|
||||
/>
|
||||
<p className="text-base font-medium m-0">
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
|
||||
|
||||
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Create workspace form"
|
||||
className="flex flex-col gap-6 w-full border border-border-default border-solid rounded-lg p-6"
|
||||
>
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
{mode === "duplicate" && (
|
||||
<Alert
|
||||
severity="info"
|
||||
dismissible
|
||||
data-testid="duplication-warning"
|
||||
>
|
||||
{Language.duplicationWarning}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<hgroup>
|
||||
<h2 className="text-xl font-semibold m-0">General</h2>
|
||||
<p className="text-sm text-content-secondary mt-0">
|
||||
{permissions.createWorkspaceForUser
|
||||
? "Only admins can create workspaces for other users."
|
||||
: "The name of your new workspace."}
|
||||
</p>
|
||||
</hgroup>
|
||||
<div>
|
||||
{versionId && versionId !== template.active_version_id && (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<Label className="text-sm" htmlFor={`${id}-version-id`}>
|
||||
Version ID
|
||||
</Label>
|
||||
<Input id={`${id}-version-id`} value={versionId} disabled />
|
||||
<span className="text-xs text-content-secondary">
|
||||
This parameter has been preset, and cannot be modified.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
|
||||
Workspace name
|
||||
</Label>
|
||||
<div>
|
||||
<Input
|
||||
id={`${id}-workspace-name`}
|
||||
value={form.values.name}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("name", e.target.value.trim());
|
||||
resetMutation();
|
||||
}}
|
||||
disabled={creatingWorkspace}
|
||||
/>
|
||||
<div className="flex gap-2 text-xs text-content-secondary items-center">
|
||||
Need a suggestion?
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await form.setFieldValue("name", suggestedName);
|
||||
rerollSuggestedName();
|
||||
}}
|
||||
>
|
||||
{suggestedName}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{permissions.createWorkspaceForUser && (
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
|
||||
Owner
|
||||
</Label>
|
||||
<UserAutocomplete
|
||||
value={owner}
|
||||
onChange={(user) => {
|
||||
setOwner(user ?? defaultOwner);
|
||||
}}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{externalAuth && externalAuth.length > 0 && (
|
||||
<section>
|
||||
<hgroup>
|
||||
<h2 className="text-xl font-semibold mb-0">
|
||||
External Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-content-secondary mt-0">
|
||||
This template uses external services for authentication.
|
||||
</p>
|
||||
</hgroup>
|
||||
<div>
|
||||
{Boolean(error) && !hasAllRequiredExternalAuth && (
|
||||
<Alert severity="error">
|
||||
To create a workspace using this template, please connect to
|
||||
all required external authentication providers listed below.
|
||||
</Alert>
|
||||
)}
|
||||
{externalAuth.map((auth) => (
|
||||
<ExternalAuthButton
|
||||
key={auth.id}
|
||||
error={error}
|
||||
auth={auth}
|
||||
isLoading={externalAuthPollingState === "polling"}
|
||||
onStartPolling={startPollingExternalAuth}
|
||||
displayRetry={externalAuthPollingState === "abandoned"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{parameters.length > 0 && (
|
||||
<section className="flex flex-col gap-6">
|
||||
<hgroup>
|
||||
<h2 className="text-xl font-semibold m-0">Parameters</h2>
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
These are the settings used by your template. Please note that
|
||||
immutable parameters cannot be modified once the workspace is
|
||||
created.
|
||||
</p>
|
||||
</hgroup>
|
||||
{presets.length > 0 && (
|
||||
<Stack direction="column" spacing={2}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label className="text-sm">Preset</Label>
|
||||
<FeatureStageBadge contentType={"beta"} size="md" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<SelectFilter
|
||||
label="Preset"
|
||||
options={presetOptions}
|
||||
onSelect={(option) => {
|
||||
const index = presetOptions.findIndex(
|
||||
(preset) => preset.value === option?.value,
|
||||
);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
setSelectedPresetIndex(index);
|
||||
}}
|
||||
placeholder="Select a preset"
|
||||
selectedOption={presetOptions[selectedPresetIndex]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-9">
|
||||
{parameters.map((parameter, index) => {
|
||||
const parameterField = `rich_parameter_values.${index}`;
|
||||
const parameterInputName = `${parameterField}.value`;
|
||||
const isDisabled =
|
||||
disabledParams?.includes(
|
||||
parameter.name.toLowerCase().replace(/ /g, "_"),
|
||||
) ||
|
||||
creatingWorkspace ||
|
||||
presetParameterNames.includes(parameter.name);
|
||||
|
||||
return (
|
||||
<RichParameterInput
|
||||
{...getFieldHelpers(parameterInputName)}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue(parameterField, {
|
||||
name: parameter.name,
|
||||
value,
|
||||
});
|
||||
}}
|
||||
key={parameter.name}
|
||||
parameter={parameter}
|
||||
parameterAutofill={autofillByName[parameter.name]}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={creatingWorkspace || !hasAllRequiredExternalAuth}
|
||||
>
|
||||
<Spinner loading={creatingWorkspace} />
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
description: (theme) => ({
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
@ -95,8 +95,8 @@ const TemplatePermissionsPage = lazy(
|
||||
const TemplateSummaryPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"),
|
||||
);
|
||||
const CreateWorkspacePage = lazy(
|
||||
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
|
||||
const CreateWorkspaceExperimentRouter = lazy(
|
||||
() => import("./pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter"),
|
||||
);
|
||||
const OverviewPage = lazy(
|
||||
() => import("./pages/DeploymentSettingsPage/OverviewPage/OverviewPage"),
|
||||
@ -334,7 +334,7 @@ const templateRouter = () => {
|
||||
<Route path="insights" element={<TemplateInsightsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="workspace" element={<CreateWorkspacePage />} />
|
||||
<Route path="workspace" element={<CreateWorkspaceExperimentRouter />} />
|
||||
|
||||
<Route path="settings" element={<TemplateSettingsLayout />}>
|
||||
<Route index element={<TemplateSettingsPage />} />
|
||||
|
Reference in New Issue
Block a user