Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions client/app/cross-modules/deployment/pages/repo-details.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<RepoDetails />, {
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(<RepoDetails />, {
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,
Expand Down
109 changes: 79 additions & 30 deletions client/app/cross-modules/deployment/pages/repo-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="mx-auto pb-8">
<div className="mt-2 space-y-2">
Expand All @@ -485,39 +493,80 @@ export default function RepoDetails() {
</div>
</div>

<Card>
<div className="flex h-auto flex-col items-center justify-center self-stretch rounded-sm bg-background px-1 py-5">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-secondary">
<GitBranch className="h-8 w-8 text-low-emphasis" />
{/* 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. */}
<Tabs value={preDeployTab} onValueChange={setTabId}>
<div className="mb-5 mt-6 flex items-center justify-between rounded text-base">
<div className="md:hidden">
<Select value={preDeployTab} onValueChange={setTabId}>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="details">Details</SelectItem>
<SelectItem value="secrets">
Environment Variables
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-center gap-2">
<h3 className="py-4 text-xl font-semibold text-high-emphasis">
No deployments available
</h3>

<p className="max-w-md items-center text-center text-sm text-low-emphasis">
This repository has not been deployed yet. Click the deploy
button to create your first deployment.
</p>
<div className="hidden md:block">
<TabsList>
<TabsTrigger value="details" className="w-20">
Details
</TabsTrigger>
<TabsTrigger value="secrets" className="px-4">
Environment Variables
</TabsTrigger>
</TabsList>
</div>
<Button
onClick={handleDeploy}
disabled={isProcessing}
className="mt-4">
Deploy Now
</Button>
</div>
<DeploymentSettingsModal
isOpen={isSettingsModalOpen}
onClose={handleCloseModal}
repoId={repoId}
isDeploymentFlow={isDeploymentSettingsForDeploy}
onDeploy={handleDeployFromSettings}
isDeploying={isDeploying}
pageNumber={buildPageNumber}
pageSize={buildPageSize}
/>
</Card>

<TabsContent value="details">
<Card>
<div className="flex h-auto flex-col items-center justify-center self-stretch rounded-sm bg-background px-1 py-5">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-secondary">
<GitBranch className="h-8 w-8 text-low-emphasis" />
</div>
<div className="flex flex-col items-center gap-2">
<h3 className="py-4 text-xl font-semibold text-high-emphasis">
No deployments available
</h3>

<p className="max-w-md items-center text-center text-sm text-low-emphasis">
This repository has not been deployed yet. Click the
deploy button to create your first deployment.
</p>
</div>
<Button
onClick={handleDeploy}
disabled={isProcessing}
className="mt-4">
Deploy Now
</Button>
</div>
<DeploymentSettingsModal
isOpen={isSettingsModalOpen}
onClose={handleCloseModal}
repoId={repoId}
isDeploymentFlow={isDeploymentSettingsForDeploy}
onDeploy={handleDeployFromSettings}
isDeploying={isDeploying}
pageNumber={buildPageNumber}
pageSize={buildPageSize}
/>
</Card>
</TabsContent>

<TabsContent value="secrets">
<SecretsTab
repoId={repoId}
repoName={repoDetails?.data?.repo.repoName}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
Expand Down
Loading