Skip to content

feat: support custom validation errors for number-typed parameters #12224

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 9 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
46 changes: 46 additions & 0 deletions cli/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,14 @@ func TestCreateValidateRichParameters(t *testing.T) {
{Name: numberParameterName, Type: "number", Mutable: true, ValidationMin: ptr.Ref(int32(3)), ValidationMax: ptr.Ref(int32(10))},
}

numberCustomErrorRichParameters := []*proto.RichParameter{
{
Name: numberParameterName, Type: "number", Mutable: true,
ValidationMin: ptr.Ref(int32(3)), ValidationMax: ptr.Ref(int32(10)),
ValidationError: "These are values: {min}, {max}, and {value}.",
},
}

stringRichParameters := []*proto.RichParameter{
{Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"},
}
Expand Down Expand Up @@ -644,6 +652,44 @@ func TestCreateValidateRichParameters(t *testing.T) {
<-doneChan
})

t.Run("ValidateNumber_CustomError", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(numberCustomErrorRichParameters))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)

template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)

inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()

matches := []string{
numberParameterName, "12",
"These are values: 3, 10, and 12.", "",
"Enter a value", "8",
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
if value != "" {
pty.WriteLine(value)
}
}
<-doneChan
})

t.Run("ValidateBool", func(t *testing.T) {
t.Parallel()

Expand Down
21 changes: 21 additions & 0 deletions codersdk/richparameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,24 @@ func TestParameterResolver_ValidateResolve_Ephemeral_UseEmptyDefault(t *testing.
require.NoError(t, err)
require.Equal(t, "", v)
}

func TestParameterResolver_ValidateResolve_Number_CustomError(t *testing.T) {
t.Parallel()
uut := codersdk.ParameterResolver{}
p := codersdk.TemplateVersionParameter{
Name: "n",
Type: "number",
Mutable: true,
DefaultValue: "5",

ValidationMin: ptr.Ref(int32(4)),
ValidationMax: ptr.Ref(int32(6)),
ValidationError: "These are values for testing purposes: {min}, {max}, and {value}.",
}
_, err := uut.ValidateResolve(p, &codersdk.WorkspaceBuildParameter{
Name: "n",
Value: "8",
})
require.Error(t, err)
require.Contains(t, err.Error(), "These are values for testing purposes: 4, 6, and 8.")
}
17 changes: 17 additions & 0 deletions docs/templates/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,23 @@ data "coder_parameter" "instances" {
}
```

It is possible to override the default `error` message for a `number` parameter,
along with its associated `min` and/or `max` properties. The following message
placeholders are available `{min}`, `{max}`, and `{value}`.

```hcl
data "coder_parameter" "instances" {
name = "Instances"
type = "number"
description = "Number of compute instances"
validation {
min = 1
max = 4
error = "Sorry, we can't provision too many instances - maximum limit: {max}, wanted: {value}."
}
}
```

### String

You can validate a `string` parameter to match a regular expression. The `regex`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ require (
github.com/coder/flog v1.1.0
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0
github.com/coder/retry v1.5.1
github.com/coder/terraform-provider-coder v0.17.0
github.com/coder/terraform-provider-coder v0.18.0
github.com/coder/wgtunnel v0.1.13-0.20231127054351-578bfff9b92a
github.com/coreos/go-oidc/v3 v3.9.0
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuO
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
github.com/coder/tailscale v1.1.1-0.20240214140224-3788ab894ba1 h1:A7dZHNidAVH6Kxn5D3hTEH+iRO8slnM0aRer6/cxlyE=
github.com/coder/tailscale v1.1.1-0.20240214140224-3788ab894ba1/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4=
github.com/coder/terraform-provider-coder v0.17.0 h1:qwdLSbh6vPN+QDDvw1WNSYYEFlFwJFwzzP9vrvwr/ks=
github.com/coder/terraform-provider-coder v0.17.0/go.mod h1:pACHRoXSHBGyY696mLeQ1hR/Ag1G2wFk5bw0mT5Zp2g=
github.com/coder/terraform-provider-coder v0.18.0 h1:JWSBsOuzyiCev3C2Aj8Y1dvJkm5JMysIrIylMJtzPAY=
github.com/coder/terraform-provider-coder v0.18.0/go.mod h1:pACHRoXSHBGyY696mLeQ1hR/Ag1G2wFk5bw0mT5Zp2g=
github.com/coder/wgtunnel v0.1.13-0.20231127054351-578bfff9b92a h1:KhR9LUVllMZ+e9lhubZ1HNrtJDgH5YLoTvpKwmrGag4=
github.com/coder/wgtunnel v0.1.13-0.20231127054351-578bfff9b92a/go.mod h1:QzfptVUdEO+XbkzMKx1kw13i9wwpJlfI1RrZ6SNZ0hA=
github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5 h1:eDk/42Kj4xN4yfE504LsvcFEo3dWUiCOaBiWJ2uIH2A=
Expand Down
32 changes: 30 additions & 2 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { rest } from "msw";
const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create Workspace";
const validationNumberNotInRangeText = "Value must be between 1 and 3.";
const validationPatternNotMatched = `${MockTemplateVersionParameter3.validation_error} (value does not match the pattern ^[a-z]{3}$)`;

const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
Expand Down Expand Up @@ -152,7 +151,36 @@ describe("CreateWorkspacePage", () => {
fireEvent.submit(thirdParameterField);

const validationError = await screen.findByText(
validationPatternNotMatched,
MockTemplateVersionParameter3.validation_error as string,
);
expect(validationError).toBeInTheDocument();
});

it("rich parameter: number validation fails with custom error", async () => {
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
MockTemplateVersionParameter1,
{
...MockTemplateVersionParameter2,
validation_error: "These are values: {min}, {max}, and {value}.",
validation_monotonic: undefined, // only needs min-max rules
},
]);

renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();

const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
);
expect(secondParameterField).toBeDefined();
fireEvent.change(secondParameterField, {
target: { value: "4" },
});
fireEvent.submit(secondParameterField);

const validationError = await screen.findByText(
"These are values: 1, 3, and 4.",
);
expect(validationError).toBeInTheDocument();
});
Expand Down
43 changes: 39 additions & 4 deletions site/src/utils/richParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export const useValidationSchemaForRichParameters = (
if (Number(val) < templateParameter.validation_min) {
return ctx.createError({
path: ctx.path,
message: `Value must be greater than ${templateParameter.validation_min}.`,
message:
parameterError(templateParameter, val) ??
`Value must be greater than ${templateParameter.validation_min}.`,
});
}
} else if (
Expand All @@ -89,7 +91,9 @@ export const useValidationSchemaForRichParameters = (
if (templateParameter.validation_max < Number(val)) {
return ctx.createError({
path: ctx.path,
message: `Value must be less than ${templateParameter.validation_max}.`,
message:
parameterError(templateParameter, val) ??
`Value must be less than ${templateParameter.validation_max}.`,
});
}
} else if (
Expand All @@ -102,7 +106,9 @@ export const useValidationSchemaForRichParameters = (
) {
return ctx.createError({
path: ctx.path,
message: `Value must be between ${templateParameter.validation_min} and ${templateParameter.validation_max}.`,
message:
parameterError(templateParameter, val) ??
`Value must be between ${templateParameter.validation_min} and ${templateParameter.validation_max}.`,
});
}
}
Expand Down Expand Up @@ -149,7 +155,7 @@ export const useValidationSchemaForRichParameters = (
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: `${templateParameter.validation_error} (value does not match the pattern ${templateParameter.validation_regex})`,
message: parameterError(templateParameter, val),
});
}
}
Expand All @@ -162,3 +168,32 @@ export const useValidationSchemaForRichParameters = (
)
.required();
};

const parameterError = (
parameter: TemplateVersionParameter,
value?: string,
): string | undefined => {
if (!parameter.validation_error || !value) {
return;
}

const r = new Map<string, string>([
[
"{min}",
parameter.validation_min !== undefined
? parameter.validation_min.toString()
: "",
],
[
"{max}",
parameter.validation_max !== undefined
? parameter.validation_max.toString()
: "",
],
["{value}", value],
]);
return parameter.validation_error.replace(
/{min}|{max}|{value}/g,
(match) => r.get(match) || "",
);
};