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(, { + route: "/app/deployment/repo/r1?tab=details", + nuqs: true, + }); + + // The set is keyed on the repository alone, so it has to be reachable before the first + // build exists - otherwise it could only be created after a deployment that needed it. + expect( + screen.getByRole("tab", { name: "Environment Variables" }), + ).toBeInTheDocument(); + }); + + it("falls back to Details when a stale ?tab=history reaches the empty state", () => { + currentTab = "history"; + vi.mocked(useGetRepoDetails).mockReturnValue({ + data: repoDetailsEmpty, + isLoading: false, + isError: false, + error: null, + } as never); + renderWithProviders(, { + route: "/app/deployment/repo/r1?tab=history", + nuqs: true, + }); + + // History has no tab here, so an unclamped value would select nothing and render a blank page. + expect(screen.getByText("No deployments available")).toBeInTheDocument(); + }); + it("goes back from the empty state", () => { vi.mocked(useGetRepoDetails).mockReturnValue({ data: repoDetailsEmpty, diff --git a/client/app/cross-modules/deployment/pages/repo-details.tsx b/client/app/cross-modules/deployment/pages/repo-details.tsx index 024ffc6..641e600 100644 --- a/client/app/cross-modules/deployment/pages/repo-details.tsx +++ b/client/app/cross-modules/deployment/pages/repo-details.tsx @@ -472,6 +472,14 @@ export default function RepoDetails() { repoDetails?.data?.build.length === 0 && repoDetails?.data?.repo !== null ) { + // History has nothing to show before the first build, so the tab strip here is Details plus + // Environment Variables only. A ?tab=history left over from a deployed repository would + // otherwise select a tab that does not exist and render an empty page. + const preDeployTab = + tabId === REPO_DETAILS_PROVIDERS.SECRETS + ? REPO_DETAILS_PROVIDERS.SECRETS + : REPO_DETAILS_PROVIDERS.DETAILS; + return (
@@ -485,39 +493,80 @@ export default function RepoDetails() {
- -
-
- + {/* Environment variables are deliberately reachable before the first deployment: the + server keys them on the repository alone, and the first pipeline run reads whatever + is stored at that point. Without this the set could only be created after a + deployment that already needed it. */} + +
+
+
-
-

- No deployments available -

- -

- This repository has not been deployed yet. Click the deploy - button to create your first deployment. -

+
+ + + Details + + + Environment Variables + +
-
- - + + + +
+
+ +
+
+

+ No deployments available +

+ +

+ This repository has not been deployed yet. Click the + deploy button to create your first deployment. +

+
+ +
+ +
+
+ + + + +
);