8000 feat: create dynamic parameter component by jaaydenh · Pull Request #17351 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: create dynamic parameter component #17351

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 13 commits into from
Apr 16, 2025
Merged
Prev Previous commit
Next Next commit
chore: cleanup, update validation
  • Loading branch information
jaaydenh committed Apr 11, 2025
commit 4516af20817878e8da230125eac93d294961130f
176 changes: 174 additions & 2 deletions site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
10000
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Parameter, ParameterOption } from "api/typesParameter";
import type { WorkspaceBuildParameter } from "api/typesGenerated";
import type {
Parameter,
ParameterOption,
ParameterValidation,
} from "api/typesParameter";
import { Badge } from "components/Badge/Badge";
import { Checkbox } from "components/Checkbox/Checkbox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
Expand Down Expand Up @@ -26,6 +31,7 @@ import {
} from "components/Tooltip/Tooltip";
import { Info, Settings, TriangleAlert } from "lucide-react";
import { type FC, useId } from "react";
import * as Yup from "yup";

export interface DynamicParameterProps {
parameter: Parameter;
Expand Down Expand Up @@ -324,7 +330,9 @@ const OptionDisplay: FC<OptionDisplayProps> = ({ option }) => {
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>{option.description}</TooltipContent>
<TooltipContent side="right" sideOffset={10}>
{option.description}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
Expand Down Expand Up @@ -357,3 +365,167 @@ const ParameterDiagnostics: FC<ParameterDiagnosticsProps> = ({
</div>
);
};

export const useValidationSchemaForDynamicParameters = (
parameters?: Parameter[],
lastBuildParameters?: WorkspaceBuildParameter[],
): Yup.AnySchema => {
if (!parameters) {
return Yup.object();
}

return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string()
.test("verify with template", (val, ctx) => {
const name = ctx.parent.name;
const parameter = parameters.find(
(parameter) => parameter.name === name,
);
if (parameter) {
switch (parameter.type) {
case "number": {
const minValidation = parameter.validations.find(
(v) => v.validation_min !== null,
);
const maxValidation = parameter.validations.find(
(v) => v.validation_max !== null,
);

if (
minValidation?.validation_min &&
!maxValidation &&
Number(val) < minValidation.validation_min
) {
return ctx.createError({
path: ctx.path,
message:
parameterError(parameter, val) ??
`Value must be greater than ${minValidation.validation_min}.`,
});
}

if (
!minValidation &&
maxValidation?.validation_max &&
Number(val) > maxValidation.validation_max
) {
return ctx.createError({
path: ctx.path,
message:
parameterError(parameter, val) ??
`Value must be less than ${maxValidation.validation_max}.`,
});
}

if (
minValidation?.validation_min &&
maxValidation?.validation_max &&
(Number(val) < minValidation.validation_min ||
Number(val) > maxValidation.validation_max)
) {
return ctx.createError({
path: ctx.path,
message:
parameterError(parameter, val) ??
`Value must be between ${minValidation.validation_min} and ${maxValidation.validation_max}.`,
});
}

const monotonicValidation = parameter.validations.find(
(v) => v.validation_monotonic !== null,
);
if (
monotonicValidation?.validation_monotonic &&
lastBuildParameters
) {
const lastBuildParameter = lastBuildParameters.find(
(last: { name: string }) => last.name === name,
);
if (lastBuildParameter) {
switch (monotonicValidation.validation_monotonic) {
case "increasing":
if (Number(lastBuildParameter.value) > Number(val)) {
return ctx.createError({
path: ctx.path,
message: `Value must only ever increase (last value was ${lastBuildParameter.value})`,
});
}
break;
case "decreasing":
if (Number(lastBuildParameter.value) < Number(val)) {
return ctx.createError({
path: ctx.path,
message: `Value must only ever decrease (last value was ${lastBuildParameter.value})`,
});
}
break;
}
}
}
break;
}
case "string": {
const regexValidation = parameter.validations.find(
(v) => v.validation_regex !== null,
);
if (!regexValidation?.validation_regex) {
return true;
}

if (
val &&
!new RegExp(regexValidation.validation_regex).test(val)
) {
return ctx.createError({
path: ctx.path,
message: parameterError(parameter, val),
});
}
break;
}
}
}
return true;
}),
}),
)
.required();
};

const parameterError = (
parameter: Parameter,
value?: string,
): string | undefined => {
const validation_error = parameter.validations.find(
(v) => v.validation_error !== null,
);
const minValidation = parameter.validations.find(
(v) => v.validation_min !== null,
);
const maxValidation = parameter.validations.find(
(v) => v.validation_max !== null,
);

if (!validation_error || !value) {
return;
}

const r = new Map<string, string>([
[
"{min}",
minValidation ? (minValidation.validation_min?.toString() ?? "") : "",
],
[
"{max}",
maxValidation ? (maxValidation.validation_max?.toString() ?? "") : "",
],
["{value}", value],
]);
return validation_error.validation_error.replace(
/{min}|{max}|{value}/g,
(match) => r.get(match) || "",
);
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
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 {
Template,
TemplateVersionParameter,
Workspace,
} from "api/typesGenerated";
import type { Template, Workspace } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useEffectEvent } from "hooks/hookPolyfills";
Expand All @@ -29,7 +24,6 @@ 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];
Expand Down Expand Up @@ -101,14 +95,8 @@ const CreateWorkspacePageExperimental: FC = () => {
);
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,
Expand All @@ -118,11 +106,8 @@ const CreateWorkspacePageExperimental: FC = () => {
} = useExternalAuth(realizedVersionId);

const isLoadingFormData =
templateQuery.isLoading ||
permissionsQuery.isLoading ||
richParametersQuery.isLoading;
const loadFormDataError =
templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error;
templateQuery.isLoading || permissionsQuery.isLoading;
const loadFormDataError = templateQuery.error ?? permissionsQuery.error;

const title = autoCreateWorkspaceMutation.isLoading
? "Creating workspace..."
Expand Down Expand Up @@ -205,7 +190,6 @@ const CreateWorkspacePageExperimental: FC = () => {
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
}, [currentResponse?.parameters]);

// console.log("sortedParams", sortedParams);
return (
<>
<Helmet>
Expand Down Expand Up @@ -238,9 +222,6 @@ const CreateWorkspacePageExperimental: FC = () => {
startPollingExternalAuth={startPollingExternalAuth}
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWorkspacePermissions}
templateVersionParameters={
realizedParameters as TemplateVersionParameter[]
}
parameters={sortedParams}
presets={templateVersionPresetsQuery.data ?? []}
creatingWorkspace={createWorkspaceMutation.isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { Switch } from "components/Switch/Switch";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FormikContextType, useFormik } from "formik";
import { ArrowLeft } from "lucide-react";
import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter";
import {
DynamicParameter,
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import {
type FC,
Expand All @@ -26,11 +29,7 @@ import {
useState,
} from "react";
import { getFormHelpers, nameValidator } from "utils/formUtils";
import {
type AutofillBuildParameter,
getInitialRichParameterValues,
useValidationSchemaForRichParameters,
} from "utils/richParameters";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
import type {
CreateWorkspaceMode,
Expand Down Expand Up @@ -60,7 +59,6 @@ export interface CreateWorkspacePageViewExperimentalProps {
permissions: CreateWorkspacePermissions;
presets: TypesGen.Preset[];
template: TypesGen.Template;
templateVersionParameters: TypesGen.TemplateVersionParameter[];
versionId?: string;
onCancel: () => void;
onSubmit: (
Expand All @@ -73,7 +71,7 @@ export interface CreateWorkspacePageViewExperimentalProps {
startPollingExternalAuth: () => void;
}

// const getInitialParameterValues = (
// const getInitialParameterValues1 = (
// params: Parameter[],
// autofillParams?: AutofillBuildParameter[],
// ): WorkspaceBuildParameter[] => {
Expand All @@ -95,7 +93,7 @@ export interface CreateWorkspacePageViewExperimentalProps {
// name: parameter.name,
// value:
// autofillParam &&
// // isValidValue(parameter, autofillParam) &&
// isValidValue(parameter, autofillParam) &&
// autofillParam.source !== "user_history"
// ? autofillParam.value
// : parameter.default_value,
Expand Down Expand Up @@ -129,7 +127,6 @@ export const CreateWorkspacePageViewExperimental: FC<
permissions,
presets = [],
template,
templateVersionParameters,
versionId,
onSubmit,
onCancel,
Expand Down Expand Up @@ -157,9 +154,8 @@ export const CreateWorkspacePageViewExperimental: FC<
},
validationSchema: Yup.object({
name: nameValidator("Workspace Name"),
rich_parameter_values: useValidationSchemaForRichParameters(
templateVersionParameters,
),
rich_parameter_values:
useValidationSchemaForDynamicParameters(parameters),
}),
enableReinitialize: true,
validateOnChange: false,
Expand Down
0