Skip to content
Open
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
160 changes: 160 additions & 0 deletions __tests__/integration/plone_add_single_block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,166 @@ describe("plone_add_single_block", () => {
expect(Nock.isDone()).toBe(true);
});

it("should add a block directly after a specified block using afterBlockId", async () => {
mockServer.mockContentGet(testPath, mockContent);

const mockContentAfterAnchor = {
...mockContent,
message: "Block added after existing block",
};
mockServer.mockContentUpdate(
testPath,
(body: { blocks: Blocks; blocks_layout: { items: string[] } }) => {
const newBlockId = body.blocks_layout.items[1]; // Inserted after existing-1
expect(newBlockId).toBeDefined();
if (newBlockId !== undefined) {
expect(body.blocks[newBlockId]["@type"]).toBe("slate");
expect(body.blocks[newBlockId].plaintext).toBe("After anchor");
}
expect(body.blocks_layout.items[0]).toBe("block-existing-1");
expect(body.blocks_layout.items[1]).toBe(newBlockId);
expect(body.blocks_layout.items[2]).toBe("block-existing-2");
return true;
},
mockContentAfterAnchor,
);

const args = {
path: testPath,
blockType: "text",
blockData: { text: "After anchor" },
afterBlockId: "block-existing-1",
};

const result = await ploneAddSingleBlock.handler(args, mockExtra);
expect(result.content[0].text).toEqual(
JSON.stringify(mockContentAfterAnchor, null, 2),
);
expect(Nock.isDone()).toBe(true);
});

it("should append at the end when afterBlockId is the last block in the layout", async () => {
mockServer.mockContentGet(testPath, mockContent);

const mockContentAfterAppend = {
...mockContent,
message: "Block appended after last block",
};
mockServer.mockContentUpdate(
testPath,
(body: { blocks: Blocks; blocks_layout: { items: string[] } }) => {
const newBlockId = body.blocks_layout.items[2];
expect(newBlockId).toBeDefined();
expect(body.blocks_layout.items).toEqual([
"block-existing-1",
"block-existing-2",
newBlockId,
]);
return true;
},
mockContentAfterAppend,
);

const args = {
path: testPath,
blockType: "text",
blockData: { text: "Append after last" },
afterBlockId: "block-existing-2",
};

const result = await ploneAddSingleBlock.handler(args, mockExtra);
expect(result.content[0].text).toEqual(
JSON.stringify(mockContentAfterAppend, null, 2),
);
expect(Nock.isDone()).toBe(true);
});

it("should insert a block directly after the title block using afterBlockId", async () => {
const contentWithTitle = {
...mockContent,
blocks: {
"block-title": { "@type": "title" },
"block-existing-1": { "@type": "text", plaintext: "Existing block 1" },
"block-existing-2": { "@type": "text", plaintext: "Existing block 2" },
},
blocks_layout: {
items: ["block-title", "block-existing-1", "block-existing-2"],
},
};
mockServer.mockContentGet(testPath, contentWithTitle);

const mockContentAfterTitle = {
...contentWithTitle,
message: "Block added after title",
};
mockServer.mockContentUpdate(
testPath,
(body: { blocks: Blocks; blocks_layout: { items: string[] } }) => {
const newBlockId = body.blocks_layout.items[1];
expect(newBlockId).toBeDefined();
if (newBlockId !== undefined) {
expect(body.blocks[newBlockId].plaintext).toBe("Right after title");
}
expect(body.blocks_layout.items).toEqual([
"block-title",
newBlockId,
"block-existing-1",
"block-existing-2",
]);
return true;
},
mockContentAfterTitle,
);

const args = {
path: testPath,
blockType: "text",
blockData: { text: "Right after title" },
afterBlockId: "block-title",
};

const result = await ploneAddSingleBlock.handler(args, mockExtra);
expect(result.content[0].text).toEqual(
JSON.stringify(mockContentAfterTitle, null, 2),
);
expect(Nock.isDone()).toBe(true);
});

it("should throw an error if afterBlockId does not exist in the layout", async () => {
mockServer.mockContentGet(testPath, mockContent);

const args = {
path: testPath,
blockType: "text",
blockData: { text: "Some text" },
afterBlockId: "block-does-not-exist",
};

await expect(
ploneAddSingleBlock.handler(args, mockExtra),
).rejects.toThrow("[AddBlock] Block ID not found in the page layout");
expect(Nock.pendingMocks()).toHaveLength(0); // No patch request should be made
});

it("should throw an error if both position and afterBlockId are provided", async () => {
mockServer.mockContentGet(testPath, mockContent);

const args = {
path: testPath,
blockType: "text",
blockData: { text: "Some text" },
position: 1,
afterBlockId: "block-existing-1",
};

await expect(
ploneAddSingleBlock.handler(args, mockExtra),
).rejects.toThrow(
"[AddBlock] Provide either 'position' or 'afterBlockId', not both",
);
expect(Nock.pendingMocks()).toHaveLength(0); // No patch request should be made
});

it("should throw an error if Plone client is not configured", async () => {
const service = sessionManager.getSession(sessionId);
service.client = null; // Ensure client is not configured
Expand Down
1 change: 1 addition & 0 deletions news/+afterblockid.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`plone_add_single_block` now accepts an optional `afterBlockId` argument to insert the new block directly after an existing block (IDs are returned by `plone_get_content`). When both `position` and `afterBlockId` are provided the tool errors. @nileshgulia1
27 changes: 23 additions & 4 deletions src/tools/plone_add_single_block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ const inputSchema = z.object({
.number()
.optional()
.describe("Position to insert the block (optional, defaults to end)"),
afterBlockId: z
.string()
.optional()
.describe(
"ID of an existing block to insert the new block directly after. Get block IDs from plone_get_content: every key in the 'blocks' object is a block ID, and 'blocks_layout.items' lists them in rendering order. Cannot be combined with 'position'. Optional; if omitted, 'position' or append-to-end is used.",
),
});

export const ploneAddSingleBlock = {
config: {
name: "plone_add_single_block",
description:
"Adds a single new block to an existing content item without replacing other blocks. Specify the block type, data, and optional position. Example: plone_add_single_block({path: '/my-page', blockType: 'slate', blockData: {text: 'New paragraph'}})",
"Adds a single new block to an existing content item without replacing other blocks. Specify the block type, data, and either position or afterBlockId (insert directly after an existing block; block IDs are returned by plone_get_content). Example: plone_add_single_block({path: '/my-page', blockType: 'slate', blockData: {text: 'New paragraph'}})",
inputSchema,
},
handler: async (
Expand All @@ -37,7 +43,7 @@ export const ploneAddSingleBlock = {
try {
const sessionId = extra.sessionId || "default";
const service = sessionManager.getSession(sessionId);
const { path, blockType, position, blockData } = args;
const { path, blockType, position, afterBlockId, blockData } = args;
const client = service.getClient();

// First get the current content
Expand Down Expand Up @@ -84,8 +90,21 @@ export const ploneAddSingleBlock = {
);
}

// Insert at specified position or at the end
if (
// Insert relative to an existing block, at a specified position, or at the end
if (afterBlockId !== undefined) {
if (position !== undefined) {
throw new Error(
"Provide either 'position' or 'afterBlockId', not both",
);
}
const anchorIndex = blocks_layout.items.indexOf(afterBlockId);
if (anchorIndex === -1) {
throw new Error(
`Block ID not found in the page layout: ${afterBlockId}. Get existing block IDs from plone_get_content.`,
);
}
blocks_layout.items.splice(anchorIndex + 1, 0, blockId);
} else if (
position !== undefined &&
position >= 0 &&
position <= blocks_layout.items.length
Expand Down
Loading