7 posts tagged with "sharepoint"

View All Tags

Improving Page Properties in SharePoint

Mike Homol

Mike Homol

Principal Consultant @ ThreeWill

Recently, I had the privilege of making my first post on the PnP Tech Community Blog. This is just a cross-post of that same article. Enjoy!

P.S. It's been a year since I started up this new blog and, while my regular posting has been down, this is definitely better than I've ever been about communicating what I know and do. The fact that a year in I'm now a contributor to now 2 different PnP repositories and an author in the tech community blog feels good. Now, on to the post.

Improving the Page Properties web part

Ever get annoyed with the page properties web part put out by Microsoft? If you've got some OCD issues (like me) then it may not take very long. At ThreeWill, we help clients with their digital workplaces and improving the way their users can obtain information and makes sense of it all. Oftentimes, the Page Properties web part can be useful here, as we very often add valuable metadata to pages in a digital workplace, which we often tie to page templates as well. News might roll up based on these page properties, which can assist in finding information in many ways. But its often handy to display this metadata in a clean way on a page as well. The standard Page Properties web part seeks to do just that. And, for the most part, it does a fine job with it. But it has a few deficiencies. The most annoying thing to me, when setting up digital workplaces was that it only supports a white background. But there are other small things, like the limitations with pretty standard field types. I like the idea of taking advantage of metadata columns for pages, but being able to use it visually is equally important. I finally decided to do something about it and build a new version of this web part. So with this in mind, let's lay out our goals with this new web part. We will call it the Advanced Page Properties web part.

Feature Goals

Attempt to replicate the functionality of Page Properties with the following improvements:

  • Support for theme variants
  • Updated to standard capsule look for list options
  • Support for image fields
  • Support for hyperlink fields
  • Support for currency
  • Improved support for dates

In other words, we're shooting for this: Desired End State

Property Pane

For a part like this, it's all about getting the property page figured out first. We want this to feel familiar too and not stray too much from the original design, unless it helps.

Let's start by recognizing our chief property that the web part needs: selectedProperties. This array will hold the internal names of the fields that a user has selected for display in our web part. We intend on passing this property down to our React component. Here's a look at our property object:

export interface IAdvancedPagePropertiesWebPartProps {
title: string;
selectedProperties: string[];
}

In our AdvancedPagePropertiesWebPart, we want to hold all possible properties for drop downs in a single array.

private availableProperties: IPropertyPaneDropdownOption[] = [];

Next, we need the following method to obtain the right types of properties for display:

private async getPageProperties(): Promise<void> {
Log.Write("Getting Site Page fields...");
const list = sp.web.lists.getByTitle("Site Pages");
const fi = await list.fields();
this.availableProperties = [];
Log.Write(`${fi.length.toString()} fields retrieved!`);
fi.forEach((f) => {
if (!f.FromBaseType && !f.Hidden && !f.Sealed && f.SchemaXml.indexOf("ShowInListSettings=\"FALSE\"") === -1
&& f.TypeAsString !== "Boolean" && f.TypeAsString !== "Note" && f.TypeAsString !== "User") {
this.availableProperties.push({ key: f.InternalName, text: f.Title });
Log.Write(f.TypeAsString);
}
});
}

We are using the PnP JS library for gathering the fields in the Site Pages library. Figuring out the right types of filters to gather was a bit of trial-and-error. We are excluding anything that's inherited from a base type or is hidden in any way. We are also excluding 3 standard types so far: boolean, note and user. Note doesn't make sense to display. Boolean can definitely work, but needs a good display convention. User was the only tricky object, which is the reason it isn't done yet.

We call the above method prior to loading up the property pane.

protected async onPropertyPaneConfigurationStart(): Promise<void> {
Log.Write(`onPropertyPaneConfigurationStart`);
await this.getPageProperties();
this.context.propertyPane.refresh();
}

We need handlers for adding and deleting a property and selecting a property from a dropdown. These methods make necessary changes to the selectedProperties array.

protected onAddButtonClick (value: any) {
this.properties.selectedProperties.push(this.availableProperties[0].key.toString());
}
protected onDeleteButtonClick (value: any) {
Log.Write(value.toString());
var removed = this.properties.selectedProperties.splice(value, 1);
Log.Write(`${removed[0]} removed.`);
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void {
if (propertyPath.indexOf("selectedProperty") >= 0) {
Log.Write('Selected Property identified');
let index: number = _.toInteger(propertyPath.replace("selectedProperty", ""));
this.properties.selectedProperties[index] = newValue;
}
}

Finally, with all of our pieces in place, we can render our property pane with all it's needed functionality.

protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
Log.Write(`getPropertyPaneConfiguration`);
// Initialize with the Title entry
var propDrops: IPropertyPaneField<any>[] = [];
propDrops.push(PropertyPaneTextField('title', {
label: strings.TitleFieldLabel
}));
propDrops.push(PropertyPaneHorizontalRule());
// Determine how many page property dropdowns we currently have
this.properties.selectedProperties.forEach((prop, index) => {
propDrops.push(PropertyPaneDropdown(`selectedProperty${index.toString()}`,
{
label: strings.SelectedPropertiesFieldLabel,
options: this.availableProperties,
selectedKey: prop,
}));
// Every drop down gets its own delete button
propDrops.push(PropertyPaneButton(`deleteButton${index.toString()}`,
{
text: strings.PropPaneDeleteButtonText,
buttonType: PropertyPaneButtonType.Command,
icon: "RecycleBin",
onClick: this.onDeleteButtonClick.bind(this, index)
}));
propDrops.push(PropertyPaneHorizontalRule());
});
// Always have the Add button
propDrops.push(PropertyPaneButton('addButton',
{
text: strings.PropPaneAddButtonText,
buttonType: PropertyPaneButtonType.Command,
icon: "CirclePlus",
onClick: this.onAddButtonClick.bind(this)
}));
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.SelectionGroupName,
groupFields: propDrops
}
]
}
]
};
}

Our Component and Displaying our fields/values

Our React component needs to properly react to the list of selected properties changing. It also needs to react to our theme changing. I leveraged this awesome post from Hugo Bernier for the theming, so I will not cover that in-depth, although you will see how it's being leveraged in the code snippets below. Here are the properties we plan to start with and respond to:

import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface IAdvancedPagePropertiesProps {
context: WebPartContext;
title: string;
selectedProperties: string[];
themeVariant: IReadonlyTheme | undefined;
}

We will track the state of our selected properties and their values with hooks. We want to trigger off of changes to our properties, so we will setup a reference to their current state. We will also establish our themeVariant and context at the start of our component.

// Main state object for the life of this component - pagePropValues
const [pagePropValues, setPagePropValues] = useState<PageProperty[]>([]);
const propsRef = useRef(props);
const { semanticColors }: IReadonlyTheme = props.themeVariant;
propsRef.current = props;
sp.setup({ spfxContext: props.context });

So we are tracking the state of pagePropValues, which is an array of type PageProperty. What is PageProperty?

import { IFieldInfo } from "@pnp/sp/fields";
export interface PageProperty {
info: IFieldInfo;
values: any[];
}

Our effect is looking to see when changes are made to the properties, then is peforming our core logic to refresh properties and values.

/**
* @description Effects to fire whenever the properties change
*/
useEffect(() => {
refreshProperties();
return () => {
// No cleanup at this moment
};
}, [propsRef.current]);

The core method is refreshProperties. It has 2 main calls it needs to make, whenever selected properties has changed: Establish any known metadata for each property that will assist in display and obtain all actual values for this property and the specific page id that we are viewing.

/**
* refreshProperties
* @description Gets the actual values for any selected properties, along with critical field metadata and ultimately re-sets the pagePropValues state
*/
async function refreshProperties () {
var newSetOfValues: PageProperty[] = [];
if (props.selectedProperties !== undefined && props.selectedProperties !== null) {
Log.Write(`${props.selectedProperties.length.toString()} properties used.`);
// Get the value(s) for the field from the list item itself
var allValues: any = {};
if (props.context.pageContext.listItem !== undefined && props.context.pageContext.listItem !== null) {
allValues = await sp.web.lists.getByTitle("Site Pages").items.getById(props.context.pageContext.listItem.id).select(...props.selectedProperties).get();
console.log(allValues);
}
for (let i = 0; i < props.selectedProperties.length; i++) {
const prop = props.selectedProperties[i];
Log.Write(`Selected Property: ${prop}`);
// Get field information, in case anything is needed in conjunction with value types
const field = await sp.web.lists.getByTitle("Site Pages").fields.getByInternalNameOrTitle(prop)();
// Establish the values array
var values: any[] = [];
if (allValues.hasOwnProperty(prop)) {
switch (field.TypeAsString) {
case "TaxonomyFieldTypeMulti":
case "MultiChoice":
values = _.clone(allValues[prop]);
break;
case "Thumbnail":
values.push(JSON.parse(allValues[prop]));
break;
default:
// Default behavior is to treat it like a string
values.push(allValues[prop]);
break;
}
}
// Push the final setup of a PageProperty object
newSetOfValues.push({ info: field, values: [...values] });
}
setPagePropValues({...newSetOfValues});
}
}

As we loop through all of the properties that have been selected, we make calls with PnP JS to get all of the metadata per field and all of the values per field. The call to get all of the values can return with any number of data types, so we need to be prepared for that. This is why it is of type any[] to start. But this is also why we have a switch statement for certain outlier situations, where the line to set the array of any need to be done a little differently than the default. Our 3 known cases of needing to do something different are TaxonomyFieldTypeMulti, MultiChoice and Thumbnail.

React and Display

Our function component returns the following:

return (
<div className={`${styles.advancedPageProperties} ${styles.container}`} style={{backgroundColor: semanticColors.bodyBackground, color: semanticColors.bodyText}}>
{RenderTitle()}
{RenderPageProperties()}
</div>
);

RenderTitle is pretty straightforward.

/**
* RenderTitle
* @description Focuses on the 1 row layer, being the Title that has been chosen for the page
* @returns
*/
const RenderTitle = () => {
if (props.title !== '') {
return <div className={styles.title}>{props.title}</div>;
} else {
return null;
}
};

RenderPageProperties is the first of a 2-dimensional loop, where we want to display a section for each page property that was select, just like the original.

/**
* RenderPageProperties
* @description Focuses on the 2nd row layer, which is the property names that have been chosen to be displayed (uses Title as the display name)
* @returns
*/
const RenderPageProperties = () => {
if (pagePropValues !== undefined && pagePropValues !== null) {
var retVal = _.map(pagePropValues, (prop) => {
return (
<>
<div className={styles.propNameRow}>{prop.info.Title}<span style={{display: 'none'}}> - {prop.info.TypeAsString}</span></div>
<div className={styles.propValsRow}>
{RenderPagePropValue(prop)}
</div>
</>
);
});
return retVal;
} else {
return <i>Nothing to display</i>;
}
};

This method then calls our final display method, RenderPagePropValue, which performs our 2nd layer of array display, mapping all of the values and providing the correct display, based on the field type of the selected property. This is the heart of the display, where various type conversions and logic are done real-time as we display the values, including trying to achieve a slightly more modern SharePoint look using capsules for array labels.

/**
* RenderPagePropValue
* @description Focuses on the 3rd and final row layer, which is the actual values tied to any property displayed for the page
* @param prop
* @returns
*/
const RenderPagePropValue = (prop: PageProperty) => {
console.log(prop);
var retVal = _.map(prop.values, (val) => {
if (val !== null) {
switch (prop.info.TypeAsString) {
case "URL":
return (
<span className={styles.urlValue}><a href={val.Url} target="_blank" style={{color: semanticColors.link}}>{val.Description}</a></span>
);
case "Thumbnail":
return (
<span><img className={styles.imgValue} src={val.serverRelativeUrl} /></span>
);
case "Number":
return (
<span className={styles.plainValue}>{(prop.info["ShowAsPercentage"] === true ? Number(val).toLocaleString(undefined,{style: 'percent', minimumFractionDigits:0}) : (prop.info["CommaSeparator"] === true ? val.toLocaleString('en') : val.toString()))}</span>
);
case "Currency":
return (
<span className={styles.plainValue}>{(prop.info["CommaSeparator"] === true ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val) : Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', useGrouping: false }).format(val))}</span>
);
case "DateTime":
//,"",,
switch (prop.info["DateFormat"]) {
case "StandardUS":
return (
<span className={styles.plainValue}>{new Date(val).toLocaleDateString()}</span>
);
case "ISO8601":
const d = new Date(val);
return (
<span className={styles.plainValue}>{`${d.getFullYear().toString()}-${d.getMonth()}-${d.getDate()}`}</span>
);
case "DayOfWeek":
return (
<span className={styles.plainValue}>{new Date(val).toLocaleDateString("en-US", { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</span>
);
case "MonthSpelled":
return (
<span className={styles.plainValue}>{new Date(val).toLocaleDateString("en-US", { month: 'long', day: 'numeric', year: 'numeric' })}</span>
);
default:
return (
<span className={styles.plainValue}>{new Date(val).toLocaleDateString()}</span>
);
}
case "TaxonomyFieldTypeMulti":
case "TaxonomyFieldType":
return (
<span className={styles.standardCapsule} style={{backgroundColor: semanticColors.accentButtonBackground, color: semanticColors.accentButtonText}}>{val.Label}</span>
);
default:
return (
<span className={styles.standardCapsule} style={{backgroundColor: semanticColors.accentButtonBackground, color: semanticColors.accentButtonText}}>{val}</span>
);
}
} else {
return (<span className={styles.plainValue}>N/A</span>);
}
});
return retVal;
};

So that's all of the necessary code. Here's what the finished product looks like, compared to the original page properties web part.

Old vs New

This web part is now officially apart of the PnP Web Parts repository and can be found here. I would love to hear about improvements you'd like to see and obviously you are more than welcome to contribute. I already have a bit of a list of things I'd love to see it do.

Other ideas for improvements

  • Capsules to be linkable to either a search result page or a filtered view of site pages (we always get client requests for this)
  • Support for People fields (this is the only thing lacking from the original)
  • Support for Boolean fields (just need the right idea for proper display, really)
  • Styling per property (ie. colorizing per property or something to that effect)

Conclusion

Hopefully, I've gotten you excited about Page Properties again and you've learned a little along the way around how the current Page Properties part might be doing what it does under the hood. Please consider contributing and feel free to reach out to me anytime. Thanks for your time!

ALWAYS use Disconnect-PnPOnline

Mike Homol

Mike Homol

Principal Consultant @ ThreeWill

I learned a valuable lesson in the use of Disconnect-PnPOnline : ALWAYS use it!

I had a provisioning script that first connected to the tenant admin to create a new site, then switched contexts to provision files, including a newly laid out home page, onto the newly created site.

I had a connection issue midway through one particular run, where it successfully connected to the tenant admin but didn't successfully connect to the new site, but the script kept running successfully because, hey, it still had a context - to the tenant admin site.

Essentially, I was able to change the Home page of the Sharepoint Admin Center. Thankfully, I was also able to use Set-PnPHomePage to set it back to _layouts/15/online/AdminHome.aspx#/home. But my heart was skipping a few beats there for a bit.

If I had just used Disconnect-PnPOnline in between switching contexts then everything would have just stopped. So you've now been warned.

Better Sharing is Better Caring

Mike Homol

Mike Homol

Principal Consultant @ ThreeWill

As the PnP Weekly adage goes, "Sharing is Caring". So I posit the following: does this imply that "Better Sharing is Better Caring?" You be the judge. 😜

Current state of knowledge sharing - it's great!

The state of sharing in the development world, particularly thanks to the gains of open source over the years, has never been stronger. We can push code to GitHub almost instantaneously. We have lots of ways of describing our code to others using markdown files, typically at the root of our solution. Maybe we've even gone the distance and built out a set of GitHub pages. In it we have real writeups on specific features with example snippets of code or showing how to use our product.

But can it be better?

Have you ever felt like there's almost too much to learn and to unpack? Developers are expected to move faster than ever and know more than ever. How do you move quickly when you're jumping into a new technology or project without feeling overwhelmed? Is there more that we can add to our tool belt to assist with knowledge sharing, documentation or investigation? Allow me to throw at you 2 new technologies to assist in this endeavor: Jupyter Labs and CodeTour.

Jupyter Labs

Jupyter Labs comes from the Python world of data science. But it has moved far beyond just that. Essentially, it leverages Python to allow for different languages to have a runnable kernel against a jupyter notebook. What this gives you is something quite powerful and cool. It's a web application that allows you to create and share documents that contain live code, equations, visualizations and narrative text.

I once described it to my team as this: imagine having a wiki with runnable code snippets directly in the wiki. One of my teammates, upon seeing a demo for the first time, described it in a somewhat opposite way: it's like code with way better commenting. Either way you look at it, it's certainly more powerful that just code or just documentation.

Getting Jupyter Labs set up

Jupyter's site has instructions to install here. But just doing that isn't going to be as powerful, especially if you're a Microsoft developer. Jupyter's base install is a Python notebook that can run in the Kernel, We want to deal in things like C# and PowerShell. So lets add that to kernel using .Net Interactive. Personally, I think Scott Hanselman's instructions here may be your best bet, especially if you're on Windows. This means you'll need Anaconda installed first (remember all this is based on Python).

PowerShell Core

As you've probably already caught on, this is Python and cross-platform. This means we Microsoft folks need to stick with all things Core. .Net interactive's notebooks give us C#, F# and PowerShell Core though, so we have some fun things we can do. This does mean that the PnP PoSH folks are on the outside looking in, until it supports PowerShell Core. But hopefully that's coming very soon. So check out what we can do using PowerShell Core, in the examples below, and hopefully that will get your mind spinning about other things you could do, including when PnP gets added to PS Core.

Azure CLI example

So keeping in mind that we are sticking to PowerShell Core, I whipped up a few examples of utilizing other CLI's with PowerShell to do some computing. Let's start with Azure CLI. Below is something simple. I just copied the MS documentation for getting started with the CLI into a notebook.

It's a totally different way to imagine documentation. Allow readers to instantly see the results, in the context of their own data!

Office 365 CLI example

Let's look at another aspect of using these notebooks: helping your team get something done. In this example, I've crafted some instructions to give to someone to create a site with the same theme that I made inside my tenant. Check it out.

Code Tour

Let's end with a bang. I have absolutely fallen in love with this next one: CodeTour. It's pretty new extension for VS Code and allows for providing a tour of your solution. As someone who has a passion for learning and teaching, I can't think of a better way to handle the onboarding experience for coders than a guided tour. And there are many other applications too. Recently, the PnP team used Code Tour to assist with the SPFx Project Upgrade. I'm sure once you play around with it, you will also think of new applications for it.

Install the CodeTour VS Code extension

Get the extension here. I'm assuming that you already have VS Code. 😜 Also, a shout out to Jonathan Carter, the brains behind this. He's very receptive to feedback too so hit him up.

CodeTour example

I'll stay on point here and keep within the realm of PowerShell. Here's something I did recently for a PnP Provisioning Script for a client.

As you can see, it's a wonderful and powerful way to onboard or to simply amp up your documentation for a piece of code or for a script like this one.

Conclusion

Hopefully I've provided you some new thought-starters for better ways to share information. These technologies and others should become part of our best practices tool bag. They allow for easier explanation of code, faster results in collaboration, simpler paths to onboarding and so much more. Please take the time to consider how you might use these solutions on your next project.

The trick to migrating list parts on a page with custom views with PnP

Mike Homol

Mike Homol

Principal Consultant @ ThreeWill

Adventures in PnP PowerShell provisioning templates

Has this ever happened to you? I had built a custom list with a custom view. To be more precise, I had basically lifted Chris Kent's sample here for a custom FAQ and dropped this on the page and the client was thrilled with it just as it is. Thanks Chris! Here's what the FAQ page looked like on the template site:

The way an FAQ should look
Not a bad looking FAQ list, right?

But this is just the beginning! This was my template. I need lots of sites to have this same FAQ page as a starting point, and it needs to look this good too.

So, onto provisioning with Powershell and PnP! At first I was running this:

Get-PnPProvisioningTemplate -Out $templateFull -Verbose -PersistBrandingFiles `
-PersistPublishingFiles -IncludeAllClientSidePages -Force
Apply-PnPProvisioningTemplate -Path $templateFull

Looks familiar right? Well pretty much everything was working great except for this:

No bueno FAQ
Not so good. Also don't focus on the color difference lol

What the heck was going on here? The view and the list were migrating just fine, but that view was not getting applied! Or, was it? I noticed this in the List part properties:

FAQ Properties
Something is amiss

See anything off? Nothing is selected in the view drop down, even though it is selected in the my template site.

Acting on a Hunch

So here was my hunch. Perhaps, the pages are getting deployed before the custom list and custom view, sooo when the page gets made, there's no view to select, which is why it looks like the above. I acted on this hunch, by doing the following:

I split out just the FAQ list portion from the full Get-PnPProvisioningTemplate - essentially doing 2 Gets: one for the list only and one for everything else. Here's what that looked like:

Get-PnPProvisioningTemplate -Out $templateFull -Verbose -PersistBrandingFiles `
-PersistPublishingFiles -IncludeAllClientSidePages -Force
Get-PnPProvisioningTemplate -Out $templateListOnly -Verbose -Handlers Lists `
-ListsToExtract "FAQ" -Force

Now you have 2 files. But there's 1 trick to this, if you want it to work in your favor. You need to open up the XML file for everything, and delete just the ListInstance node for the list (in my case, FAQ) from the XML file. So you can't easily do this all in one full script. You'd have to keep your pulls separate from your applies because of this manual intervention.

Then I applied my 2 files separately as well, starting with the lists first:

Apply-PnPProvisioningTemplate -Path $templateListOnly
Apply-PnPProvisioningTemplate -Path $templateFull

And, viola! My FAQ list was displaying as expected on the page, because the view was already found for the web part property because it already existed.

Thoughts on Customer-Centric Nav

Mike Homol

Mike Homol

Principal Consultant @ ThreeWill

Discovery

Recently, I was tasked with discovering the needs of a customer in order to assess their current state in SharePoint Online and needs for the future. This was for a collection of technology groups that performed a wide array of services for other employees within the company. Early on, we knew that much of what we were evaluating was a lack of access to these services via SharePoint, so much would be created for the first time. Additionally, for the content that did exist, there was a lack of understanding in where the items could be found. Another interesting issue was the lack of understanding of the functions of these groups. Basically, a number of interesting problems, but largely centering around the employees being serviced, or their customers, if you will.

To the Hub!

Based on the feedback, particularly around wanting consistency and knowing that there would be a hurdle with getting folks to know where to go, we decided early on to use a Hub for the job. It solves so many issues out of the box: consistent look-and-feel, consistent navigation, the ability to roll up content from spoke sites and search scoped at the hub level, among other benefits. But just because we now have a spot for folks to come and a place where maybe things look better and more consistent, it doesn't mean that they will suddenly know what to do. Part of the art of helping folks with SharePoint intranets or portals has very little to do with the tooling that is or isn't available in the 365 platform. Much of it is a User Experience and an Information Architecture problem. This may be my heavy marketing background at play, but it felt critical to me to deal with the hub needing to be customer-centric from the get-go, particularly the navigation.

Ways to Solve Navigation

There were many thoughts on the navigation, both from stakeholders and discovery. The default thinking around the Hub navigation was to break it up by the various domains or spheres of teams that were solving problems or serving customers. One of my first thoughts on navigation was to look into taking it towards a task-based navigation, as it seemed like customers may only know what thing they may need to do. In my UX research, I encounter the following 2 articles that I wanted to highlight:

Object-focused vs Task-focused Design

Audience-Based Navigation: 5 Reasons to Avoid It

These are great resources for gaining insights into user habits and trends. So what is actually the best way to have a navigation that keeps the customer at the forefront?

Topic-based = Customer-centric

The bottom-line is that typically task-based navigation can be very confusing to most users. So that was a wash. What becomes clear from UX research is that there isn't always a silver bullet. But UX is an iterative process. So let's find a solid starting point. Based on those articles and knowing that we still needed information to be somewhat grouped by communications site spokes - we still need to be thinking about the authoring challenges too - it seemed like our best starting point would be to go with topic-based navigation. This would allow the sub-options to still be domain or sphere-based, but should provide a customer with the terminology that they can relate to. The customer needs to see words that represent the things that have brought them out to the hub.

Example

Let's look at a quick example of one of these domains: a group that we will call PMG. They were responsible for presenting resources pertinent to PMs and the PMI certifications including in-house governance resources. Originally, we had a top-level navigation called "PMG" with sub-options. But instead we landed here:

As you can see, the topic of "Project Support" aims to be a topic that should gravitate these PM users' eyes to the item that matters most to them. Is it fool-proof? No. But it's a solid start.

Final thoughts

IA and UX is 2 parts science and 1 part art, in my humble opinion. I have worked on countless websites (namely at Brown Bag Marketing) over the past 10 years with Creative and UX resources. I've seen the sheer amount of different opinions mixed with a bevy of different facts, which also tend to change. But some things have remained true: the customer needs to be at the forefront and the calls to action need to be clear and take precedence. These are just some random thoughts in that arena. Hope you found it useful.