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
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"dependencies": {
"@accordproject/concerto-core": "^3.25.7",
"@accordproject/concerto-cto": "^3.25.7",
"@accordproject/markdown-common": "^0.17.2",
"@accordproject/markdown-template": "^0.17.2",
"@accordproject/markdown-transform": "^0.17.2",
Expand Down Expand Up @@ -110,3 +111,4 @@
"Safari >= 10"
]
}

41 changes: 41 additions & 0 deletions src/components/ConcertoFormatButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { message } from "antd";
import { MdFormatAlignLeft } from "react-icons/md";
import useAppStore from "../store/store";
import { formatConcertoModel } from "../utils/formatConcertoModel";

interface ConcertoFormatButtonProps {
disabled?: boolean;
}

const ConcertoFormatButton = ({ disabled = false }: ConcertoFormatButtonProps) => {
const { editorModelCto, setEditorModelCto, setModelCto } = useAppStore((state) => ({
editorModelCto: state.editorModelCto,
setEditorModelCto: state.setEditorModelCto,
setModelCto: state.setModelCto,
}));

const handleFormat = async () => {
try {
const formatted = formatConcertoModel(editorModelCto);
setEditorModelCto(formatted);
await setModelCto(formatted);
} catch {
void message.error("Fix Concerto syntax errors before formatting.");
}
};

return (
<button
onClick={() => void handleFormat()}
className="px-1 pt-1 border-gray-300 bg-white hover:bg-gray-200 rounded shadow-md"
disabled={disabled || !editorModelCto.trim()}
title="Format Concerto"
aria-label="Format Concerto"
type="button"
>
<MdFormatAlignLeft size={16} />
</button>
);
};

export default ConcertoFormatButton;
3 changes: 3 additions & 0 deletions src/pages/MainContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useAppStore from "../store/store";
import { AIChatPanel } from "../components/AIChatPanel";
import ProblemPanel from "../components/ProblemPanel";
import SampleDropdown from "../components/SampleDropdown";
import ConcertoFormatButton from "../components/ConcertoFormatButton";
import { useState, useRef } from "react";
import { TemplateMarkdownToolbar } from "../components/TemplateMarkdownToolbar";
import { MarkdownEditorProvider } from "../contexts/MarkdownEditorContext";
Expand Down Expand Up @@ -150,6 +151,7 @@ const MainContainer = () => {
<span>Concerto Model</span>
<SampleDropdown setLoading={setLoading} />
</div>
<ConcertoFormatButton disabled={isModelCollapsed} />
</div>
{!isModelCollapsed && (
<div className="main-container-editor-content" style={{ backgroundColor }}>
Expand Down Expand Up @@ -275,3 +277,4 @@ const MainContainer = () => {
};

export default MainContainer;

59 changes: 59 additions & 0 deletions src/tests/components/ConcertoFormatButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ConcertoFormatButton from "../../components/ConcertoFormatButton";
import { formatConcertoModel } from "../../utils/formatConcertoModel";
import { message } from "antd";

const hoisted = vi.hoisted(() => ({
storeState: {
editorModelCto: "namespace org.example\nasset Sample identified by sampleId {\no String sampleId\n}",
setEditorModelCto: vi.fn(),
setModelCto: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock("../../store/store", () => ({
default: vi.fn((selector) => selector(hoisted.storeState)),
}));

vi.mock("../../utils/formatConcertoModel", () => ({
formatConcertoModel: vi.fn(),
}));

vi.mock("antd", () => ({
message: {
error: vi.fn(),
},
}));

describe("ConcertoFormatButton", () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.storeState.editorModelCto = "namespace org.example\nasset Sample identified by sampleId {\no String sampleId\n}";
});

it("formats and updates editor + model state", async () => {
vi.mocked(formatConcertoModel).mockReturnValue("formatted-cto");

render(<ConcertoFormatButton />);
fireEvent.click(screen.getByRole("button", { name: /format concerto/i }));

expect(formatConcertoModel).toHaveBeenCalledWith(hoisted.storeState.editorModelCto);
expect(hoisted.storeState.setEditorModelCto).toHaveBeenCalledWith("formatted-cto");
expect(hoisted.storeState.setModelCto).toHaveBeenCalledWith("formatted-cto");
});

it("shows error toast and does not mutate when formatting fails", async () => {
vi.mocked(formatConcertoModel).mockImplementation(() => {
throw new Error("invalid cto");
});

render(<ConcertoFormatButton />);
fireEvent.click(screen.getByRole("button", { name: /format concerto/i }));

expect(hoisted.storeState.setEditorModelCto).not.toHaveBeenCalled();
expect(hoisted.storeState.setModelCto).not.toHaveBeenCalled();
expect(message.error).toHaveBeenCalledWith("Fix Concerto syntax errors before formatting.");
});
});

29 changes: 29 additions & 0 deletions src/tests/utils/formatConcertoModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { Parser } from "@accordproject/concerto-cto";
import { formatConcertoModel } from "../../utils/formatConcertoModel";

const unformattedCto = `namespace org.example
asset Sample identified by sampleId {o String sampleId o String name optional}`;

describe("formatConcertoModel", () => {
it("formats valid CTO while preserving model semantics", () => {
const formatted = formatConcertoModel(unformattedCto);

const inputAst = Parser.parse(unformattedCto, undefined, { skipLocationNodes: true });
const outputAst = Parser.parse(formatted, undefined, { skipLocationNodes: true });

expect(outputAst).toEqual(inputAst);
});

it("is stable when formatting already formatted CTO", () => {
const once = formatConcertoModel(unformattedCto);
const twice = formatConcertoModel(once);

expect(twice).toBe(once);
});

it("throws for invalid CTO", () => {
expect(() => formatConcertoModel("namespace org.example asset")).toThrow();
});
});

7 changes: 7 additions & 0 deletions src/utils/formatConcertoModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Parser, Printer } from "@accordproject/concerto-cto";

export function formatConcertoModel(cto: string): string {
const ast = Parser.parse(cto);
return Printer.toCTO(ast);
}

Loading