Nov 18, 2019

User preferences in your SPFx solutions

  Hi SharePoint Devs !

In this blog post, I want to share a pattern I came up with to address the need of handling user preferences in a SPFx WebPart.

The requirements

I want the end-users to be able to save some of their own preferences in a SPFx WebPart. The preferences can be anything according to the needs of the implemented solution.

The approach

Among several possible approaches, I chose to leverage local storage. A standard feature included in all of the modern browsers. it works as a dictionary data structure for a particular web domain, so for a specific key, you can set a value. However, keep in mind the following pros and cons:

Pros

  • Easy to use
  • Persistent data
  • Data privacy, the data is local to user's browser and never shared with the server side

Cons

  • Local storage is stored in the internal browser data, which means, it won't be found if the users decides to use an other browser, and obviously, from an other device. However, browsers like Chrome allow to sync user data across devices, that kind of features might help.

To keep in mind

Local storage is scoped to the current web domain, that means that, on any page of a same tenant, the key and its defined value is accessible for the same user. It can be useful in some scenarios, but in our case, we would like to save preferences for a particular SPFx WebPart (or extension instance). Moreover, we would like the user to be able to save preferences for a particular instance, even if multiple instances of a same WebPart are added on a same page. In order to address that, we will define a key that is strongly tied to our WebPart instance : the instance Id.

The implementation

A few month ago I wrote a blog post about scoping the context (or the service instances) to a particular WebPart instance rather than scoping to the page. I then implemented a sample using the same technique of implementing a SPFx service that must be configured to be scoped to a WebPart instance. The sample, that you can see an exhibit here below, is simply a list of super heroes that the user can click to set as his/her favorite. Obviously that is probably not a real-word need, but it illustrates quite well the implementation keeping the business logic very simple.

user_prefs_sample01.png

We can see the content of the local storage dictionary to see what values are stored and how they are keyed.

user_prefs_f12_localstorage

  1.  A Prefix in order to identify our own entries (SharePoint Online massively uses local storage so we need to identify them
  2. The unique IDs of the WebPart instances, it ensures to keep preferences for each specific instance
  3. A stringified JSON object, in our case, the object has only one favoriteSuperHero property, but that object might contains dozen of properties without any issues

Here is the code of the UserPreferences service

import { ServiceScope, ServiceKey } from "@microsoft/sp-core-library";

export interface IUserPreferences {
    favoriteSuperHero: string;
}

export interface IUserPreferencesService extends IUserPreferences {
    configure(instanceId: string): void;
}

export class UserPreferencesService implements IUserPreferencesService {
    private _instanceId: string = null;
    private _internalData: IUserPreferences = null;

    constructor(private serviceScope: ServiceScope) {

    }

    public configure(instanceId: string): void {
        this._instanceId = instanceId;
    }

    public get favoriteSuperHero(): string {
        this._ensureLoadWebPartUserPreferences();
        return this._internalData.favoriteSuperHero;
    }

    public set favoriteSuperHero(value: string) {
        this._internalData.favoriteSuperHero = value;
        this._saveWebPartUserPreferences();
    }

    private get userPreferencesKey(): string {
        return `USER_PREFS_${this._instanceId}`;
    }

    private _saveWebPartUserPreferences() {
        if (!localStorage) {
            console.error("local storage feature is not supported in this browser");
            return;
        }

        const toSave = this._internalData || { favoriteSuperHero: null };
        const userPreferencesAsString = JSON.stringify(toSave);
        localStorage.setItem(this.userPreferencesKey, userPreferencesAsString);
    }

    private _ensureLoadWebPartUserPreferences() {
        if (!localStorage) {
            console.error("local storage feature is not supported in this browser");
            return;
        }

        if (!this._internalData) {
            const userPreferencesAsString = localStorage.getItem(this.userPreferencesKey);
            if (userPreferencesAsString) {
                this._internalData = JSON.parse(userPreferencesAsString) as IUserPreferences;
            } else {
                this._internalData = {
                    favoriteSuperHero: null
                };
            }
        }
    }
}

export const UserPreferencesServiceKey = ServiceKey.create<IUserPreferencesService>("ypcode-user-preferences", UserPreferencesService);

Let's see how to configure the service in the WebPart class

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version, ServiceScope } from '@microsoft/sp-core-library';
import {
  BaseClientSideWebPart,
  IPropertyPaneConfiguration,
  PropertyPaneTextField
} from '@microsoft/sp-webpart-base';

import * as strings from 'UserSettingsSampleWebPartStrings';
import UserSettingsSample from './components/UserSettingsSample';
import { IUserSettingsSampleProps } from './components/IUserSettingsSampleProps';
import { UserPreferencesServiceKey, UserPreferencesService } from '../../services/UserPreferencesService';


export interface IUserSettingsSampleWebPartProps {
  description: string;
}

export default class UserSettingsSampleWebPart extends BaseClientSideWebPart<IUserSettingsSampleWebPartProps> {

  private _usedServiceScope: ServiceScope;

  public onInit(): Promise<void> {
    return super.onInit().then(() => {
      // Create a child scope for the current WebPart
      this._usedServiceScope = this.context.serviceScope.startNewChild();
      // Configure it with the current WebPart instance Id
      const serviceInstance = this._usedServiceScope.createAndProvide(UserPreferencesServiceKey, UserPreferencesService);
      serviceInstance.configure(this.instanceId);
      this._usedServiceScope.finish();
    });
  }

  public render(): void {

    const element: React.ReactElement<IUserSettingsSampleProps> = React.createElement(
      UserSettingsSample,
      {
        serviceScope: this._usedServiceScope
      }
    );

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

And then see, with this implementation how easy it is to manipulate user preferences

import * as React from 'react';
import styles from './UserSettingsSample.module.scss';
import { IUserSettingsSampleProps } from './IUserSettingsSampleProps';
import { ISuperHero, SUPER_HEROES } from '../data/superheroes';
import { UserPreferencesServiceKey, IUserPreferences } from '../../../services/UserPreferencesService';

export interface IUserSettingsSampleState {
  favorite: string;
}

export default class UserSettingsSample extends React.Component<IUserSettingsSampleProps, IUserSettingsSampleState> {

  private userPreferences: IUserPreferences = null;
  constructor(props: IUserSettingsSampleProps) {
    super(props);
    this.userPreferences = this.props.serviceScope.consume(UserPreferencesServiceKey);
    this.state = {
      favorite: this.userPreferences.favoriteSuperHero
    };
  }

  private _isFavorite(superHero: ISuperHero): boolean {
    return this.userPreferences.favoriteSuperHero == superHero.name;
  }

  private _toggleFavorite(superHero: ISuperHero) {
    console.log("Toggle favorite super hero: ", superHero);
    if (this._isFavorite(superHero)) {
      this.userPreferences.favoriteSuperHero = null;
    } else {
      this.userPreferences.favoriteSuperHero = superHero.name;
    }
    this.setState({ favorite: this.userPreferences.favoriteSuperHero });
  }

  private _renderSuperHero(key: string, superHero: ISuperHero): JSX.Element {
    return <div key={key} className={`${styles.superHero} ${this._isFavorite(superHero) ? styles.favorite : ""}`} onClick={() => this._toggleFavorite(superHero)}>
      <img className={styles.picture} src={`${superHero.picture}`} />
      <p className={styles.title}>{superHero.name}</p>
    </div>;
  }

  public render(): React.ReactElement<IUserSettingsSampleProps> {

    return (
      <div className={styles.userSettingsSample}>
        <div className={styles.container}>
          <h1>What is your favorite super hero ?</h1>
          {SUPER_HEROES.map((superHero, ndx) => this._renderSuperHero(`super_hero_${ndx}`, superHero))}
        </div>
      </div>
    );
  }
}

As usual, you can find the entire solution on my GitHub repo here.

Conclusion

I think this approach can be used almost as is in many scenarios, or at least, inspire a lot of developers in addressing those kinds of needs. I hope you found this post interesting and feel free to let me know any feedback !

See you soon in a next post :)

Cheers,

Yannick

Other posts