diff --git a/__tests__/integration/plone_add_single_block.test.ts b/__tests__/integration/plone_add_single_block.test.ts index 672090d..02ab515 100644 --- a/__tests__/integration/plone_add_single_block.test.ts +++ b/__tests__/integration/plone_add_single_block.test.ts @@ -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 diff --git a/news/+afterblockid.feature b/news/+afterblockid.feature new file mode 100644 index 0000000..3d67046 --- /dev/null +++ b/news/+afterblockid.feature @@ -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 diff --git a/src/tools/plone_add_single_block.ts b/src/tools/plone_add_single_block.ts index 5a16544..f1690b6 100644 --- a/src/tools/plone_add_single_block.ts +++ b/src/tools/plone_add_single_block.ts @@ -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 ( @@ -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 @@ -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