SPFx – Custom Left Navigation For SharePoint Online

Introduction

Hi friends, in this article, we will see how we can use SPFx webpart or controls combined with Office UI Fabric controls and SharePoint lists to display the left navigation dynamically from the list item. I hope many have encountered this scenario of developing custom navigation in SharePoint Online. I hope this article will provide some guidelines and help you to achieve your left navigation simply and more efficiently.

Limitations of OOB navigation

SharePoint provides different navigation for different site templates. Below are the list of navigations provided by default.

  • Left Navigation – For team sites
  • Global Navigation – which can be modified with logo for different styles
  • Hub Navigation – Which is via the Hub site.

Some limitations are attached to the OOB, like adding icons and loading based on the user’s permission.

Why Custom Navigation

Custom navigation provides the flexibility of using list items, making it more convenient for the end users. In most organisations, there will be a separate administrator for different sites based on the division or department, and it’s not guaranteed that all the admins are knowledgeable in using SharePoint. They feel difficulty using the default settings page to update the navigation; if any menu is deleted, I am unsure how to restore it. These are just a couple of limitations to address when we use the list items.

List Schema

Below are the fields that are used.

  • Title – Default title field – Menu title to be displayed to the end users
  • PageUrl – Hyperlink – Page url to be navigated.
  • IconName – Single line of Text – Office UI Fabric icon name to be used for the menu items
  • Sequence – Number – Display sequeence
  • IsParent – Yes or No – Make it true if sub menus are attached to this menu item.
  • Position – Choice – Whether the menu has to be displayed in the left or top navigation (If you want to use the same list for both left and top navigation, you can use this field else no need)
  • ParentMenu – Lookup (on the same list title field) – Map the parent menu for the sub menu items
  • IsActive – Yes or No – Field for soft delete. Only active menus are displayed for the end users. I use this field in all of my custom applications to ensure the items are not deleted permanently.

Focus on the Code

You can refer to the Part 1 post on creating the SPFx project and installing all the dependencies. In addition to the dependencies mentioned in that post, you must install @pnp/sp – v3.13.0. It would be best if you also made some project file changes to make the pnpjs version 3 work on SPFx 1.16.1. Please follow this link to make the changes.

Note: I changed the default CSS class to hide some of the section canvas. Microsoft does not recommend this, and you can implement it at your own risk.

Below is the structure of the

Below is the code for the LeftNavigationWebPart.ts file

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
	IPropertyPaneConfiguration,
	PropertyPaneTextField
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { SPFI, spfi, SPFx } from "@pnp/sp";
import * as strings from 'LeftNavigationWebPartStrings';
import LeftNavigation from './components/LeftNavigation';
import { ILeftNavigationProps } from './components/LeftNavigation';

export interface ILeftNavigationWebPartProps {
	description: string;
}

export default class LeftNavigationWebPart extends BaseClientSideWebPart<ILeftNavigationWebPartProps> {

	private _isDarkTheme: boolean = false;
	private _environmentMessage: string = '';
	private _sp: SPFI;

	public render(): void {
		const element: React.ReactElement<ILeftNavigationProps> = React.createElement(
			LeftNavigation,
			{
				description: this.properties.description,
				isDarkTheme: this._isDarkTheme,
				environmentMessage: this._environmentMessage,
				hasTeamsContext: !!this.context.sdks.microsoftTeams,
				userDisplayName: this.context.pageContext.user.displayName,
				sp: this._sp
			}
		);

		ReactDom.render(element, this.domElement);
	}

    private hideControls(classnames: string): void {
        let style: string;
        style = `
            ${classnames}
            {
                display: none !important;
            }
            .CanvasSection--read .ControlZone {
                margin-top: 0px;
            }
            div[data-automation-id='CanvasZone'] > div {
                width: 100%;
                max-width: unset !important;
            }
            `;
        const head = document.head || document.getElementsByTagName('head')[0];
        const styletag = document.createElement('style');
        styletag.type = 'text/css';
        styletag.appendChild(document.createTextNode(style));
        head.appendChild(styletag);
    }

	protected async onInit(): Promise<void> {
		await super.onInit();
		this._sp = spfi().using(SPFx(this.context));
        this.hideControls('div[Id$="CommentsWrapper"], div[data-automation-id="pageHeader"]');
		return this._getEnvironmentMessage().then(message => {
			this._environmentMessage = message;
		});
	}



	private _getEnvironmentMessage(): Promise<string> {
		if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
			return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
				.then(context => {
					let environmentMessage: string = '';
					switch (context.app.host.name) {
						case 'Office': // running in Office
							environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
							break;
						case 'Outlook': // running in Outlook
							environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
							break;
						case 'Teams': // running in Teams
							environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
							break;
						default:
							throw new Error('Unknown host');
					}

					return environmentMessage;
				});
		}

		return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
	}

	protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
		if (!currentTheme) {
			return;
		}

		this._isDarkTheme = !!currentTheme.isInverted;
		const {
			semanticColors
		} = currentTheme;

		if (semanticColors) {
			this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
			this.domElement.style.setProperty('--link', semanticColors.link || null);
			this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
		}

	}

	protected onDispose(): void {
		ReactDom.unmountComponentAtNode(this.domElement);
	}

	protected get dataVersion(): Version {
		return Version.parse('1.0');
	}

	protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
		return {
			pages: [
				{
					header: {
						description: strings.PropertyPaneDescription
					},
					groups: [
						{
							groupName: strings.BasicGroupName,
							groupFields: [
								PropertyPaneTextField('description', {
									label: strings.DescriptionFieldLabel
								})
							]
						}
					]
				}
			]
		};
	}
}

Below are the changes done

  • Modified the onInit method to register the pnp SP context object
  • Invoked hide controls methods to hide a few default controls as mentioned above

Below is the code for LeftNavigation.module.scss file

@import '~@fluentui/react/dist/sass/References.scss';

.leftNavigation {
    ul {
        li {
            div {
                button[class*="ms-Nav-chevronButton"] {
                    right: 5px !important;
                    left: unset !important;
                }
                button[class*="ms-Nav-link"] {
                    padding-left: 4px !important;
                }
            }
        }
    }
}

Below os the code for LeftNavigation.tsx file

import * as React from 'react';
import { FC, useState, useEffect } from 'react';
import styles from './LeftNavigation.module.scss';
import { SPFI } from '@pnp/sp';
import "@pnp/sp/webs";
import "@pnp/sp/site-users/web";
import "@pnp/sp/lists/web";
import "@pnp/sp/items";
import { INavLink, INavLinkGroup, Nav, Spinner, Stack } from 'office-ui-fabric-react';

export interface ILeftNavigationProps {
	description: string;
	isDarkTheme: boolean;
	environmentMessage: string;
	hasTeamsContext: boolean;
	userDisplayName: string;
	sp: SPFI;
}

const LeftNavigation: FC<ILeftNavigationProps> = (props) => {
	const { sp } = props;
	const [loading, setLoading] = useState<boolean>(true);
	const [menuItems, setMenuItems] = useState<INavLinkGroup[]>(undefined);
	const [selMenu, setSelMenu] = useState<string>(undefined);

	const getActiveMenuItems = async (): Promise<any[]> => {
		const filQuery = `IsActive eq 1 and Position eq 'Left'`;
		return await sp.web.lists.getByTitle('Menus').items
			.select('ID', 'Title', 'PageUrl', 'IconName', 'Sequence', 'IsParent', 'ParentMenu/Id', 'ParentMenu/Title', 'IsActive')
			.expand('ParentMenu')
			.filter(filQuery)();
	};

	const _loadLeftNavigation = async (): Promise<void> => {
		const menuItems: any[] = await getActiveMenuItems();
		if (menuItems.length > 0) {
			const navLinks: INavLinkGroup[] = [];
			const navLink: INavLink[] = [];
			if (menuItems.length > 0) {
				const fil = menuItems.filter((mi: any) => mi.IsParent);
				if (fil && fil.length > 0) {
					fil.map((item: any) => {
						const subMenus: any[] = menuItems.filter((smi: any) => !smi.IsParent && smi.ParentMenu?.Id === item.ID);
						let navsubLink: INavLink[] = [];
						if (subMenus && subMenus.length > 0) {
							subMenus.map((item: any) => {
								if (item.PageUrl?.Url.toLowerCase() === (window.location.origin + window.location.pathname).toLowerCase())
									setSelMenu(item.ID.toString());
								navsubLink.push({
									key: item.ID.toString(),
									name: item.Title,
									url: item.PageUrl?.Url,
									expandAriaLabel: item.Title,
									icon: item.IconName,
								});
							});
						}
						if (item.PageUrl?.Url.toLowerCase() === (window.location.origin + window.location.pathname).toLowerCase())
							setSelMenu(item.ID.toString());
						navLink.push({
							key: item.ID.toString(),
							name: item.Title,
							url: item.PageUrl?.Url,
							expandAriaLabel: item.Title,
							icon: item.IconName,
							links: navsubLink.length > 0 ? navsubLink : [],
							isExpanded: true
						});
						navsubLink = [];
					});
				}
				navLinks.push({ links: navLink });
				setMenuItems(navLinks);
			}
		}
		setLoading(false);
	};

	const _linkClick = (ev?: React.MouseEvent<HTMLElement, MouseEvent>, item?: INavLink): void => {
		if (item) {
			setSelMenu(item.ID.toString());
		}
	};

	useEffect(() => {
		(async () => {
			await _loadLeftNavigation();
		})();
	}, []);

	return (
		<div className={styles.leftNavigation}>
			<Stack tokens={{ childrenGap: 10 }} horizontal horizontalAlign='start'>
				<Stack.Item style={{ width: '25%', borderRight: '1px solid #CCC' }}>
					<div style={{ marginRight: '5px' }}>
						{loading ? (
							<Spinner label='Please wait...' labelPosition='top' />
						) : (
							<>
								<Nav selectedKey={selMenu} groups={menuItems} className={styles.leftNavigation} onLinkClick={_linkClick} />
							</>
						)}
					</div>
				</Stack.Item>
				<Stack.Item style={{width: '75%'}}>
					<div>
						Page Content goes here.
					</div>
				</Stack.Item>
			</Stack>
		</div>
	);
};

export default LeftNavigation;

Below are the changes done. Used Reack Hooks for the component.

  • Used selective imports for the pnp modules
  • Received the SP object from the props
  • Created 3 state variables
    • Loading – Display loading icon while retrieving the list items
    • menuItems – To store the menu items from the list
    • selMenu – To store the selected menu item.
  • getActiveMenuItems – Method to retrieve the active left menu items from the list. I have named the list as Menus.
  • _loadLeftNavigation – Method called while the component is loaded. Get the menu items from the list by calling the getActiveMenuItems method and then re-structuring the menu to load via Nav control from Office UI fabric.
  • _linkClick – Method to be executed when clicking or selecting the menu item
  • useEffect – Method to be executed when the component is loaded.

Conclussion

As you can see from the code explained above, it’s straightforward and easy to implement custom navigation. I hope you enjoyed and learned some new things from this article. See you next time in another interesting article.

SPFx Left Navigation

Happy Coding…

Advertisement

One thought on “SPFx – Custom Left Navigation For SharePoint Online

  1. Pingback: SPFx – Custom Global Navigation For SharePoint Online | Knowledge Share

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s