From 02dc77e03ca56c53f87c48b3798e576bab90d603 Mon Sep 17 00:00:00 2001 From: Adam-it Date: Wed, 7 Oct 2026 22:57:20 +0200 Subject: [PATCH 1/5] Updates navigation node commands navigation/menuState API --- .../spo/navigation/navigation-node-add.mdx | 75 +-- .../spo/navigation/navigation-node-get.mdx | 91 ++-- .../spo/navigation/navigation-node-set.mdx | 9 +- .../spo/commands/navigation/NavigationNode.ts | 14 +- .../navigation/navigation-node-add.spec.ts | 467 +++++++++--------- .../navigation/navigation-node-add.ts | 231 +++------ .../navigation/navigation-node-get.spec.ts | 157 ++++-- .../navigation/navigation-node-get.ts | 18 +- .../navigation/navigation-node-set.spec.ts | 240 +++++---- .../navigation/navigation-node-set.ts | 188 ++----- src/utils/spo.spec.ts | 38 ++ src/utils/spo.ts | 44 +- 12 files changed, 817 insertions(+), 755 deletions(-) diff --git a/docs/docs/cmd/spo/navigation/navigation-node-add.mdx b/docs/docs/cmd/spo/navigation/navigation-node-add.mdx index e599a7f1187..1d6de8e90c7 100644 --- a/docs/docs/cmd/spo/navigation/navigation-node-add.mdx +++ b/docs/docs/cmd/spo/navigation/navigation-node-add.mdx @@ -30,9 +30,6 @@ m365 spo navigation node add [options] `--parentNodeId [parentNodeId]` : ID of the node below which the node should be added. Specify either `location` or `parentNodeId` but not both. -`--isExternal` -: Set, if the navigation node points to an external URL. - `--audienceIds [audienceIds]` : Comma-separated list of group IDs that will be used for audience targeting. The limit is 10 ids per navigation node. @@ -57,7 +54,7 @@ m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team- Add a navigation node pointing to an external page to the quick launch. ```sh -m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team-a --location QuickLaunch --title "About us" --url https://contoso.com/about-us --isExternal +m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team-a --location QuickLaunch --title "About us" --url https://contoso.com/about-us ``` Add a navigation node below an existing node and opens it in a new window. @@ -72,6 +69,12 @@ Add a navigation node to the top navigation which is audience targetted and open m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team-a --location TopNavigationBar --title About --url /sites/team-s/sitepages/about.aspx --audienceIds "7aa4a1ca-4035-4f2f-bac7-7beada59b5ba,4bbf236f-a131-4019-b4a2-315902fcfa3a" --openInNewWindow ``` +Add a linkless label to the quick launch. + +```sh +m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team-a --location QuickLaunch --title Resources +``` + ## Response @@ -83,13 +86,18 @@ m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team- "7aa4a1ca-4035-4f2f-bac7-7beada59b5ba" ], "CurrentLCID": 1033, - "Id": 2030, - "IsDocLib": true, - "IsExternal": true, - "IsVisible": true, - "ListTemplateType": 0, + "CustomProperties": [], + "FriendlyUrlSegment": "", + "IsDeleted": false, + "IsHidden": false, + "IsTitleForExistingLanguage": false, + "Key": "2030", + "Nodes": [], + "NodeType": 0, + "OpenInNewWindow": true, + "SimpleUrl": "https://contoso.sharepoint.com", "Title": "Navigation Link", - "Url": "https://contoso.sharepoint.com" + "Translations": [] } ``` @@ -97,45 +105,52 @@ m365 spo navigation node add --webUrl https://contoso.sharepoint.com/sites/team- ```text - AudienceIds : ["7aa4a1ca-4035-4f2f-bac7-7beada59b5ba"] - CurrentLCID : 1033 - Id : 2031 - IsDocLib : true - IsExternal : true - IsVisible : true - ListTemplateType: 0 - Title : Navigation Link - Url : https://contoso.sharepoint.com + AudienceIds : ["7aa4a1ca-4035-4f2f-bac7-7beada59b5ba"] + CurrentLCID : 1033 + CustomProperties : [] + FriendlyUrlSegment : + IsDeleted : false + IsHidden : false + IsTitleForExistingLanguage: false + Key : 2030 + NodeType : 0 + Nodes : [] + OpenInNewWindow : true + SimpleUrl : https://contoso.sharepoint.com + Title : Navigation Link + Translations : [] ``` ```csv - AudienceIds,CurrentLCID,Id,IsDocLib,IsExternal,IsVisible,ListTemplateType,Title,Url - "[""7aa4a1ca-4035-4f2f-bac7-7beada59b5ba""]",1033,2032,1,1,1,0,Navigation Link,https://contoso.sharepoint.com + CurrentLCID,FriendlyUrlSegment,IsDeleted,IsHidden,IsTitleForExistingLanguage,Key,NodeType,OpenInNewWindow,SimpleUrl,Title + 1033,,0,0,0,2030,0,1,https://contoso.sharepoint.com,Navigation Link ``` ```md - # spo navigation node get --webUrl "https://contoso.sharepoint.com/sites/team-a" --location "TopNavigationBar" --title "Navigation Link" --url "https://contoso.sharepoint.com" + # spo navigation node add --webUrl "https://contoso.sharepoint.com/sites/team-a" --location "TopNavigationBar" --title "Navigation Link" --url "https://contoso.sharepoint.com" --openInNewWindow "true" - Date: 2/20/2023 + Date: 10/7/2026 - ## Navigation Link (2030) + ## Navigation Link Property | Value ---------|------- CurrentLCID | 1033 - Id | 2030 - IsDocLib | true - IsExternal | false - IsVisible | true - ListTemplateType | 0 + FriendlyUrlSegment | + IsDeleted | false + IsHidden | false + IsTitleForExistingLanguage | false + Key | 2030 + NodeType | 0 + OpenInNewWindow | true + SimpleUrl | https://contoso.sharepoint.com Title | Navigation Link - Url | https://contoso.sharepoint.com ``` diff --git a/docs/docs/cmd/spo/navigation/navigation-node-get.mdx b/docs/docs/cmd/spo/navigation/navigation-node-get.mdx index 0c4870dd20b..7b88c34411c 100644 --- a/docs/docs/cmd/spo/navigation/navigation-node-get.mdx +++ b/docs/docs/cmd/spo/navigation/navigation-node-get.mdx @@ -58,31 +58,39 @@ m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marke ```json { - "Children": [ + "AudienceIds": [ + "0d718612-8407-4d6b-833c-6891a553354f" + ], + "CurrentLCID": 1033, + "CustomProperties": [], + "FriendlyUrlSegment": "", + "IsDeleted": false, + "IsHidden": false, + "IsTitleForExistingLanguage": false, + "Key": "2209", + "Nodes": [ { - "AudienceIds": null, + "AudienceIds": [], "CurrentLCID": 1033, - "Id": 2033, - "IsDocLib": true, - "IsExternal": true, - "IsVisible": true, - "ListTemplateType": 0, + "CustomProperties": [], + "FriendlyUrlSegment": "", + "IsDeleted": false, + "IsHidden": false, + "IsTitleForExistingLanguage": false, + "Key": "2210", + "Nodes": [], + "NodeType": 0, + "OpenInNewWindow": true, + "SimpleUrl": "https://microsoft365.com", "Title": "Microsoft 365", - "Url": "https://microsoft365.com", - "Children": [] + "Translations": [] } ], - "AudienceIds": [ - "0d718612-8407-4d6b-833c-6891a553354f" - ], - "CurrentLCID": 1033, - "Id": 2209, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 100, + "NodeType": 0, + "OpenInNewWindow": null, + "SimpleUrl": "/sites/Marketing/Lists/Work Status/AllItems.aspx", "Title": "Work Status", - "Url": "/sites/Marketing/Lists/Work Status/AllItems.aspx" + "Translations": [] } ``` @@ -90,24 +98,28 @@ m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marke ```text - Children : [{ AudienceIds: null, CurrentLCID: 1033, Id: 2033, IsDocLib: true, IsExternal: true, IsVisible: true, ListTemplateType: 0, Title: "Microsoft 365", Url: "https://microsoft365.com", Children: [] }] - AudienceIds : ["0d718612-8407-4d6b-833c-6891a553354f"] - CurrentLCID : 1033 - Id : 2209 - IsDocLib : true - IsExternal : false - IsVisible : true - ListTemplateType: 100 - Title : Work Status - Url : /sites/Marketing/Lists/Work Status/AllItems.aspx + AudienceIds : ["0d718612-8407-4d6b-833c-6891a553354f"] + CurrentLCID : 1033 + CustomProperties : [] + FriendlyUrlSegment : + IsDeleted : false + IsHidden : false + IsTitleForExistingLanguage: false + Key : 2209 + NodeType : 0 + Nodes : [{"AudienceIds":[],"CurrentLCID":1033,"CustomProperties":[],"FriendlyUrlSegment":"","IsDeleted":false,"IsHidden":false,"IsTitleForExistingLanguage":false,"Key":"2210","Nodes":[],"NodeType":0,"OpenInNewWindow":true,"SimpleUrl":"https://microsoft365.com","Title":"Microsoft 365","Translations":[]}] + OpenInNewWindow : null + SimpleUrl : /sites/Marketing/Lists/Work Status/AllItems.aspx + Title : Work Status + Translations : [] ``` ```csv - CurrentLCID,Id,IsDocLib,IsExternal,IsVisible,ListTemplateType,Title,Url - 1033,2209,1,,1,100,Work Status,/sites/Marketing/Lists/Work Status/AllItems.aspx + CurrentLCID,FriendlyUrlSegment,IsDeleted,IsHidden,IsTitleForExistingLanguage,Key,NodeType,OpenInNewWindow,SimpleUrl,Title + 1033,,0,0,0,2209,0,,/sites/Marketing/Lists/Work Status/AllItems.aspx,Work Status ``` @@ -116,20 +128,21 @@ m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marke ```md # spo navigation node get --webUrl "https://contoso.sharepoint.com/sites/Marketing" --id "2209" - Date: 1/29/2023 + Date: 10/7/2026 - ## Work Status (2209) + ## Work Status Property | Value ---------|------- CurrentLCID | 1033 - Id | 2209 - IsDocLib | true - IsExternal | false - IsVisible | true - ListTemplateType | 100 + FriendlyUrlSegment | + IsDeleted | false + IsHidden | false + IsTitleForExistingLanguage | false + Key | 2209 + NodeType | 0 + SimpleUrl | /sites/Marketing/Lists/Work Status/AllItems.aspx Title | Work Status - Url | /sites/Marketing/Lists/Work Status/AllItems.aspx ``` diff --git a/docs/docs/cmd/spo/navigation/navigation-node-set.mdx b/docs/docs/cmd/spo/navigation/navigation-node-set.mdx index fadff047b6e..0adb0040919 100644 --- a/docs/docs/cmd/spo/navigation/navigation-node-set.mdx +++ b/docs/docs/cmd/spo/navigation/navigation-node-set.mdx @@ -26,10 +26,7 @@ m365 spo navigation node set [options] : New URL of the navigation node. Specify an empty string `""` to update the navigation label to a linkless label. `--audienceIds [audienceIds]` -: Comma-separated list of group IDs that will be used for audience targeting. Speficy an empty string `""` to clear this value. The limit is 10 ids per navigation node. - -`--isExternal [isExternal]` -: Whether the navigation node points to an external URL. Valid values: `true` or `false`. +: Comma-separated list of group IDs that will be used for audience targeting. Specify an empty string `""` to clear this value. The limit is 10 ids per navigation node. `--openInNewWindow [openInNewWindow]` : Open the URL in a new window. Valid values: `true` or `false`. @@ -41,6 +38,8 @@ m365 spo navigation node set [options] To enable/disable audience targeting for the navigation bar, use the [`spo web set`](../web/web-set.mdx) command. +SharePoint doesn't allow changing the URL of a navigation node pointing to a page in the current site into an external URL. In that case, the URL isn't updated and no error is returned. To point such a node to an external URL, remove the node using [`spo navigation node remove`](./navigation-node-remove.mdx) and add it again using [`spo navigation node add`](./navigation-node-add.mdx). + ## Examples Updates the title of a navigation node @@ -52,7 +51,7 @@ m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marke Updates the URL of a navigation node ```sh -m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --url "https://www.microsoft.com" --isExternal true +m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --url "https://www.microsoft.com" ``` Updates audience targeting of a navigation node with 3 groups diff --git a/src/m365/spo/commands/navigation/NavigationNode.ts b/src/m365/spo/commands/navigation/NavigationNode.ts index acea9082853..13ecee909d5 100644 --- a/src/m365/spo/commands/navigation/NavigationNode.ts +++ b/src/m365/spo/commands/navigation/NavigationNode.ts @@ -1,15 +1,3 @@ -export interface NavigationNode { - Id: number; - IsDocLib: boolean; - IsExternal: boolean; - IsVisible: boolean; - ListTemplateType: number; - Title: string; - Url: string; - AudienceIds: string[]; - Children?: NavigationNode[]; -} - export interface MenuStateNode { AudienceIds: string[]; CurrentLCID: number; @@ -18,7 +6,7 @@ export interface MenuStateNode { IsDeleted: boolean; IsHidden: boolean; IsTitleForExistingLanguage: boolean; - Key: string; + Key: string | null; Nodes: MenuStateNode[]; NodeType: number; OpenInNewWindow?: boolean | null; diff --git a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts index 616538214bc..bcc2538a257 100644 --- a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts @@ -10,22 +10,151 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './navigation-node-add.js'; +import command, { options } from './navigation-node-add.js'; describe(commands.NAVIGATION_NODE_ADD, () => { const webUrl = 'https://contoso.sharepoint.com/sites/team-a'; const nodeUrl = '/sites/team-a/sitepages/about.aspx'; const title = 'About'; const audienceIds = '7aa4a1ca-4035-4f2f-bac7-7beada59b5ba,4bbf236f-a131-4019-b4a2-315902fcfa3a'; - const topNavigationResponse = { 'AudienceIds': [], 'FriendlyUrlPrefix': '', 'IsAudienceTargetEnabledForGlobalNav': false, 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2039', 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2041', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Sub level 1', 'Translations': [] }], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Site A', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2040', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Site B', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2001', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/team-a/sitepages/about.aspx', 'Title': 'About', 'Translations': [] }], 'SimpleUrl': '', 'SPSitePrefix': '/sites/SharePointDemoSite', 'SPWebPrefix': '/sites/SharePointDemoSite', 'StartingNodeKey': '1025', 'StartingNodeTitle': 'Quick launch', 'Version': '2023-03-09T18:33:53.5468097Z' }; - const quickLaunchResponse = { 'AudienceIds': [], 'FriendlyUrlPrefix': '', 'IsAudienceTargetEnabledForGlobalNav': false, 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2003', 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2006', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': '/sites/SharePointDemoSite#/', 'Title': 'Sub Item', 'Translations': [] }], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': 'http://google.be', 'Title': 'Site A', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2018', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': 'https://google.be', 'Title': 'Site B', 'Translations': [] }], 'SimpleUrl': '', 'SPSitePrefix': '/sites/SharePointDemoSite', 'SPWebPrefix': '/sites/SharePointDemoSite', 'StartingNodeKey': '1002', 'StartingNodeTitle': 'SharePoint Top Navigation Bar', 'Version': '2023-03-09T18:34:53.650545Z' }; + const quickLaunchMenuState = { + AudienceIds: [], + FriendlyUrlPrefix: '', + IsAudienceTargetEnabledForGlobalNav: false, + Nodes: [ + { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2003', + Nodes: [ + { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2006', + Nodes: [], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: '/sites/team-a/SitePages/Home.aspx', + Title: 'Sub Item', + Translations: [] + } + ], + NodeType: 0, + OpenInNewWindow: true, + SimpleUrl: 'https://contoso.com', + Title: 'Site A', + Translations: [] + } + ], + SimpleUrl: '', + SPSitePrefix: '/sites/team-a', + SPWebPrefix: '/sites/team-a', + StartingNodeKey: '1025', + StartingNodeTitle: 'Quick launch', + Version: '2026-10-07T19:21:10.213646Z' + }; + const topNavigationMenuState = { + AudienceIds: [], + FriendlyUrlPrefix: '', + IsAudienceTargetEnabledForGlobalNav: false, + Nodes: [ + { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2039', + Nodes: [], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: '/sites/team-a', + Title: 'Home', + Translations: [] + } + ], + SimpleUrl: '', + SPSitePrefix: '/sites/team-a', + SPWebPrefix: '/sites/team-a', + StartingNodeKey: '1002', + StartingNodeTitle: 'SharePoint Top Navigation Bar', + Version: '2026-10-07T19:21:10.213646Z' + }; + const addedNode = { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '3000', + Nodes: [], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: nodeUrl, + Title: title, + Translations: [] + }; let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + const stubMenuStateRequests = (persistChanges: boolean = true): sinon.SinonStub => { + const menuStates: { [startingNodeKey: string]: any } = { + '1025': structuredClone(quickLaunchMenuState), + '1002': structuredClone(topNavigationMenuState) + }; + let nextKey = 3000; + const assignKeys = (nodes: any[]): void => { + nodes.forEach(node => { + if (node.Key === null) { + Object.assign(node, { + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsTitleForExistingLanguage: false, + Key: (nextKey++).toString(), + Translations: [] + }); + } + assignKeys(node.Nodes); + }); + }; + + return sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + return structuredClone(menuStates[opts.data.menuNodeKey ?? '1025']); + } + + if (opts.url === `${webUrl}/_api/navigation/SaveMenuState`) { + if (persistChanges) { + const menuState = structuredClone(opts.data.menuState); + assignKeys(menuState.Nodes); + menuStates[menuState.StartingNodeKey] = menuState; + } + return { value: 200 }; + } + + throw 'Invalid request'; + }); + }; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -34,6 +163,7 @@ describe(commands.NAVIGATION_NODE_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -54,10 +184,7 @@ describe(commands.NAVIGATION_NODE_ADD, () => { afterEach(() => { sinonUtil.restore([ - request.post, - spo.getTopNavigationMenuState, - spo.getQuickLaunchMenuState, - spo.saveMenuState + request.post ]); }); @@ -79,285 +206,163 @@ describe(commands.NAVIGATION_NODE_ADD, () => { }); it('adds new navigation node to the top navigation', async () => { - const nodeAddResponse = { - "AudienceIds": null, - "CurrentLCID": 1033, - "Id": 2001, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "About", - "Url": "/sites/team-a/sitepages/about.aspx" - }; - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/topnavigationbar` && - JSON.stringify(opts.data) === JSON.stringify({ - Title: title, - Url: nodeUrl, - IsExternal: false - })) { - return nodeAddResponse; - } - - throw 'Invalid request'; - }); + const postStub = stubMenuStateRequests(); await command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl, verbose: true } }); - assert(loggerLogSpy.calledWith(nodeAddResponse)); - }); - - it('adds new navigation node to the quick launch navigation and opens it in new window', async () => { - let saveCalled = false; - const nodeAddResponse = { - "AudienceIds": [], - "CurrentLCID": 1033, - "Id": 2003, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "About", - "Url": "/sites/team-a/sitepages/about.aspx" - }; - - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/quicklaunch`) { - return nodeAddResponse; - } - - throw 'Invalid request'; - }); - - sinon.stub(spo, 'getQuickLaunchMenuState').callsFake(async () => { - return quickLaunchResponse; - }); - - sinon.stub(spo, 'saveMenuState').callsFake(async () => { - saveCalled = true; - return; + const saveRequest = postStub.getCalls().find(call => call.args[0].url === `${webUrl}/_api/navigation/SaveMenuState`)!; + assert.deepStrictEqual(saveRequest.args[0].data.menuState.Nodes.at(-1), { + AudienceIds: [], + IsDeleted: false, + IsHidden: false, + Key: null, + Nodes: [], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: nodeUrl, + Title: title }); - - await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', title: title, url: nodeUrl, openInNewWindow: true, verbose: true } }); - assert(loggerLogSpy.calledWith(nodeAddResponse)); - assert.strictEqual(saveCalled, true); + assert.strictEqual(saveRequest.args[0].data.menuState.StartingNodeKey, '1002'); + assert(loggerLogSpy.calledOnceWith(addedNode)); }); - it('adds new navigation node with a parent id and opens it in new window', async () => { - const parentNodeId = 2039; - let saveCalled = false; - const nodeAddResponse = { - "AudienceIds": audienceIds.split(','), - "CurrentLCID": 1033, - "Id": 2041, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "About", - "Url": "/sites/team-a/sitepages/about.aspx" - }; - - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${parentNodeId})/Children`) { - return nodeAddResponse; - } - - throw 'Invalid request'; - }); - - sinon.stub(spo, 'getQuickLaunchMenuState').callsFake(async () => { - return quickLaunchResponse; - }); - - sinon.stub(spo, 'getTopNavigationMenuState').callsFake(async () => { - return topNavigationResponse; - }); + it('adds new navigation node to the quick launch with audience targeting and opens it in new window', async () => { + stubMenuStateRequests(); - sinon.stub(spo, 'saveMenuState').callsFake(async () => { - saveCalled = true; - return; - }); - - await command.action(logger, { options: { webUrl: webUrl, parentNodeId: parentNodeId, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true, verbose: true } }); - assert(loggerLogSpy.calledWith(nodeAddResponse)); - assert.strictEqual(saveCalled, true); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true } }); + assert(loggerLogSpy.calledOnceWith({ ...addedNode, AudienceIds: audienceIds.split(','), OpenInNewWindow: true })); }); - it('adds new navigation node below an existing node', async () => { - const nodeAddResponse = { - "AudienceIds": null, - "CurrentLCID": 1033, - "Id": 2001, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "About", - "Url": "/sites/team-a/sitepages/about.aspx" - }; - const parentNodeId = 1000; - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${parentNodeId})/Children` && - JSON.stringify(opts.data) === JSON.stringify({ - Title: title, - Url: nodeUrl, - IsExternal: false - })) { - return nodeAddResponse; - } - - throw 'Invalid request'; - }); + it('adds new linkless navigation node to the quick launch', async () => { + stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, parentNodeId: 1000, title: title, url: nodeUrl, verbose: true } }); - assert(loggerLogSpy.calledWith(nodeAddResponse)); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', title: title } }); + assert(loggerLogSpy.calledOnceWith({ ...addedNode, SimpleUrl: '' })); }); - it('adds new navigation node to the top navigation with audience targetting and opens it in new window', async () => { - let saveCalled = false; - const nodeAddResponse = { - "AudienceIds": audienceIds.split(','), - "CurrentLCID": 1033, - "Id": 2001, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "About", - "Url": "/sites/team-a/sitepages/about.aspx" - }; + it('adds new navigation node below an existing node in the quick launch', async () => { + const postStub = stubMenuStateRequests(); - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/topnavigationbar`) { - return nodeAddResponse; - } - - throw 'Invalid request'; - }); + await command.action(logger, { options: { webUrl: webUrl, parentNodeId: 2006, title: title, url: nodeUrl } }); + const saveRequest = postStub.getCalls().find(call => call.args[0].url === `${webUrl}/_api/navigation/SaveMenuState`)!; + assert.strictEqual(saveRequest.args[0].data.menuState.Nodes[0].Nodes[0].Nodes[0].Title, title); + assert(loggerLogSpy.calledOnceWith(addedNode)); + }); - sinon.stub(spo, 'getTopNavigationMenuState').callsFake(async () => { - return topNavigationResponse; - }); + it('adds new navigation node below an existing node in the top navigation', async () => { + const postStub = stubMenuStateRequests(); - sinon.stub(spo, 'saveMenuState').callsFake(async () => { - saveCalled = true; - return; - }); + await command.action(logger, { options: { webUrl: webUrl, parentNodeId: 2039, title: title, url: nodeUrl } }); + const saveRequest = postStub.getCalls().find(call => call.args[0].url === `${webUrl}/_api/navigation/SaveMenuState`)!; + assert.strictEqual(saveRequest.args[0].data.menuState.StartingNodeKey, '1002'); + assert.strictEqual(saveRequest.args[0].data.menuState.Nodes[0].Nodes[0].Title, title); + assert(loggerLogSpy.calledOnceWith(addedNode)); + }); + it('throws an error when the parent node does not exist', async () => { + stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true, verbose: true } }); - assert(loggerLogSpy.calledWith(nodeAddResponse)); - assert.strictEqual(saveCalled, true); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, parentNodeId: 9999, title: title, url: nodeUrl } }), + new CommandError(`Navigation node with id '9999' not found.`)); }); - it('adds new linkless navigation node to the top navigation with', async () => { - const requestBody = { - AudienceIds: undefined, - Title: title, - Url: 'http://linkless.header/', - IsExternal: false - }; - const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/topnavigationbar`) { - return { - "AudienceIds": null, - "CurrentLCID": 1033, - "Id": 2001, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 0, - "Title": title, - "Url": "http://linkless.header/" - }; - } - - throw 'Invalid request'; - }); + it('throws an error when the added node cannot be retrieved', async () => { + stubMenuStateRequests(false); - await command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, verbose: true } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, requestBody); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', title: title, url: nodeUrl } }), + new CommandError(`The navigation node was added, but it couldn't be retrieved.`)); }); - it('correctly handles random API error', async () => { + it('throws an error when the parent node cannot be retrieved after adding the node', async () => { + let menuStateRequests = 0; sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/topnavigationbar`) { - throw { error: 'An error has occurred' }; + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + return ++menuStateRequests === 1 ? structuredClone(quickLaunchMenuState) : { ...quickLaunchMenuState, Nodes: [] }; + } + + if (opts.url === `${webUrl}/_api/navigation/SaveMenuState`) { + return { value: 200 }; } throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, parentNodeId: 2003, title: title, url: nodeUrl } }), + new CommandError(`The navigation node was added, but it couldn't be retrieved.`)); }); - it('correctly handles random API error (string error)', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/topnavigationbar`) { - throw 'An error has occurred'; + it('correctly handles API error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, Microsoft.SharePoint.Client.InvalidOperationException', + message: { + value: 'An error has occurred' + } + } } - - throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl } } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl } }), + new CommandError('An error has occurred')); }); it('fails validation if webUrl is not a valid SharePoint URL', async () => { - const actual = await command.validate({ options: { webUrl: 'invalid', location: 'TopNavigationBar', title: title, url: nodeUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'TopNavigationBar', title: title }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if the specified parentNodeId is not a number', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, title: title, url: nodeUrl, parentNodeId: 'invalid' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentNodeId: 'invalid', title: title }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if specified location is not valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, location: 'invalid', title: title, url: nodeUrl } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'invalid', title: title }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if audienceIds contains an invalid audienceId', async () => { - const invalidAudienceIds = `${audienceIds},invalid`; - const actual = await command.validate({ options: { webUrl: webUrl, parentNodeId: 2000, title: title, url: nodeUrl, audienceIds: invalidAudienceIds } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if both location and parentNodeId are specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', parentNodeId: 2003, title: title }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if audienceIds contains more than 10 guids', async () => { - const invalidAudienceIds = `${audienceIds},${audienceIds},${audienceIds},${audienceIds},${audienceIds},${audienceIds}`; - const actual = await command.validate({ options: { webUrl: webUrl, parentNodeId: 2000, title: title, url: nodeUrl, audienceIds: invalidAudienceIds } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if neither location nor parentNodeId is specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, title: title }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation when location is TopNavigationBar and all required properties are present', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if title is not specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if audienceIds contains an invalid audienceId', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', title: title, audienceIds: `${audienceIds},invalid` }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation when location is QuickLaunch and all required properties are present', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, location: 'QuickLaunch', title: title, url: nodeUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if audienceIds contains more than 10 guids', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', title: title, audienceIds: Array(11).fill('7aa4a1ca-4035-4f2f-bac7-7beada59b5ba').join(',') }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation when location is TopNavigationBar and the link is external', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, location: 'TopNavigationBar', title: title, url: nodeUrl, isExternal: true } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if the removed isExternal option is specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', title: title, isExternal: true }); + assert.notStrictEqual(actual.success, true); }); - it('passes validation when location is QuickLaunch and the link is external', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, location: 'QuickLaunch', title: title, url: nodeUrl, isExternal: true } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when location is TopNavigationBar and all required properties are present', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', title: title }); + assert.strictEqual(actual.success, true); }); - it('passes validation when location is not specified but parentNodeId is', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentNodeId: 2000, title: title, url: nodeUrl } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when location is QuickLaunch and all properties are specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', title: title, url: 'https://contoso.com', audienceIds: audienceIds, openInNewWindow: true }); + assert.strictEqual(actual.success, true); }); - it('passes validation when audienceIds contains less than 10 ids and all are valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, parentNodeId: 2000, title: title, url: nodeUrl, audienceIds: audienceIds } }, commandInfo); - assert.strictEqual(actual, true); + it('passes validation when location is not specified but parentNodeId is', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, parentNodeId: 2003, title: title }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/spo/commands/navigation/navigation-node-add.ts b/src/m365/spo/commands/navigation/navigation-node-add.ts index d8775185f85..63f13c3fa2d 100644 --- a/src/m365/spo/commands/navigation/navigation-node-add.ts +++ b/src/m365/spo/commands/navigation/navigation-node-add.ts @@ -1,27 +1,39 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request, { CliRequestOptions } from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; import { spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { MenuState, MenuStateNode, NavigationNode } from './NavigationNode.js'; +import { MenuState, MenuStateNode } from './NavigationNode.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u'), + location: z.enum(['QuickLaunch', 'TopNavigationBar']).optional().alias('l'), + title: z.string().alias('t'), + url: z.string().optional(), + parentNodeId: z.int().positive().optional(), + audienceIds: z.string() + .refine(audienceIds => audienceIds.split(',').length <= 10, { + error: 'The maximum amount of audienceIds per navigation node exceeded. The maximum amount of audienceIds is 10.' + }) + .refine(audienceIds => validation.isValidGuidArray(audienceIds) === true, { + error: e => `The following GUIDs are invalid for the option 'audienceIds': ${validation.isValidGuidArray(e.input as string)}.` + }) + .optional(), + openInNewWindow: z.boolean().optional() +}); +declare type Options = z.infer; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - audienceIds?: string; - isExternal?: boolean; - location?: string; - parentNodeId?: number; - title: string; - url?: string; - webUrl: string; - openInNewWindow?: boolean -} - class SpoNavigationNodeAddCommand extends SpoCommand { public get name(): string { return commands.NAVIGATION_NODE_ADD; @@ -31,99 +43,19 @@ class SpoNavigationNodeAddCommand extends SpoCommand { return 'Adds a navigation node to the specified site navigation'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - isExternal: args.options.isExternal, - location: typeof args.options.location !== 'undefined', - parentNodeId: typeof args.options.parentNodeId !== 'undefined', - audienceIds: typeof args.options.audienceIds !== 'undefined', - url: typeof args.options.url !== 'undefined', - openInNewWindow: !!args.options.openInNewWindow - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl ' - }, - { - option: '-l, --location [location]', - autocomplete: ['QuickLaunch', 'TopNavigationBar'] - }, - { - option: '-t, --title ' - }, - { - option: '--url [url]' - }, - { - option: '--parentNodeId [parentNodeId]' - }, - { - option: '--isExternal' - }, - { - option: '--audienceIds [audienceIds]' - }, - { - option: '--openInNewWindow' - } - ); + public get schema(): z.ZodType { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; + public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { + return schema + .refine(options => [options.location, options.parentNodeId].filter(o => o !== undefined).length === 1, { + error: `Specify either 'location' or 'parentNodeId', but not both.`, + params: { + customCode: 'optionSet', + options: ['location', 'parentNodeId'] } - - if (args.options.parentNodeId) { - if (isNaN(args.options.parentNodeId)) { - return `${args.options.parentNodeId} is not a number`; - } - } - else { - if (args.options.location !== 'QuickLaunch' && - args.options.location !== 'TopNavigationBar') { - return `${args.options.location} is not a valid value for the location option. Allowed values are QuickLaunch|TopNavigationBar`; - } - } - - if (args.options.audienceIds) { - const audienceIdsSplitted = args.options.audienceIds.split(','); - if (audienceIdsSplitted.length > 10) { - return 'The maximum amount of audienceIds per navigation node exceeded. The maximum amount of auciendeIds is 10.'; - } - - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.audienceIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'audienceIds': ${isValidGUIDArrayResult}.`; - } - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push( - { options: ['location', 'parentNodeId'] } - ); + }); } protected getExcludedOptionsWithUrls(): string[] | undefined { @@ -131,72 +63,63 @@ class SpoNavigationNodeAddCommand extends SpoCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { + const { webUrl, location, parentNodeId, title, url, audienceIds, openInNewWindow } = args.options; + if (this.verbose) { await logger.logToStderr(`Adding navigation node...`); } - const nodesCollection: string = args.options.parentNodeId ? - `GetNodeById(${args.options.parentNodeId})/Children` : - (args.options.location as string).toLowerCase(); - - const requestOptions: CliRequestOptions = { - url: `${args.options.webUrl}/_api/web/navigation/${nodesCollection}`, - headers: { - accept: 'application/json;odata=nometadata', - 'content-type': 'application/json;odata=nometadata' - }, - responseType: 'json', - data: { - AudienceIds: args.options.audienceIds?.split(','), - Title: args.options.title, - Url: args.options.url ?? 'http://linkless.header/', - IsExternal: args.options.isExternal === true - } - }; - try { - const res = await request.post<NavigationNode>(requestOptions); + let menuState: MenuState; + let siblingNodes: MenuStateNode[]; - if (args.options.openInNewWindow) { - if (this.verbose) { - await logger.logToStderr(`Making sure that the newly added navigation node opens in a new window.`); - } - - const id: string = res.Id.toString(); + if (parentNodeId) { + const parent = await spo.getMenuStateNodeByKey(webUrl, parentNodeId.toString()); + menuState = parent.menuState; + siblingNodes = parent.node.Nodes; + } + else { + menuState = location === 'TopNavigationBar' + ? await spo.getTopNavigationMenuState(webUrl) + : await spo.getQuickLaunchMenuState(webUrl); + siblingNodes = menuState.Nodes; + } - let menuState: MenuState = args.options.location === 'TopNavigationBar' ? await spo.getTopNavigationMenuState(args.options.webUrl) : await spo.getQuickLaunchMenuState(args.options.webUrl); - let menuStateItem: MenuStateNode = this.getMenuStateNode(menuState.Nodes, id); + const existingKeys: (string | null)[] = siblingNodes.map(node => node.Key); + const newNode: Partial<MenuStateNode> = { + AudienceIds: audienceIds ? audienceIds.split(',') : [], + IsDeleted: false, + IsHidden: false, + Key: null, + Nodes: [], + NodeType: 0, + OpenInNewWindow: openInNewWindow ? true : null, + SimpleUrl: url ?? '', + Title: title + }; + siblingNodes.push(newNode as MenuStateNode); + await spo.saveMenuState(webUrl, menuState); + + if (this.verbose) { + await logger.logToStderr(`Retrieving the added navigation node...`); + } - if (args.options.parentNodeId && !menuStateItem) { - menuState = await spo.getTopNavigationMenuState(args.options.webUrl); - menuStateItem = this.getMenuStateNode(menuState.Nodes, id); - } + const updatedMenuState: MenuState = await spo.getMenuState(webUrl, menuState.StartingNodeKey); + const updatedSiblingNodes: MenuStateNode[] | undefined = parentNodeId + ? spo.findMenuStateNode(updatedMenuState.Nodes, parentNodeId.toString())?.Nodes + : updatedMenuState.Nodes; + const addedNode: MenuStateNode | undefined = updatedSiblingNodes?.find(node => !existingKeys.includes(node.Key) && node.Title === title); - menuStateItem.OpenInNewWindow = true; - await spo.saveMenuState(args.options.webUrl, menuState); + if (!addedNode) { + throw `The navigation node was added, but it couldn't be retrieved.`; } - await logger.log(res); + await logger.log(addedNode); } - catch (err: any) { this.handleRejectedODataJsonPromise(err); } } - - private getMenuStateNode(nodes: MenuStateNode[], id: string): MenuStateNode { - let menuNode = nodes.find((node: MenuStateNode) => node.Key !== null && node.Key === id); - if (menuNode === undefined) { - for (const node of nodes.filter(node => node.Nodes.length > 0)) { - menuNode = this.getMenuStateNode(node.Nodes, id); - if (menuNode) { - break; - } - } - } - return menuNode!; - } - } export default new SpoNavigationNodeAddCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts index 8d62584117c..d5069ea6fd7 100644 --- a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts @@ -16,31 +16,81 @@ import command, { options } from './navigation-node-get.js'; describe(commands.NAVIGATION_NODE_GET, () => { const webUrl = 'https://contoso.sharepoint.com/sites/team-a'; const id = 2209; - const navigationNodeGetResponse = { - "AudienceIds": null, - "CurrentLCID": 1033, - "Id": id, - "IsDocLib": true, - "IsExternal": false, - "IsVisible": true, - "ListTemplateType": 100, - "Title": "Work Status", - "Url": "/sites/team-a/Lists/Work Status/AllItems.aspx", - "Children": [ + const childNode = { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2209', + Nodes: [ { - "AudienceIds": null, - "CurrentLCID": 1033, - "Id": 2005, - "IsDocLib": true, - "IsExternal": true, - "IsVisible": true, - "ListTemplateType": 0, - "Title": "External site", - "Url": "https://externalsite.com", - "Children": [] + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2210', + Nodes: [], + NodeType: 0, + OpenInNewWindow: true, + SimpleUrl: 'https://externalsite.com', + Title: 'External site', + Translations: [] } - ] + ], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: '/sites/team-a/Lists/Work Status/AllItems.aspx', + Title: 'Work Status', + Translations: [] }; + const quickLaunchMenuState = { + AudienceIds: [], + FriendlyUrlPrefix: '', + IsAudienceTargetEnabledForGlobalNav: false, + Nodes: [ + { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2003', + Nodes: [childNode], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: '', + Title: 'Lists', + Translations: [] + } + ], + SimpleUrl: '', + SPSitePrefix: '/sites/team-a', + SPWebPrefix: '/sites/team-a', + StartingNodeKey: '1025', + StartingNodeTitle: 'Quick launch', + Version: '2026-10-07T19:21:10.213646Z' + }; + const topNavigationMenuState = { + AudienceIds: [], + FriendlyUrlPrefix: '', + IsAudienceTargetEnabledForGlobalNav: false, + Nodes: [childNode], + SimpleUrl: '', + SPSitePrefix: '/sites/team-a', + SPWebPrefix: '/sites/team-a', + StartingNodeKey: '1002', + StartingNodeTitle: 'SharePoint Top Navigation Bar', + Version: '2026-10-07T19:21:10.213646Z' + }; + const emptyQuickLaunchMenuState = { ...quickLaunchMenuState, Nodes: [] }; let log: any[]; let logger: Logger; @@ -76,7 +126,7 @@ describe(commands.NAVIGATION_NODE_GET, () => { afterEach(() => { sinonUtil.restore([ - request.get + request.post ]); }); @@ -94,7 +144,7 @@ describe(commands.NAVIGATION_NODE_GET, () => { }); it('fails validation if webUrl is not a valid SharePoint URL', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'TopNavigationBar' }); + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', id: id }); assert.notStrictEqual(actual.success, true); }); @@ -108,43 +158,60 @@ describe(commands.NAVIGATION_NODE_GET, () => { assert.strictEqual(actual.success, true); }); - it('retrieves navigation node by specified webUrl and id', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${id})?$expand=Children,Children/Children,Children/Children/Children`) { - return navigationNodeGetResponse; + it('retrieves navigation node from the quick launch', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState` && opts.data.menuNodeKey === null) { + return quickLaunchMenuState; } throw 'Invalid request'; }); await command.action(logger, { options: { webUrl: webUrl, id: id, verbose: true } }); - assert(loggerLogSpy.calledWith(navigationNodeGetResponse)); + assert(loggerLogSpy.calledOnceWith(childNode)); }); - it('command correctly handles error when navigation node is not found', async () => { - sinon.stub(request, 'get').resolves(({ 'odata.null': true })); + it('retrieves navigation node from the top navigation', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + if (opts.data.menuNodeKey === null) { + return emptyQuickLaunchMenuState; + } + + if (opts.data.menuNodeKey === '1002') { + return topNavigationMenuState; + } + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { webUrl: webUrl, id: id } }); + assert(loggerLogSpy.calledOnceWith(childNode)); + }); - await assert.rejects(command.action(logger, { - options: { - webUrl: webUrl, - id: id + it('throws an error when navigation node is not found', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + return emptyQuickLaunchMenuState; } - }), new CommandError(`No navigation node found with id ${id}.`)); + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id } }), + new CommandError(`Navigation node with id '${id}' not found.`)); }); - it('command correctly handles navigation node get reject request', async () => { - sinon.stub(request, 'get').rejects({ + it('correctly handles API error', async () => { + sinon.stub(request, 'post').rejects({ error: { code: "-2147024891, System.UnauthorizedAccessException", message: "Attempted to perform an unauthorized operation." } }); - await assert.rejects(command.action(logger, { - options: { - webUrl: webUrl, - id: id - } - }), new CommandError("Attempted to perform an unauthorized operation.")); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id } }), + new CommandError("Attempted to perform an unauthorized operation.")); }); -}); \ No newline at end of file +}); diff --git a/src/m365/spo/commands/navigation/navigation-node-get.ts b/src/m365/spo/commands/navigation/navigation-node-get.ts index 62e64953c5b..cd0af3d772d 100644 --- a/src/m365/spo/commands/navigation/navigation-node-get.ts +++ b/src/m365/spo/commands/navigation/navigation-node-get.ts @@ -1,7 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import { globalOptionsZod } from '../../../../Command.js'; import { z } from 'zod'; -import request, { CliRequestOptions } from '../../../../request.js'; +import { spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; @@ -39,21 +39,9 @@ class SpoNavigationNodeGetCommand extends SpoCommand { await logger.logToStderr(`Retrieving information about navigation node with id ${args.options.id}`); } - const requestOptions: CliRequestOptions = { - url: `${args.options.webUrl}/_api/web/navigation/GetNodeById(${args.options.id})?$expand=Children,Children/Children,Children/Children/Children`, - headers: { - accept: 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - try { - const listInstance = await request.get<any>(requestOptions); - if (listInstance['odata.null']) { - throw `No navigation node found with id ${args.options.id}.`; - } - - await logger.log(listInstance); + const { node } = await spo.getMenuStateNodeByKey(args.options.webUrl, args.options.id.toString()); + await logger.log(node); } catch (err: any) { this.handleRejectedODataJsonPromise(err); diff --git a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts index 6ab117c4386..81b5510d657 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts @@ -10,24 +10,95 @@ import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; -import { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; -import command from './navigation-node-set.js'; +import command, { options } from './navigation-node-set.js'; describe(commands.NAVIGATION_NODE_SET, () => { const webUrl = 'https://contoso.sharepoint.com/sites/team-a'; - const id = 2000; + const id = 2006; const nodeUrl = '/sites/team-a/sitepages/about.aspx'; const title = 'About'; const audienceIds = '7aa4a1ca-4035-4f2f-bac7-7beada59b5ba,4bbf236f-a131-4019-b4a2-315902fcfa3a'; - - const topNavigationResponse = { 'AudienceIds': [], 'FriendlyUrlPrefix': '', 'IsAudienceTargetEnabledForGlobalNav': false, 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2039', 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2041', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Sub level 1', 'Translations': [] }], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Site A', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2040', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/PnPCoreSDKTestGroup', 'Title': 'Site B', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2001', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': '/sites/team-a/sitepages/about.aspx', 'Title': 'About', 'Translations': [] }], 'SimpleUrl': '', 'SPSitePrefix': '/sites/SharePointDemoSite', 'SPWebPrefix': '/sites/SharePointDemoSite', 'StartingNodeKey': '1025', 'StartingNodeTitle': 'Quick launch', 'Version': '2023-03-09T18:33:53.5468097Z' }; - const quickLaunchResponse = { 'AudienceIds': [], 'FriendlyUrlPrefix': '', 'IsAudienceTargetEnabledForGlobalNav': false, 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2003', 'Nodes': [{ 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2006', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': '/sites/SharePointDemoSite#/', 'Title': 'Sub Item', 'Translations': [] }], 'NodeType': 0, 'OpenInNewWindow': true, 'SimpleUrl': 'http://google.be', 'Title': 'Site A', 'Translations': [] }, { 'AudienceIds': [], 'CurrentLCID': 1033, 'CustomProperties': [], 'FriendlyUrlSegment': '', 'IsDeleted': false, 'IsHidden': false, 'IsTitleForExistingLanguage': false, 'Key': '2018', 'Nodes': [], 'NodeType': 0, 'OpenInNewWindow': null, 'SimpleUrl': 'https://google.be', 'Title': 'Site B', 'Translations': [] }], 'SimpleUrl': '', 'SPSitePrefix': '/sites/SharePointDemoSite', 'SPWebPrefix': '/sites/SharePointDemoSite', 'StartingNodeKey': '1002', 'StartingNodeTitle': 'SharePoint Top Navigation Bar', 'Version': '2023-03-09T18:34:53.650545Z' }; - + const node = { + AudienceIds: ['0d718612-8407-4d6b-833c-6891a553354f'], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2006', + Nodes: [], + NodeType: 0, + OpenInNewWindow: null, + SimpleUrl: '/sites/team-a/SitePages/Home.aspx', + Title: 'Sub Item', + Translations: [] + }; + const quickLaunchMenuState = { + AudienceIds: [], + FriendlyUrlPrefix: '', + IsAudienceTargetEnabledForGlobalNav: false, + Nodes: [ + { + AudienceIds: [], + CurrentLCID: 1033, + CustomProperties: [], + FriendlyUrlSegment: '', + IsDeleted: false, + IsHidden: false, + IsTitleForExistingLanguage: false, + Key: '2003', + Nodes: [node], + NodeType: 0, + OpenInNewWindow: true, + SimpleUrl: 'https://contoso.com', + Title: 'Site A', + Translations: [] + } + ], + SimpleUrl: '', + SPSitePrefix: '/sites/team-a', + SPWebPrefix: '/sites/team-a', + StartingNodeKey: '1025', + StartingNodeTitle: 'Quick launch', + Version: '2026-10-07T19:21:10.213646Z' + }; + const topNavigationMenuState = { + ...quickLaunchMenuState, + Nodes: [node], + StartingNodeKey: '1002', + StartingNodeTitle: 'SharePoint Top Navigation Bar' + }; let log: string[]; let logger: Logger; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + const stubMenuStateRequests = (nodeInTopNavigation: boolean = false): sinon.SinonStub => { + return sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + if (opts.data.menuNodeKey === null) { + return nodeInTopNavigation ? { ...quickLaunchMenuState, Nodes: [] } : structuredClone(quickLaunchMenuState); + } + + if (opts.data.menuNodeKey === '1002') { + return nodeInTopNavigation ? structuredClone(topNavigationMenuState) : { ...topNavigationMenuState, Nodes: [] }; + } + } + + if (opts.url === `${webUrl}/_api/navigation/SaveMenuState`) { + return { value: 200 }; + } + + throw 'Invalid request'; + }); + }; + + const getSavedMenuState = (postStub: sinon.SinonStub): any => { + return postStub.getCalls().find(call => call.args[0].url === `${webUrl}/_api/navigation/SaveMenuState`)!.args[0].data.menuState; + }; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -35,8 +106,8 @@ describe(commands.NAVIGATION_NODE_SET, () => { sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); auth.connection.active = true; - auth.connection.spoUrl = 'https://contoso.sharepoint.com'; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -56,10 +127,8 @@ describe(commands.NAVIGATION_NODE_SET, () => { afterEach(() => { sinonUtil.restore([ - request.patch, - spo.getQuickLaunchMenuState, - spo.getTopNavigationMenuState, - spo.saveMenuState]); + request.post + ]); }); after(() => { @@ -75,108 +144,113 @@ describe(commands.NAVIGATION_NODE_SET, () => { assert.notStrictEqual(command.description, null); }); - it('correctly updates existing navigation node and make sure it opens link in new tab', async () => { - let saveCalled = false; - const id = 2041; - sinon.stub(request, 'patch').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${id})`) { - return ''; - } + it('excludes options from URL processing', () => { + assert.deepStrictEqual((command as any).getExcludedOptionsWithUrls(), ['url']); + }); - throw 'Invalid request'; - }); + it('updates all properties of a navigation node in the quick launch', async () => { + const postStub = stubMenuStateRequests(); - sinon.stub(spo, 'getQuickLaunchMenuState').callsFake(async () => { - return quickLaunchResponse; + await command.action(logger, { options: { webUrl: webUrl, id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true, verbose: true } }); + assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { + ...node, + AudienceIds: audienceIds.split(','), + OpenInNewWindow: true, + SimpleUrl: nodeUrl, + Title: title }); + }); - sinon.stub(spo, 'getTopNavigationMenuState').callsFake(async () => { - return topNavigationResponse; - }); + it('updates the title of a navigation node in the top navigation', async () => { + const postStub = stubMenuStateRequests(true); - sinon.stub(spo, 'saveMenuState').callsFake(async () => { - saveCalled = true; - return; - }); + await command.action(logger, { options: { webUrl: webUrl, id: id, title: title } }); + const savedMenuState = getSavedMenuState(postStub); + assert.strictEqual(savedMenuState.StartingNodeKey, '1002'); + assert.deepStrictEqual(savedMenuState.Nodes[0], { ...node, Title: title }); + }); + it('sets a navigation node not to open in a new window', async () => { + const postStub = stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, id: id, title: title, url: nodeUrl, isExternal: false, audienceIds: audienceIds, openInNewWindow: true, verbose: true } } as any); - assert.strictEqual(saveCalled, true); + await command.action(logger, { options: { webUrl: webUrl, id: id, openInNewWindow: false } }); + assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, OpenInNewWindow: false }); }); - it('correctly clears audienceIds from existing navigation node', async () => { - const requestBody = { - AudienceIds: [], - IsExternal: undefined, - Title: undefined, - Url: undefined - }; - const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${id})`) { - return ''; - } + it('clears audienceIds of a navigation node', async () => { + const postStub = stubMenuStateRequests(); - throw 'Invalid request'; - }); - await command.action(logger, { options: { webUrl: webUrl, id: id, audienceIds: "" } } as any); - assert.deepStrictEqual(patchStub.lastCall.args[0].data, requestBody); + await command.action(logger, { options: { webUrl: webUrl, id: id, audienceIds: '' } }); + assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, AudienceIds: [] }); }); - it('correctly sets navigation node as linkless', async () => { - const requestBody = { - IsExternal: undefined, - Title: undefined, - Url: 'http://linkless.header/' - }; - const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${id})`) { - return ''; - } + it('sets a navigation node as linkless', async () => { + const postStub = stubMenuStateRequests(); - throw 'Invalid request'; - }); - await command.action(logger, { options: { webUrl: webUrl, id: id, url: "" } } as any); - assert.deepStrictEqual(patchStub.lastCall.args[0].data, requestBody); + await command.action(logger, { options: { webUrl: webUrl, id: id, url: '' } }); + assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, SimpleUrl: '' }); }); - it('correctly handles navigation node that does not exist', async () => { - sinon.stub(request, 'patch').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/web/navigation/GetNodeById(${id})`) { - return { - 'odata.null': true - }; - } + it('throws an error when the navigation node does not exist', async () => { + stubMenuStateRequests(); - throw 'Invalid request'; + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: 9999, title: title } }), + new CommandError(`Navigation node with id '9999' not found.`)); + }); + + it('correctly handles API error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, Microsoft.SharePoint.Client.InvalidOperationException', + message: { + value: 'An error has occurred' + } + } + } }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id, title: title, verbose: true } } as any), new CommandError('Navigation node does not exist.')); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id, title: title } }), + new CommandError('An error has occurred')); }); it('fails validation if webUrl is not a valid SharePoint URL', async () => { - const actual = await command.validate({ options: { webUrl: 'invalid', id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', id: id, title: title }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if id is not a valid number', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: 'invalid', title: title }); + assert.notStrictEqual(actual.success, true); }); - it('fails validation if webUrl is no options are set to be changed', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, id: id } }, commandInfo); - assert.notStrictEqual(actual, true); + it('fails validation if no options are set to be changed', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if audienceIds contains more than 10 guids', async () => { - const manyAudienceIds = `${audienceIds},${audienceIds},${audienceIds},${audienceIds},${audienceIds},${audienceIds}`; - const actual = await command.validate({ options: { webUrl: webUrl, id: id, audienceIds: manyAudienceIds } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: Array(11).fill('7aa4a1ca-4035-4f2f-bac7-7beada59b5ba').join(',') }); + assert.notStrictEqual(actual.success, true); }); it('fails validation if audienceIds contains invalid guid', async () => { - const invalidAudienceIds = `${audienceIds},invalid`; - const actual = await command.validate({ options: { webUrl: webUrl, id: id, audienceIds: invalidAudienceIds } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: `${audienceIds},invalid` }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if the removed isExternal option is specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, isExternal: true }); + assert.notStrictEqual(actual.success, true); }); it('passes validation if all options are set properly', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, id: id, title: title, url: nodeUrl, isExternal: true, audienceIds: audienceIds } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if audienceIds is an empty string', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: '' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/spo/commands/navigation/navigation-node-set.ts b/src/m365/spo/commands/navigation/navigation-node-set.ts index 2d20be31d59..bcc087a1d01 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.ts @@ -1,183 +1,93 @@ import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; -import request, { CliRequestOptions } from '../../../../request.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; import { spo } from '../../../../utils/spo.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { MenuStateNode } from './NavigationNode.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + webUrl: z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, { + error: e => `'${e.input}' is not a valid SharePoint Online site URL.` + }) + .alias('u'), + id: z.int().positive(), + title: z.string().optional(), + url: z.string().optional(), + audienceIds: z.string() + .refine(audienceIds => audienceIds === '' || audienceIds.split(',').length <= 10, { + error: 'The maximum amount of audienceIds per navigation node exceeded. The maximum amount of audienceIds is 10.' + }) + .refine(audienceIds => audienceIds === '' || validation.isValidGuidArray(audienceIds) === true, { + error: e => `The following GUIDs are invalid for the option 'audienceIds': ${validation.isValidGuidArray(e.input as string)}.` + }) + .optional(), + openInNewWindow: z.boolean().optional() +}); +declare type Options = z.infer<typeof options>; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - audienceIds?: string; - id: string; - isExternal?: boolean; - title?: string; - url?: string; - webUrl: string; - openInNewWindow?: boolean; -} - class SpoNavigationNodeSetCommand extends SpoCommand { public get name(): string { return commands.NAVIGATION_NODE_SET; } public get description(): string { - return 'Adds a navigation node to the specified site navigation'; + return 'Updates a SharePoint navigation node'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initTypes(); - this.#initValidators(); + public get schema(): z.ZodType { + return options; } - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - title: typeof args.options.title !== 'undefined', - url: typeof args.options.url !== 'undefined', - isExternal: typeof args.options.isExternal !== 'undefined', - audienceIds: typeof args.options.audienceIds !== 'undefined', - openInNewWindow: typeof args.options.openInNewWindow !== 'undefined' + public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { + return schema + .refine(options => [options.title, options.url, options.audienceIds, options.openInNewWindow].some(o => o !== undefined), { + error: 'Specify at least one property to update.' }); - }); } - #initTypes(): void { - this.types.boolean.push('isExternal'); + protected getExcludedOptionsWithUrls(): string[] | undefined { + return ['url']; } - #initOptions(): void { - this.options.unshift( - { - option: '-u, --webUrl <webUrl>' - }, - { - option: '--id <id>' - }, - { - option: '--title [title]' - }, - { - option: '--url [url]' - }, - { - option: '--audienceIds [audienceIds]' - }, - { - option: '--isExternal [isExternal]' - }, - { - option: '--openInNewWindow [openInNewWindow]' - } - ); - } + public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { + const { webUrl, id, title, url, audienceIds, openInNewWindow } = args.options; - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); - if (isValidSharePointUrl !== true) { - return isValidSharePointUrl; - } - - if (args.options.audienceIds === undefined && args.options.url === undefined && args.options.isExternal === undefined && !args.options.title && args.options.openInNewWindow === undefined) { - return `Please specify at least one property to update.`; - } - - if (args.options.audienceIds) { - const audienceIdsSplitted = args.options.audienceIds.split(','); - if (audienceIdsSplitted.length > 10) { - return 'The maximum amount of audienceIds per navigation node exceeded. The maximum amount of audienceIds is 10.'; - } - - const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.audienceIds); - if (isValidGUIDArrayResult !== true) { - return `The following GUIDs are invalid for the option 'audienceIds': ${isValidGUIDArrayResult}.`; - } - } - - return true; - } - ); - } + if (this.verbose) { + await logger.logToStderr(`Updating navigation node with id ${id}...`); + } - public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { try { - if (this.verbose) { - await logger.logToStderr(`Setting navigation node...`); - } + const { menuState, node } = await spo.getMenuStateNodeByKey(webUrl, id.toString()); - let url = args.options.url; - if (url === '') { - url = 'http://linkless.header/'; + if (title !== undefined) { + node.Title = title; } - const requestBody: any = { - Title: args.options.title, - IsExternal: args.options.isExternal, - Url: url - }; - - if (args.options.audienceIds !== undefined) { - requestBody.AudienceIds = args.options.audienceIds === '' ? [] : args.options.audienceIds.split(','); + if (url !== undefined) { + node.SimpleUrl = url; } - const requestOptions: CliRequestOptions = { - url: `${args.options.webUrl}/_api/web/navigation/GetNodeById(${args.options.id})`, - headers: { - accept: 'application/json;odata=nometadata', - 'content-type': 'application/json;odata=nometadata' - }, - data: requestBody, - responseType: 'json' - }; - - const response = await request.patch<any>(requestOptions); - if (response['odata.null'] === true) { - throw `Navigation node does not exist.`; + if (audienceIds !== undefined) { + node.AudienceIds = audienceIds === '' ? [] : audienceIds.split(','); } - if (args.options.openInNewWindow !== undefined) { - if (this.verbose) { - await logger.logToStderr(`Making sure that the navigation node opens in a new window.`); - } - - let menuState = await spo.getQuickLaunchMenuState(args.options.webUrl); - let menuStateItem = this.getMenuStateNode(menuState.Nodes, args.options.id); - if (!menuStateItem) { - menuState = await spo.getTopNavigationMenuState(args.options.webUrl); - menuStateItem = this.getMenuStateNode(menuState.Nodes, args.options.id); - } - menuStateItem!.OpenInNewWindow = args.options.openInNewWindow; - await spo.saveMenuState(args.options.webUrl, menuState); + if (openInNewWindow !== undefined) { + node.OpenInNewWindow = openInNewWindow; } + + await spo.saveMenuState(webUrl, menuState); } catch (err: any) { this.handleRejectedODataJsonPromise(err); } } - - private getMenuStateNode(nodes: MenuStateNode[], id: string): MenuStateNode { - let menuNode = nodes.find((node: MenuStateNode) => node.Key.toString() === id.toString()); - if (menuNode === undefined) { - for (const node of nodes.filter(node => node.Nodes.length > 0)) { - menuNode = this.getMenuStateNode(node.Nodes, id); - if (menuNode) { - break; - } - } - } - return menuNode!; - } } export default new SpoNavigationNodeSetCommand(); \ No newline at end of file diff --git a/src/utils/spo.spec.ts b/src/utils/spo.spec.ts index e311947c850..cbe7abad17f 100644 --- a/src/utils/spo.spec.ts +++ b/src/utils/spo.spec.ts @@ -961,6 +961,44 @@ describe('utils/spo', () => { assert.deepStrictEqual(postStub.lastCall.args[0].data, { menuState: topNavigation }); }); + it(`retrieves a nested navigation node from the quick launch`, async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState` && opts.data.menuNodeKey === null) { + return quickLaunchResponse; + } + + throw 'Invalid request'; + }); + + const actual = await spo.getMenuStateNodeByKey(webUrl, '2006'); + assert.deepStrictEqual(actual, { menuState: quickLaunchResponse, node: quickLaunchResponse.Nodes[0].Nodes[0] }); + }); + + it(`retrieves a nested navigation node from the top navigation when it's not in the quick launch`, async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + return opts.data.menuNodeKey === '1002' ? topNavigationResponse : quickLaunchResponse; + } + + throw 'Invalid request'; + }); + + const actual = await spo.getMenuStateNodeByKey(webUrl, '2041'); + assert.deepStrictEqual(actual, { menuState: topNavigationResponse, node: topNavigationResponse.Nodes[0].Nodes[0] }); + }); + + it(`throws an error when the navigation node is not found in the quick launch nor the top navigation`, async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState`) { + return opts.data.menuNodeKey === '1002' ? topNavigationResponse : quickLaunchResponse; + } + + throw 'Invalid request'; + }); + + await assert.rejects(spo.getMenuStateNodeByKey(webUrl, '9999'), (err) => err === `Navigation node with id '9999' not found.`); + }); + it(`retrieves spo group by name sucessfully`, async () => { const groupResponse = { Id: 11, diff --git a/src/utils/spo.ts b/src/utils/spo.ts index 5dedc49c2b9..8d8a02f435d 100644 --- a/src/utils/spo.ts +++ b/src/utils/spo.ts @@ -8,7 +8,7 @@ import { BasePermissions } from '../m365/spo/base-permissions.js'; import request, { CliRequestOptions } from "../request.js"; import { formatting } from './formatting.js'; import { CustomAction } from '../m365/spo/commands/customaction/customaction.js'; -import { MenuState } from '../m365/spo/commands/navigation/NavigationNode.js'; +import { MenuState, MenuStateNode } from '../m365/spo/commands/navigation/NavigationNode.js'; import { odata } from './odata.js'; import { RoleDefinition } from '../m365/spo/commands/roledefinition/RoleDefinition.js'; import { RoleType } from '../m365/spo/commands/roledefinition/RoleType.js'; @@ -923,6 +923,48 @@ export const spo = { return request.post(requestOptions); }, + /** + * Retrieves the navigation node with the specified key from the quick launch or the top navigation. + * Returns the node along with the menu state that contains it. + * @param webUrl Web url + * @param key Key (ID) of the navigation node + */ + async getMenuStateNodeByKey(webUrl: string, key: string): Promise<{ menuState: MenuState, node: MenuStateNode }> { + let menuState: MenuState = await this.getQuickLaunchMenuState(webUrl); + let node: MenuStateNode | undefined = this.findMenuStateNode(menuState.Nodes, key); + + if (!node) { + menuState = await this.getTopNavigationMenuState(webUrl); + node = this.findMenuStateNode(menuState.Nodes, key); + } + + if (!node) { + throw `Navigation node with id '${key}' not found.`; + } + + return { menuState, node }; + }, + + /** + * Recursively searches the specified menu state nodes for the node with the specified key. + * @param nodes Menu state nodes to search + * @param key Key (ID) of the navigation node + */ + findMenuStateNode(nodes: MenuStateNode[], key: string): MenuStateNode | undefined { + for (const node of nodes) { + if (node.Key === key) { + return node; + } + + const childNode: MenuStateNode | undefined = this.findMenuStateNode(node.Nodes, key); + if (childNode) { + return childNode; + } + } + + return undefined; + }, + /** * Retrieves the spo group by name. * Returns a group object From 1395402f31f8789ac7be720c70805844aa554e72 Mon Sep 17 00:00:00 2001 From: Adam-it <adam.wojcik.it@gmail.com> Date: Wed, 7 Oct 2026 23:17:24 +0200 Subject: [PATCH 2/5] title validation fixup --- src/m365/spo/commands/navigation/navigation-node-add.spec.ts | 5 +++++ src/m365/spo/commands/navigation/navigation-node-add.ts | 2 +- src/m365/spo/commands/navigation/navigation-node-set.spec.ts | 5 +++++ src/m365/spo/commands/navigation/navigation-node-set.ts | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts index bcc2538a257..63fb7c61efa 100644 --- a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts @@ -336,6 +336,11 @@ describe(commands.NAVIGATION_NODE_ADD, () => { assert.notStrictEqual(actual.success, true); }); + it('fails validation if title is an empty string', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', title: '' }); + assert.notStrictEqual(actual.success, true); + }); + it('fails validation if audienceIds contains an invalid audienceId', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', title: title, audienceIds: `${audienceIds},invalid` }); assert.notStrictEqual(actual.success, true); diff --git a/src/m365/spo/commands/navigation/navigation-node-add.ts b/src/m365/spo/commands/navigation/navigation-node-add.ts index 63f13c3fa2d..d7736a1a496 100644 --- a/src/m365/spo/commands/navigation/navigation-node-add.ts +++ b/src/m365/spo/commands/navigation/navigation-node-add.ts @@ -15,7 +15,7 @@ export const options = z.strictObject({ }) .alias('u'), location: z.enum(['QuickLaunch', 'TopNavigationBar']).optional().alias('l'), - title: z.string().alias('t'), + title: z.string().min(1, 'Cannot be empty.').alias('t'), url: z.string().optional(), parentNodeId: z.int().positive().optional(), audienceIds: z.string() diff --git a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts index 81b5510d657..fe28ffc6804 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts @@ -224,6 +224,11 @@ describe(commands.NAVIGATION_NODE_SET, () => { assert.notStrictEqual(actual.success, true); }); + it('fails validation if title is an empty string', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, title: '' }); + assert.notStrictEqual(actual.success, true); + }); + it('fails validation if no options are set to be changed', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id }); assert.notStrictEqual(actual.success, true); diff --git a/src/m365/spo/commands/navigation/navigation-node-set.ts b/src/m365/spo/commands/navigation/navigation-node-set.ts index bcc087a1d01..61d7da9913a 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.ts @@ -14,7 +14,7 @@ export const options = z.strictObject({ }) .alias('u'), id: z.int().positive(), - title: z.string().optional(), + title: z.string().min(1, 'Cannot be empty.').optional(), url: z.string().optional(), audienceIds: z.string() .refine(audienceIds => audienceIds === '' || audienceIds.split(',').length <= 10, { From 1a71b4c35b217ffb829dbfbf3acea941089788eb Mon Sep 17 00:00:00 2001 From: Adam-it <adam.wojcik.it@gmail.com> Date: Sun, 11 Oct 2026 01:30:42 +0200 Subject: [PATCH 3/5] Adds obligatory location option --- .../spo/navigation/navigation-node-get.mdx | 15 ++++-- .../spo/navigation/navigation-node-set.mdx | 11 ++-- .../navigation/navigation-node-get.spec.ts | 40 +++++++------- .../navigation/navigation-node-get.ts | 3 +- .../navigation/navigation-node-set.spec.ts | 52 +++++++++++++------ .../navigation/navigation-node-set.ts | 5 +- src/utils/spo.spec.ts | 25 +++++++++ src/utils/spo.ts | 26 ++++++---- 8 files changed, 121 insertions(+), 56 deletions(-) diff --git a/docs/docs/cmd/spo/navigation/navigation-node-get.mdx b/docs/docs/cmd/spo/navigation/navigation-node-get.mdx index 7b88c34411c..ad12ac6eba0 100644 --- a/docs/docs/cmd/spo/navigation/navigation-node-get.mdx +++ b/docs/docs/cmd/spo/navigation/navigation-node-get.mdx @@ -18,6 +18,9 @@ m365 spo navigation node get [options] `-u, --webUrl <webUrl>` : Absolute URL of the site. +`-l, --location <location>` +: Navigation type where the node is located. Available options: `QuickLaunch`, `TopNavigationBar`. + `--id <id>` : Id of the navigation node. ``` @@ -45,10 +48,16 @@ m365 spo navigation node get [options] ## Examples -Retrieve information for a specific navigation node. +Retrieve information for a specific navigation node from the quick launch. + +```sh +m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marketing --location QuickLaunch --id 2209 +``` + +Retrieve information for a specific navigation node from the top navigation. ```sh -m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marketing --id 2209 +m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marketing --location TopNavigationBar --id 2209 ``` ## Response @@ -126,7 +135,7 @@ m365 spo navigation node get --webUrl https://contoso.sharepoint.com/sites/Marke <TabItem value="Markdown"> ```md - # spo navigation node get --webUrl "https://contoso.sharepoint.com/sites/Marketing" --id "2209" + # spo navigation node get --webUrl "https://contoso.sharepoint.com/sites/Marketing" --location "QuickLaunch" --id "2209" Date: 10/7/2026 diff --git a/docs/docs/cmd/spo/navigation/navigation-node-set.mdx b/docs/docs/cmd/spo/navigation/navigation-node-set.mdx index 0adb0040919..952287e50e5 100644 --- a/docs/docs/cmd/spo/navigation/navigation-node-set.mdx +++ b/docs/docs/cmd/spo/navigation/navigation-node-set.mdx @@ -16,6 +16,9 @@ m365 spo navigation node set [options] `-u, --webUrl <webUrl>` : Absolute URL of the site. +`-l, --location <location>` +: Navigation type where the node is located. Available options: `QuickLaunch`, `TopNavigationBar`. + `--id <id>` : Id of the navigation node. @@ -45,25 +48,25 @@ SharePoint doesn't allow changing the URL of a navigation node pointing to a pag Updates the title of a navigation node ```sh -m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --title "Pictures" +m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --location TopNavigationBar --id 2209 --title "Pictures" ``` Updates the URL of a navigation node ```sh -m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --url "https://www.microsoft.com" +m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --location QuickLaunch --id 2209 --url "https://www.microsoft.com" ``` Updates audience targeting of a navigation node with 3 groups ```sh -m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --audienceIds "61f78c73-f71a-471e-a3b9-15daa936e200,9524e6b4-e663-44fe-b179-210c963e37e7,c42b8756-494d-4141-a575-45f01320e26a" +m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --location QuickLaunch --id 2209 --audienceIds "61f78c73-f71a-471e-a3b9-15daa936e200,9524e6b4-e663-44fe-b179-210c963e37e7,c42b8756-494d-4141-a575-45f01320e26a" ``` Updates the navigation node so that it opens in a new window ```sh -m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --id 2209 --openInNewWindow true +m365 spo navigation node set --webUrl https://contoso.sharepoint.com/sites/marketing --location QuickLaunch --id 2209 --openInNewWindow true ``` ## Response diff --git a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts index d5069ea6fd7..186535a31c5 100644 --- a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts @@ -144,17 +144,27 @@ describe(commands.NAVIGATION_NODE_GET, () => { }); it('fails validation if webUrl is not a valid SharePoint URL', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', id: id }); + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'QuickLaunch', id: id }); assert.notStrictEqual(actual.success, true); }); it('fails validation if id is not a valid number', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: 12.48 }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: 12.48 }); assert.notStrictEqual(actual.success, true); }); - it('passes validation when webUrl and id are specified', async () => { + it('fails validation if location is not specified', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if location is not valid', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'invalid', id: id }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation when webUrl, location and id are specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', id: id }); assert.strictEqual(actual.success, true); }); @@ -167,40 +177,34 @@ describe(commands.NAVIGATION_NODE_GET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, id: id, verbose: true } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, verbose: true } }); assert(loggerLogSpy.calledOnceWith(childNode)); }); it('retrieves navigation node from the top navigation', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `${webUrl}/_api/navigation/MenuState`) { - if (opts.data.menuNodeKey === null) { - return emptyQuickLaunchMenuState; - } - - if (opts.data.menuNodeKey === '1002') { - return topNavigationMenuState; - } + if (opts.url === `${webUrl}/_api/navigation/MenuState` && opts.data.menuNodeKey === '1002') { + return topNavigationMenuState; } throw 'Invalid request'; }); - await command.action(logger, { options: { webUrl: webUrl, id: id } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', id: id } }); assert(loggerLogSpy.calledOnceWith(childNode)); }); - it('throws an error when navigation node is not found', async () => { + it('throws an error when navigation node is not found in the specified location', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `${webUrl}/_api/navigation/MenuState`) { - return emptyQuickLaunchMenuState; + return opts.data.menuNodeKey === '1002' ? topNavigationMenuState : emptyQuickLaunchMenuState; } throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id } }), - new CommandError(`Navigation node with id '${id}' not found.`)); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id } }), + new CommandError(`Navigation node with id '${id}' not found in QuickLaunch.`)); }); it('correctly handles API error', async () => { @@ -211,7 +215,7 @@ describe(commands.NAVIGATION_NODE_GET, () => { } }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id } }), + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id } }), new CommandError("Attempted to perform an unauthorized operation.")); }); }); diff --git a/src/m365/spo/commands/navigation/navigation-node-get.ts b/src/m365/spo/commands/navigation/navigation-node-get.ts index cd0af3d772d..41861ae0a95 100644 --- a/src/m365/spo/commands/navigation/navigation-node-get.ts +++ b/src/m365/spo/commands/navigation/navigation-node-get.ts @@ -13,6 +13,7 @@ export const options = z.strictObject({ error: e => `'${e.input}' is not a valid SharePoint Online site URL.` }) .alias('u'), + location: z.enum(['QuickLaunch', 'TopNavigationBar']).alias('l'), id: z.int().positive() }); declare type Options = z.infer<typeof options>; @@ -40,7 +41,7 @@ class SpoNavigationNodeGetCommand extends SpoCommand { } try { - const { node } = await spo.getMenuStateNodeByKey(args.options.webUrl, args.options.id.toString()); + const { node } = await spo.getMenuStateNodeByKey(args.options.webUrl, args.options.id.toString(), args.options.location); await logger.log(node); } catch (err: any) { diff --git a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts index fe28ffc6804..f5e23e05e69 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts @@ -151,7 +151,7 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('updates all properties of a navigation node in the quick launch', async () => { const postStub = stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true, verbose: true } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true, verbose: true } }); assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, AudienceIds: audienceIds.split(','), @@ -164,7 +164,7 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('updates the title of a navigation node in the top navigation', async () => { const postStub = stubMenuStateRequests(true); - await command.action(logger, { options: { webUrl: webUrl, id: id, title: title } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'TopNavigationBar', id: id, title: title } }); const savedMenuState = getSavedMenuState(postStub); assert.strictEqual(savedMenuState.StartingNodeKey, '1002'); assert.deepStrictEqual(savedMenuState.Nodes[0], { ...node, Title: title }); @@ -173,29 +173,37 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('sets a navigation node not to open in a new window', async () => { const postStub = stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, id: id, openInNewWindow: false } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, openInNewWindow: false } }); assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, OpenInNewWindow: false }); }); it('clears audienceIds of a navigation node', async () => { const postStub = stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, id: id, audienceIds: '' } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, audienceIds: '' } }); assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, AudienceIds: [] }); }); it('sets a navigation node as linkless', async () => { const postStub = stubMenuStateRequests(); - await command.action(logger, { options: { webUrl: webUrl, id: id, url: '' } }); + await command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, url: '' } }); assert.deepStrictEqual(getSavedMenuState(postStub).Nodes[0].Nodes[0], { ...node, SimpleUrl: '' }); }); it('throws an error when the navigation node does not exist', async () => { stubMenuStateRequests(); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: 9999, title: title } }), - new CommandError(`Navigation node with id '9999' not found.`)); + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: 9999, title: title } }), + new CommandError(`Navigation node with id '9999' not found in QuickLaunch.`)); + }); + + it('throws an error when the navigation node exists in a different location than specified', async () => { + const postStub = stubMenuStateRequests(true); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, title: title } }), + new CommandError(`Navigation node with id '${id}' not found in QuickLaunch.`)); + assert(postStub.getCalls().every(call => call.args[0].url !== `${webUrl}/_api/navigation/SaveMenuState`)); }); it('correctly handles API error', async () => { @@ -210,52 +218,62 @@ describe(commands.NAVIGATION_NODE_SET, () => { } }); - await assert.rejects(command.action(logger, { options: { webUrl: webUrl, id: id, title: title } }), + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, location: 'QuickLaunch', id: id, title: title } }), new CommandError('An error has occurred')); }); it('fails validation if webUrl is not a valid SharePoint URL', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', id: id, title: title }); + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'QuickLaunch', id: id, title: title }); assert.notStrictEqual(actual.success, true); }); it('fails validation if id is not a valid number', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: 'invalid', title: title }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: 'invalid', title: title }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if location is not specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, title: title }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if location is not valid', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'invalid', id: id, title: title }); assert.notStrictEqual(actual.success, true); }); it('fails validation if title is an empty string', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, title: '' }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, title: '' }); assert.notStrictEqual(actual.success, true); }); it('fails validation if no options are set to be changed', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id }); assert.notStrictEqual(actual.success, true); }); it('fails validation if audienceIds contains more than 10 guids', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: Array(11).fill('7aa4a1ca-4035-4f2f-bac7-7beada59b5ba').join(',') }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, audienceIds: Array(11).fill('7aa4a1ca-4035-4f2f-bac7-7beada59b5ba').join(',') }); assert.notStrictEqual(actual.success, true); }); it('fails validation if audienceIds contains invalid guid', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: `${audienceIds},invalid` }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, audienceIds: `${audienceIds},invalid` }); assert.notStrictEqual(actual.success, true); }); it('fails validation if the removed isExternal option is specified', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, isExternal: true }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, isExternal: true }); assert.notStrictEqual(actual.success, true); }); it('passes validation if all options are set properly', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, title: title, url: nodeUrl, audienceIds: audienceIds, openInNewWindow: true }); assert.strictEqual(actual.success, true); }); it('passes validation if audienceIds is an empty string', async () => { - const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: id, audienceIds: '' }); + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, audienceIds: '' }); assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/spo/commands/navigation/navigation-node-set.ts b/src/m365/spo/commands/navigation/navigation-node-set.ts index 61d7da9913a..cd237011661 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.ts @@ -13,6 +13,7 @@ export const options = z.strictObject({ error: e => `'${e.input}' is not a valid SharePoint Online site URL.` }) .alias('u'), + location: z.enum(['QuickLaunch', 'TopNavigationBar']).alias('l'), id: z.int().positive(), title: z.string().min(1, 'Cannot be empty.').optional(), url: z.string().optional(), @@ -57,14 +58,14 @@ class SpoNavigationNodeSetCommand extends SpoCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { - const { webUrl, id, title, url, audienceIds, openInNewWindow } = args.options; + const { webUrl, location, id, title, url, audienceIds, openInNewWindow } = args.options; if (this.verbose) { await logger.logToStderr(`Updating navigation node with id ${id}...`); } try { - const { menuState, node } = await spo.getMenuStateNodeByKey(webUrl, id.toString()); + const { menuState, node } = await spo.getMenuStateNodeByKey(webUrl, id.toString(), location); if (title !== undefined) { node.Title = title; diff --git a/src/utils/spo.spec.ts b/src/utils/spo.spec.ts index cbe7abad17f..88ed259601c 100644 --- a/src/utils/spo.spec.ts +++ b/src/utils/spo.spec.ts @@ -999,6 +999,31 @@ describe('utils/spo', () => { await assert.rejects(spo.getMenuStateNodeByKey(webUrl, '9999'), (err) => err === `Navigation node with id '9999' not found.`); }); + it(`retrieves a navigation node only from the specified top navigation`, async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState` && opts.data.menuNodeKey === '1002') { + return topNavigationResponse; + } + + throw 'Invalid request'; + }); + + const actual = await spo.getMenuStateNodeByKey(webUrl, '2041', 'TopNavigationBar'); + assert.deepStrictEqual(actual, { menuState: topNavigationResponse, node: topNavigationResponse.Nodes[0].Nodes[0] }); + }); + + it(`throws an error when the navigation node is not found in the specified location`, async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/navigation/MenuState` && opts.data.menuNodeKey === null) { + return quickLaunchResponse; + } + + throw 'Invalid request'; + }); + + await assert.rejects(spo.getMenuStateNodeByKey(webUrl, '2041', 'QuickLaunch'), (err) => err === `Navigation node with id '2041' not found in QuickLaunch.`); + }); + it(`retrieves spo group by name sucessfully`, async () => { const groupResponse = { Id: 11, diff --git a/src/utils/spo.ts b/src/utils/spo.ts index 8d8a02f435d..6d5f04f1d9b 100644 --- a/src/utils/spo.ts +++ b/src/utils/spo.ts @@ -924,25 +924,29 @@ export const spo = { }, /** - * Retrieves the navigation node with the specified key from the quick launch or the top navigation. + * Retrieves the navigation node with the specified key. * Returns the node along with the menu state that contains it. * @param webUrl Web url * @param key Key (ID) of the navigation node + * @param location Navigation to search. When not specified, searches the quick launch first and then the top navigation */ - async getMenuStateNodeByKey(webUrl: string, key: string): Promise<{ menuState: MenuState, node: MenuStateNode }> { - let menuState: MenuState = await this.getQuickLaunchMenuState(webUrl); - let node: MenuStateNode | undefined = this.findMenuStateNode(menuState.Nodes, key); + async getMenuStateNodeByKey(webUrl: string, key: string, location?: 'QuickLaunch' | 'TopNavigationBar'): Promise<{ menuState: MenuState, node: MenuStateNode }> { + const locations: string[] = location ? [location] : ['QuickLaunch', 'TopNavigationBar']; - if (!node) { - menuState = await this.getTopNavigationMenuState(webUrl); - node = this.findMenuStateNode(menuState.Nodes, key); - } + for (const navigationLocation of locations) { + const menuState: MenuState = navigationLocation === 'TopNavigationBar' + ? await this.getTopNavigationMenuState(webUrl) + : await this.getQuickLaunchMenuState(webUrl); + const node: MenuStateNode | undefined = this.findMenuStateNode(menuState.Nodes, key); - if (!node) { - throw `Navigation node with id '${key}' not found.`; + if (node) { + return { menuState, node }; + } } - return { menuState, node }; + throw location + ? `Navigation node with id '${key}' not found in ${location}.` + : `Navigation node with id '${key}' not found.`; }, /** From 77ddebd6a29344fb5d3558befb75acb77277c45b Mon Sep 17 00:00:00 2001 From: Adam-it <adam.wojcik.it@gmail.com> Date: Sun, 11 Oct 2026 19:09:47 +0200 Subject: [PATCH 4/5] Updates to increase code coverage --- src/m365/spo/commands/navigation/navigation-node-add.spec.ts | 2 ++ src/m365/spo/commands/navigation/navigation-node-get.spec.ts | 1 + src/m365/spo/commands/navigation/navigation-node-set.spec.ts | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts index 63fb7c61efa..167f35b75cc 100644 --- a/src/m365/spo/commands/navigation/navigation-node-add.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-add.spec.ts @@ -309,6 +309,7 @@ describe(commands.NAVIGATION_NODE_ADD, () => { it('fails validation if webUrl is not a valid SharePoint URL', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'TopNavigationBar', title: title }); assert.notStrictEqual(actual.success, true); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid SharePoint Online site URL.`); }); it('fails validation if the specified parentNodeId is not a number', async () => { @@ -344,6 +345,7 @@ describe(commands.NAVIGATION_NODE_ADD, () => { it('fails validation if audienceIds contains an invalid audienceId', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'TopNavigationBar', title: title, audienceIds: `${audienceIds},invalid` }); assert.notStrictEqual(actual.success, true); + assert.strictEqual(actual.error?.issues[0].message, `The following GUIDs are invalid for the option 'audienceIds': invalid.`); }); it('fails validation if audienceIds contains more than 10 guids', async () => { diff --git a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts index 186535a31c5..11b0d48ccbb 100644 --- a/src/m365/spo/commands/navigation/navigation-node-get.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-get.spec.ts @@ -146,6 +146,7 @@ describe(commands.NAVIGATION_NODE_GET, () => { it('fails validation if webUrl is not a valid SharePoint URL', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'QuickLaunch', id: id }); assert.notStrictEqual(actual.success, true); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid SharePoint Online site URL.`); }); it('fails validation if id is not a valid number', async () => { diff --git a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts index f5e23e05e69..b233e5be44a 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts @@ -225,6 +225,7 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('fails validation if webUrl is not a valid SharePoint URL', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', location: 'QuickLaunch', id: id, title: title }); assert.notStrictEqual(actual.success, true); + assert.strictEqual(actual.error?.issues[0].message, `'invalid' is not a valid SharePoint Online site URL.`); }); it('fails validation if id is not a valid number', async () => { @@ -260,6 +261,7 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('fails validation if audienceIds contains invalid guid', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id, audienceIds: `${audienceIds},invalid` }); assert.notStrictEqual(actual.success, true); + assert.strictEqual(actual.error?.issues[0].message, `The following GUIDs are invalid for the option 'audienceIds': invalid.`); }); it('fails validation if the removed isExternal option is specified', async () => { From bfc5c1d2a325c3b40b99af4d33f12cbef3f7b41e Mon Sep 17 00:00:00 2001 From: Adam-it <adam.wojcik.it@gmail.com> Date: Sun, 18 Oct 2026 02:49:46 +0200 Subject: [PATCH 5/5] Fixup --- .../spo/commands/navigation/navigation-node-set.spec.ts | 1 + src/m365/spo/commands/navigation/navigation-node-set.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts index b233e5be44a..f29cc4eeef5 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.spec.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.spec.ts @@ -251,6 +251,7 @@ describe(commands.NAVIGATION_NODE_SET, () => { it('fails validation if no options are set to be changed', async () => { const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, location: 'QuickLaunch', id: id }); assert.notStrictEqual(actual.success, true); + assert.deepStrictEqual((actual.error?.issues[0] as any).params, { customCode: 'optionSet', options: ['title', 'url', 'audienceIds', 'openInNewWindow'] }); }); it('fails validation if audienceIds contains more than 10 guids', async () => { diff --git a/src/m365/spo/commands/navigation/navigation-node-set.ts b/src/m365/spo/commands/navigation/navigation-node-set.ts index cd237011661..bad61adfd3a 100644 --- a/src/m365/spo/commands/navigation/navigation-node-set.ts +++ b/src/m365/spo/commands/navigation/navigation-node-set.ts @@ -49,7 +49,11 @@ class SpoNavigationNodeSetCommand extends SpoCommand { public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { return schema .refine(options => [options.title, options.url, options.audienceIds, options.openInNewWindow].some(o => o !== undefined), { - error: 'Specify at least one property to update.' + error: 'Specify at least one property to update.', + params: { + customCode: 'optionSet', + options: ['title', 'url', 'audienceIds', 'openInNewWindow'] + } }); }