Skip to content
This repository was archived by the owner on Jul 5, 2026. It is now read-only.

Commit 4ecd7bc

Browse files
committed
fix: align config value validation with API
1 parent f3077a2 commit 4ecd7bc

4 files changed

Lines changed: 94 additions & 22 deletions

File tree

src/__tests__/integration/ProjectPage.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,36 @@ describe('ProjectPage', () => {
9999
});
100100
});
101101

102+
it('shows backend validation message when parameter creation fails', async () => {
103+
server.use(
104+
http.put(
105+
'http://localhost:5027/admin/projects/:projectId/environments/:envName/config-entries/:key',
106+
() =>
107+
HttpResponse.json(
108+
{ error: "Value must be 'true' or 'false' when contentType is 'boolean'." },
109+
{ status: 400 },
110+
),
111+
),
112+
);
113+
114+
renderProjectPage('my-app');
115+
116+
const addParamButton = await screen.findByRole('button', { name: /add parameter/i });
117+
fireEvent.click(addParamButton);
118+
119+
fireEvent.input(await screen.findByTestId('parameter-key-input'), {
120+
target: { value: 'sdfgsdfg' },
121+
});
122+
fireEvent.input(await screen.findByTestId('parameter-value-input'), {
123+
target: { value: 'not-a-boolean' },
124+
});
125+
fireEvent.click(screen.getByTestId('parameter-create-submit-button'));
126+
127+
expect(
128+
await screen.findByText("Value must be 'true' or 'false' when contentType is 'boolean'."),
129+
).toBeInTheDocument();
130+
});
131+
102132
it('shows the Projects fallback when slug does not match any project', async () => {
103133
renderProjectPage('nonexistent-project');
104134

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isValidConfigEntryValue } from "../../features/project-param-edit/ProjectParamCreateForm";
3+
4+
describe("isValidConfigEntryValue", () => {
5+
it("matches backend value validation for config entry content types", () => {
6+
expect(isValidConfigEntryValue("text", "hello")).toBe(true);
7+
expect(isValidConfigEntryValue("text", "")).toBe(false);
8+
9+
expect(isValidConfigEntryValue("number", "42")).toBe(true);
10+
expect(isValidConfigEntryValue("number", "1.5")).toBe(true);
11+
expect(isValidConfigEntryValue("number", "abc")).toBe(false);
12+
expect(isValidConfigEntryValue("number", "")).toBe(false);
13+
14+
expect(isValidConfigEntryValue("boolean", "true")).toBe(true);
15+
expect(isValidConfigEntryValue("boolean", "false")).toBe(true);
16+
expect(isValidConfigEntryValue("boolean", "")).toBe(false);
17+
expect(isValidConfigEntryValue("boolean", "yes")).toBe(false);
18+
19+
expect(isValidConfigEntryValue("json", '{"enabled":true}')).toBe(true);
20+
expect(isValidConfigEntryValue("json", "{bad")).toBe(false);
21+
});
22+
});

src/features/project-param-edit/ProjectParamCreateForm.tsx

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,54 @@ import { Select } from "../../shared/ui/select";
44
import { VisualJsonEditor } from "../../shared/ui/visual-json-editor";
55
import { FormField } from "../../widgets/auth-shell/FormField";
66

7+
type ConfigEntryContentType = "text" | "number" | "boolean" | "json";
8+
type ConfigEntryScope = "client" | "server" | "all";
9+
710
interface ProjectParamCreateFormProps {
811
onCancel: () => void;
912
onSubmit: (data: {
1013
key: string;
1114
value: string;
12-
contentType: "text" | "number" | "boolean" | "json";
13-
scope: "client" | "server" | "all";
15+
contentType: ConfigEntryContentType;
16+
scope: ConfigEntryScope;
1417
displayName: string;
1518
description: string;
1619
}) => void;
1720
isPending: boolean;
1821
}
1922

23+
export function isValidConfigEntryValue(contentType: ConfigEntryContentType, value: string): boolean {
24+
const trimmed = value.trim();
25+
26+
if (trimmed.length === 0) {
27+
return false;
28+
}
29+
30+
if (contentType === "text") {
31+
return true;
32+
}
33+
34+
try {
35+
const parsed = JSON.parse(trimmed) as unknown;
36+
37+
if (contentType === "json") {
38+
return true;
39+
}
40+
41+
if (contentType === "number") {
42+
return typeof parsed === "number" && Number.isFinite(parsed);
43+
}
44+
45+
return typeof parsed === "boolean";
46+
} catch {
47+
return false;
48+
}
49+
}
50+
2051
export function ProjectParamCreateForm(props: ProjectParamCreateFormProps) {
2152
const [cfgKey, setCfgKey] = createSignal("");
2253
const [cfgValue, setCfgValue] = createSignal("");
23-
const [cfgType, setCfgType] = createSignal<"text" | "number" | "boolean" | "json">("text");
54+
const [cfgType, setCfgType] = createSignal<ConfigEntryContentType>("text");
2455
const [cfgDisplayName, setCfgDisplayName] = createSignal("");
2556
const [cfgDescription, setCfgDescription] = createSignal("");
2657

@@ -30,21 +61,7 @@ export function ProjectParamCreateForm(props: ProjectParamCreateFormProps) {
3061
}
3162
};
3263

33-
const isValidJson = (str: string): boolean => {
34-
try {
35-
JSON.parse(str);
36-
return true;
37-
} catch {
38-
return false;
39-
}
40-
};
41-
42-
const isAddInvalid = () => {
43-
if (cfgType() === "json") {
44-
return !isValidJson(cfgValue());
45-
}
46-
return false;
47-
};
64+
const isAddInvalid = () => !isValidConfigEntryValue(cfgType(), cfgValue());
4865

4966
const handleSubmit = (e: Event) => {
5067
e.preventDefault();
@@ -92,7 +109,7 @@ export function ProjectParamCreateForm(props: ProjectParamCreateFormProps) {
92109
<Select
93110
value={cfgType()}
94111
onChange={(val: string) => {
95-
setCfgType(val as "text" | "number" | "boolean" | "json");
112+
setCfgType(val as ConfigEntryContentType);
96113
setCfgValue("");
97114
}}
98115
options={["text", "number", "boolean", "json"]}

src/pages/projects/ProjectPage.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import type {
3939
Project
4040
} from "../../types";
4141

42+
const errorMessage = (caught: unknown, fallback: string) =>
43+
caught instanceof Error && caught.message ? caught.message : fallback;
44+
4245
export default function ProjectPage() {
4346
const params = useParams<{ slug: string }>();
4447
const queryClient = useQueryClient();
@@ -185,7 +188,7 @@ export default function ProjectPage() {
185188
setShowConfigForm(false);
186189
addToast(MSG.PARAM_CREATED, "success");
187190
},
188-
onError: () => addToast(MSG.PARAM_CREATE_FAILED, "error")
191+
onError: error => addToast(errorMessage(error, MSG.PARAM_CREATE_FAILED), "error")
189192
}));
190193

191194
// Param deletion mutation
@@ -251,7 +254,7 @@ export default function ProjectPage() {
251254
setEditingEntry(null);
252255
addToast(MSG.PARAM_UPDATED, "success");
253256
},
254-
onError: () => addToast(MSG.PARAM_UPDATE_FAILED, "error")
257+
onError: error => addToast(errorMessage(error, MSG.PARAM_UPDATE_FAILED), "error")
255258
}));
256259

257260
// Bulk import callback
@@ -293,7 +296,7 @@ export default function ProjectPage() {
293296
setEditingEntry(entry);
294297
addToast(MSG.PARAM_ROLLED_BACK, "success");
295298
},
296-
onError: () => addToast(MSG.PARAM_ROLLBACK_FAILED, "error")
299+
onError: error => addToast(errorMessage(error, MSG.PARAM_ROLLBACK_FAILED), "error")
297300
}));
298301

299302
const handleRollbackVersion = (version: ConfigEntryVersion) => {

0 commit comments

Comments
 (0)