fix: save empty template files (#22202)

The Monaco editor wrapper was only calling `onChange` if the template
file has content, but we want to allow saving an empty file.

Fixes #19721

Claude was used to port tests from jest to vitest, and for the stories.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kayla はな <mckayla@hey.com>
This commit is contained in:
Jeremy Ruppel
2026-02-25 13:43:07 -05:00
committed by GitHub
parent 4e1cedf8fd
commit 77006f241b
3 changed files with 97 additions and 19 deletions
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import * as monaco from "monaco-editor";
import { expect, fn, within } from "storybook/test";
import { MonacoEditor } from "./MonacoEditor";
const meta: Meta<typeof MonacoEditor> = {
title: "pages/TemplateVersionEditorPage/MonacoEditor",
component: MonacoEditor,
args: {},
parameters: {
layout: "fullscreen",
},
decorators: [
(Story) => (
<div style={{ height: "400px" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof MonacoEditor>;
export const Empty: Story = {};
export const WithContent: Story = {
args: {
value: `terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
}
`,
path: "main.tf",
},
};
export const WithJSON: Story = {
args: {
value: JSON.stringify({ key: "value", nested: { foo: "bar" } }, null, 2),
path: "config.json",
},
};
export const WithOnChangeHandler: Story = {
args: {
onChange: fn(),
value: "fnord",
},
// Monaco's textarea does not receive or fire events directly. Instead, we
// have to interact with the editor's model and then assert that the
// onChange callback was called with the new value.
async play({ args, canvasElement }) {
const canvas = within(canvasElement);
const editor = canvas.getByRole("textbox");
// there's only one model in the story
const model = monaco.editor.getModels()[0];
model.setValue("");
await expect(editor).toHaveValue("");
await expect(args.onChange).toHaveBeenCalledOnce();
await expect(args.onChange).toHaveBeenCalledWith("");
model.setValue("fnord");
await expect(editor).toHaveValue("fnord");
await expect(args.onChange).toHaveBeenCalledTimes(2);
await expect(args.onChange).toHaveBeenLastCalledWith("fnord");
},
};
@@ -49,7 +49,7 @@ export const MonacoEditor: FC<MonacoEditorProps> = ({
}}
path={path}
onChange={(newValue) => {
if (onChange && newValue) {
if (onChange && newValue !== undefined) {
onChange(newValue);
}
}}
@@ -34,7 +34,7 @@ const { API } = apiModule;
// For some reason this component in Jest is throwing a MUI style warning so,
// since we don't need it for this test, we can mock it out
jest.mock(
vi.mock(
"modules/templates/TemplateResourcesTable/TemplateResourcesTable",
() => ({
TemplateResourcesTable: () => <div></div>,
@@ -43,7 +43,7 @@ jest.mock(
// Occasionally, Jest encounters HTML5 canvas errors. As the MonacoEditor is not
// required for these tests, we can safely mock it.
jest.mock("pages/TemplateVersionEditorPage/MonacoEditor", () => ({
vi.mock("pages/TemplateVersionEditorPage/MonacoEditor", () => ({
MonacoEditor: (props: MonacoEditorProps) => (
<textarea
data-testid="monaco-editor"
@@ -78,27 +78,25 @@ const buildTemplateVersion = async (
user: UserEvent,
topbar: HTMLElement,
) => {
jest.spyOn(API, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
jest.spyOn(API, "createTemplateVersion").mockResolvedValue({
vi.spyOn(API, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
vi.spyOn(API, "createTemplateVersion").mockResolvedValue({
...templateVersion,
job: {
...templateVersion.job,
status: "running",
},
});
jest
.spyOn(API, "getTemplateVersionByName")
.mockResolvedValue(templateVersion);
jest
.spyOn(apiModule, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
vi.spyOn(API, "getTemplateVersionByName").mockResolvedValue(templateVersion);
vi.spyOn(apiModule, "watchBuildLogsByTemplateVersionId").mockImplementation(
(_, options) => {
options.onMessage(MockWorkspaceBuildLogs[0]);
options.onDone?.();
const wsMock = {
close: jest.fn(),
close: vi.fn(),
} as unknown;
return wsMock as WebSocket;
});
},
);
const buildButton = within(topbar).getByRole("button", {
name: "Build",
});
@@ -124,10 +122,10 @@ test("Use custom name, message and set it as active when publishing", async () =
await buildTemplateVersion(newTemplateVersion, user, topbar);
// Publish
const patchTemplateVersion = jest
const patchTemplateVersion = vi
.spyOn(API, "patchTemplateVersion")
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
const updateActiveTemplateVersion = vi
.spyOn(API, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
const publishButton = within(topbar).getByRole("button", {
@@ -170,10 +168,10 @@ test("Do not mark as active if promote is not checked", async () => {
await buildTemplateVersion(newTemplateVersion, user, topbar);
// Publish
const patchTemplateVersion = jest
const patchTemplateVersion = vi
.spyOn(API, "patchTemplateVersion")
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
const updateActiveTemplateVersion = vi
.spyOn(API, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
const publishButton = within(topbar).getByRole("button", {
@@ -215,7 +213,7 @@ test("Patch request is not send when there are no changes", async () => {
await buildTemplateVersion(newTemplateVersion, user, topbar);
// Publish
const patchTemplateVersion = jest
const patchTemplateVersion = vi
.spyOn(API, "patchTemplateVersion")
.mockResolvedValue(newTemplateVersion);
const publishButton = within(topbar).getByRole("button", {
@@ -333,7 +331,7 @@ describe.each([
askForVariables,
}) => {
it(testName, async () => {
jest.resetAllMocks();
vi.resetAllMocks();
const queryClient = new QueryClient();
queryClient.setQueryData(
templateVersionVariablesKey(MockTemplateVersion.id),