diff --git a/client/app/cross-modules/deployment/components/repo-secrets/secret-form-modal.test.tsx b/client/app/cross-modules/deployment/components/repo-secrets/secret-form-modal.test.tsx
index da34620..3c6847f 100644
--- a/client/app/cross-modules/deployment/components/repo-secrets/secret-form-modal.test.tsx
+++ b/client/app/cross-modules/deployment/components/repo-secrets/secret-form-modal.test.tsx
@@ -133,6 +133,47 @@ describe("SecretFormModal", () => {
expect(screen.getByRole("textbox", { name: /json/i })).toBeInTheDocument();
});
+ it("saves pasted JSON on a new set, where the rows are still blank", async () => {
+ const save = mockSave();
+ renderModal();
+
+ await userEvent.click(screen.getByRole("radio", { name: /paste json/i }));
+
+ // fireEvent.change rather than userEvent.type: "{" and "}" are control sequences in
+ // userEvent's keyboard grammar, so typing raw JSON does not produce the text it looks like.
+ fireEvent.change(screen.getByRole("textbox", { name: /json/i }), {
+ target: { value: '{"API_KEY":"abc"}' },
+ });
+ await userEvent.click(screen.getByRole("button", { name: /save variables/i }));
+
+ // The blank row the key/value editor starts with must not fail the JSON submit: its error
+ // would land on a field this mode does not render, leaving Save looking inert.
+ await waitFor(() =>
+ expect(save.mutateAsync).toHaveBeenCalledWith({
+ repoId: REPO_ID,
+ secrets: { API_KEY: "abc" },
+ }),
+ );
+ });
+
+ it("saves pasted JSON when editing an existing set", async () => {
+ const save = mockSave();
+ renderModal({ EXISTING: "v" });
+
+ await userEvent.click(screen.getByRole("radio", { name: /paste json/i }));
+ fireEvent.change(screen.getByRole("textbox", { name: /json/i }), {
+ target: { value: '{"API_KEY":"abc"}' },
+ });
+ await userEvent.click(screen.getByRole("button", { name: /save variables/i }));
+
+ await waitFor(() =>
+ expect(save.mutateAsync).toHaveBeenCalledWith({
+ repoId: REPO_ID,
+ secrets: { API_KEY: "abc" },
+ }),
+ );
+ });
+
it("rejects a non-string JSON value", async () => {
const save = mockSave();
renderModal();
diff --git a/client/app/cross-modules/deployment/components/repo-secrets/secret-form-values.ts b/client/app/cross-modules/deployment/components/repo-secrets/secret-form-values.ts
index 096e8c1..5ed5c69 100644
--- a/client/app/cross-modules/deployment/components/repo-secrets/secret-form-values.ts
+++ b/client/app/cross-modules/deployment/components/repo-secrets/secret-form-values.ts
@@ -44,11 +44,18 @@ const valueField = z.string();
*
* Kept as a single schema rather than two so the form keeps one resolver across a mode switch;
* swapping resolvers mid-edit would discard the errors already on screen.
+ *
+ * The object shape deliberately accepts any string as a row key: shape validation runs for both
+ * modes, so enforcing `keyField` there failed the whole form on the rows the user is not editing.
+ * In JSON mode those rows still hold whatever the key/value editor was last seeded with - for a
+ * new set, one blank row - and a blank key made every JSON save fail on `rows.0.key`, a field
+ * that is not on screen in that mode. Submit became a no-op with nothing to explain it. The key
+ * rules therefore live in the refinement below, which only reaches them in key/value mode.
*/
export const secretFormSchema = z
.object({
mode: z.enum(["kv", "json"]),
- rows: z.array(z.object({ key: keyField, value: valueField })),
+ rows: z.array(z.object({ key: z.string(), value: valueField })),
json: z.string(),
})
.superRefine((values, ctx) => {
@@ -66,6 +73,28 @@ export const secretFormSchema = z
return;
}
+ // Each key is checked against the shared field rules and reported on its own row, so the
+ // wording still cannot drift between the two modes.
+ let hasInvalidKey = false;
+
+ values.rows.forEach((row, index) => {
+ const result = keyField.safeParse(row.key);
+
+ if (result.success) return;
+
+ hasInvalidKey = true;
+
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["rows", index, "key"],
+ message: result.error.issues[0].message,
+ });
+ });
+
+ // Duplicate and size checks read the keys, so they are only meaningful once every key is
+ // valid; running them on a half-typed set would stack a second error onto the same row.
+ if (hasInvalidKey) return;
+
if (values.rows.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
diff --git a/client/app/cross-modules/deployment/pages/repo-details.test.tsx b/client/app/cross-modules/deployment/pages/repo-details.test.tsx
index f15a91b..2bcbbae 100644
--- a/client/app/cross-modules/deployment/pages/repo-details.test.tsx
+++ b/client/app/cross-modules/deployment/pages/repo-details.test.tsx
@@ -166,6 +166,42 @@ describe("RepoDetails page", () => {
expect(screen.getByText("acme/app")).toBeInTheDocument();
});
+ it("offers the Environment Variables tab before the first deployment", () => {
+ vi.mocked(useGetRepoDetails).mockReturnValue({
+ data: repoDetailsEmpty,
+ isLoading: false,
+ isError: false,
+ error: null,
+ } as never);
+ renderWithProviders(
- This repository has not been deployed yet. Click the deploy - button to create your first deployment. -
++ This repository has not been deployed yet. Click the + deploy button to create your first deployment. +
+