Hi SharePoint Devs,
In this post, I would like to tackle a notion very important to me as a code lover: the Separation of Concerns (SoC) and how we can implement this principle in a SharePoint Framework solution. For the example, we will re-use the Kanban SPFx WebPart I presented in this post. If you want to follow the steps of this article and do the changes by yourself, you can download or clone the github repo for this webpart here
What is SoC ?
SoC is a principle where the code is splitted into units each responsible for its own business (UI, Business, Data Access, ...), the units (or layers) encapsulate their internal business data and produce a result according to the input we pass to them. The units don't care about what's internal in other units, they only care about the result they get when they use the features of other units. Each unit is easier to maintain, to unit-test, and even entirely rewritten if needed. An application is then a set of units that communicate with each other. We remove all intricate dependencies we have to deal with when the concerns are not really separated (calls to data API directly in the UI code, changes to do in the UI whenever the data schema changes, ...) Moreover, if you build your classes and services generic enough, you will be able to take it as they are to reuse in a totally different context.
In practice
In practice, it means we need to design and implement our units of code in a way that they will never depend on a actual implementation of any unit, because it means it would be very difficult to maintain or change in the future. Instead, units will only be aware of interfaces they have to deal with, they should never have a clue of what's going on internally in the units they consume. It is, actually, one of the pillars of OOP. From this point, I will talk about these units as Services. A service is nothing more than a unit of code dedicated to a specific concern, it is generally described by a public interface. So, to achieve that, in practice, we have to define public interfaces that will be known by any consumer of the services
A ConfigurationService class
We want to remove all dependencies to the WebPart in our services. However, the WebPart class handles something very important: the configuration via the Property Pane. Under a src/services/ folder, let's create a class that will hold the configuration information. Let's also create its public interface:
import { ServiceKey} from "@microsoft/sp-core-library";
/**
* The Configuration Service public interface
*/
export interface IConfigurationService {
tasksListId: string;
statusFieldInternalName: string;
}
/**
* The default implementation of the Configuration service class
* It is a simple class with 2 public properties that represent the settings in the property pane
*/
export default class ConfigurationService implements IConfigurationService {
public tasksListId: string;
public statusFieldInternalName: string;
}
export const ConfigurationServiceKey = ServiceKey.create<IConfigurationService>("kanban:config", ConfigurationService);
You don't have to worry about the ServiceKey things for now. We have now to relay the configuration information from the property pane to this service class. In the KanbanBoardWebPart class, add the following changes
// Add this in the imports
import {
IConfigurationService
} from "../../services";
// ....
// Add the following member to the KanbanBoardWebPart class
private config: IConfigurationService = null;
// ....
// Add the following method to the KanbanBoardWebPart class
public onPropertyPaneFieldChanged(propertyName: string, oldValue: string, newValue: string) {
this.config.statusFieldInternalName = this.properties.statusFieldName;
this.config.tasksListId = this.properties.tasksListId;
}
// ...
A DataService class
In order to clearly separate the concerns, we have to move all the methods responsible for the data access from the WebPart UI class to a dedicated service class. In the src/services/ folder let's add a DataService.ts file. We then move all the data related methods from the UI to that service. We also declare the public interface of the service.
import { ServiceScope, ServiceKey } from "@microsoft/sp-core-library";
import { firstOrDefault } from "../helpers/CollectionHelper";
import { IFieldInfo, IListInfo, ITask } from "../models";
import { IConfigurationService, ConfigurationServiceKey } from "./ConfigurationService";
import pnp from "sp-pnp-js";
export const ChoiceFieldType = "Choice";
export interface IDataService {
/**
* Get the statuses (the available choices) from the specified choice field
*/
getStatuses(): Promise<string[]>;
/**
* Get the available lists in the current web
*/
getAvailableLists(refresh?: boolean): Promise<IListInfo[]>;
/**
* Get the available choice fields for the specified list
*/
getAvailableChoiceFields(): Promise<IFieldInfo[]>;
/**
* Get the available choice fields from lists aleady loaded
*/
getAvailableChoiceFieldsFromLoadedLists(): IFieldInfo[];
/**
* Get all tasks
*/
getAllTasks(): Promise<ITask[]>;
/**
* Update the status to newStatus for the specified task
*/
updateTaskStatus(taskId: number, newStatus: string): Promise<void>;
}
export default class SharePointDataService implements IDataService {
private config: IConfigurationService = null;
private cachedAvailableLists: IListInfo[] = null;
constructor(serviceScope: ServiceScope) {
serviceScope.whenFinished(() => {
// Configure the required dependencies
this.config = serviceScope.consume(ConfigurationServiceKey);
});
}
public getStatuses(): Promise<string[]> {
return pnp.sp.web.lists.getById(this.config.tasksListId).fields
.getByInternalNameOrTitle(this.config.statusFieldInternalName)
.get()
.then((fieldInfo: IFieldInfo) => fieldInfo.Choices || []);
}
public getAvailableLists(refresh: boolean = false): Promise<IListInfo[]> {
if (!refresh && this.cachedAvailableLists)
return new Promise<IListInfo[]>(resolve => resolve(this.cachedAvailableLists));
return pnp.sp.web.lists
.expand("Fields")
.select("Id", "Title", "Fields/Title", "Fields/InternalName", "Fields/TypeAsString")
.get()
.then(lists => {
this.cachedAvailableLists = lists;
return lists;
});
}
public getAvailableChoiceFields(): Promise<IFieldInfo[]> {
return this.getAvailableLists(false).then(lists => {
let list = firstOrDefault(lists, l => l.Id == this.config.tasksListId);
if (!list)
return [];
return list.Fields.filter(f => f.TypeAsString == ChoiceFieldType);
});
}
public getAvailableChoiceFieldsFromLoadedLists() {
if (!this.cachedAvailableLists)
return [];
let list = firstOrDefault(this.cachedAvailableLists, l => l.Id == this.config.tasksListId);
if (!list)
return [];
return list.Fields.filter(f => f.TypeAsString == ChoiceFieldType);
}
public updateTaskStatus(taskId: number, newStatus: string): Promise<any> {
// Set the value for the configured "status" field
let fieldsToUpdate = {};
fieldsToUpdate[this.config.statusFieldInternalName] = newStatus;
// Update the property on the list item
return pnp.sp.web.lists.getById(this.config.tasksListId).items.getById(taskId).update(fieldsToUpdate);
}
public getAllTasks(): Promise<ITask[]> {
return pnp.sp.web.lists.getById(this.config.tasksListId).items
.select("Id", "Title", this.config.statusFieldInternalName)
.get()
.then((results: ITask[]) => results && results.map(t => ({
Id: t.Id,
Title: t.Title,
Status: t[this.config.statusFieldInternalName]
})));
}
}
export const DataServiceKey = ServiceKey.create<IDataService>("kanban:data-service", SharePointDataService);
Again that ServiceKey and now ServiceScope, what are they ? These classes are the implementation in SPFx that allow the developer to leverage dependency injection.
Instead of passing a reference to each single dependency, we pass the scope as argument to the unit and the unit calls a consume() method to get the service it needs. With the line export const DataServiceKey = ServiceKey.create
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneDropdown,
} from '@microsoft/sp-webpart-base';
import { SPComponentLoader } from '@microsoft/sp-loader';
import styles from './KanbanBoard.module.scss';
import * as strings from 'kanbanBoardStrings';
import { IKanbanBoardWebPartProps } from './IKanbanBoardWebPartProps';
import { AppStartup } from "../../startup";
// jQuery and jQuery UI Drag&Drop extensions
import * as $ from "jquery";
require("jquery-ui/ui/widgets/draggable");
require("jquery-ui/ui/widgets/droppable");
// Models
import { ITask, IListInfo, IFieldInfo } from "../../models/";
// Services
import {
IConfigurationService, ConfigurationServiceKey,
IDataService, DataServiceKey
} from "../../services";
// Constants
const LAYOUT_MAX_COLUMNS = 12;
export default class KanbanBoardWebPart extends BaseClientSideWebPart<IKanbanBoardWebPartProps> {
private statuses: string[] = [];
private tasks: ITask[] = [];
private availableLists: IListInfo[] = [];
private availableChoiceFields: IFieldInfo[] = [];
private dataService: IDataService = null;
private config: IConfigurationService = null;
constructor() {
super();
SPComponentLoader.loadCss('https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.min.css');
}
public onInit(): Promise<any> {
return super.onInit()
// Set the global configuration of the application
// This is where we will define the proper services according to the context (Local, Test, Prod,...)
// or according to specific settings
.then(_ => AppStartup.configure(this.context, this.properties))
// When configuration is done, we get the instances of the services we want to use
.then(serviceScope => {
this.dataService = serviceScope.consume(DataServiceKey);
this.config = serviceScope.consume(ConfigurationServiceKey);
})
// Then, we are able to use the methods of the services
// Load the available lists in the current site
.then(() => this.dataService.getAvailableLists())
.then((lists: IListInfo[]) => this.availableLists = lists);
}
public render(): void {
// Only if properly configured
if (this.properties.statusFieldName && this.properties.tasksListId) {
// Load the data
this.dataService.getStatuses()
.then((statuses: string[]) => this.statuses = statuses)
.then(() => this.dataService.getAllTasks())
.then((tasks: ITask[]) => this.tasks = tasks)
// And then render
.then(() => this._renderBoard())
.catch(error => {
console.log(error);
console.log("An error occured while loading data...");
});
} else {
this.domElement.innerHTML = `<div class="ms-MessageBar ms-MessageBar--warning">${strings.PleaseConfigureWebPartMessage}</div>`
}
}
private _getColumnSizeClassName(columnsCount: number): string {
if (columnsCount < 1) {
console.log("Invalid number of columns");
return "";
}
if (columnsCount > LAYOUT_MAX_COLUMNS) {
console.log("Too many columns for responsive UI");
return "";
}
let columnSize = Math.floor(LAYOUT_MAX_COLUMNS / columnsCount);
return "ms-u-sm" + (columnSize || 1);
}
/**
* Generates and inject the HTML of the Kanban Board
*/
private _renderBoard(): void {
let columnSizeClass = this._getColumnSizeClassName(this.statuses.length);
// The begininning of the WebPart
let html = `
<h1>Hello World!</h1>
<div class="${styles.kanbanBoard}">
<div class="${styles.container}">
<div class="ms-Grid-row ${styles.row}">`;
// For each status
this.statuses.forEach(status => {
// Append a new Office UI Fabric column with the appropriate width to the row
html += `<div class="${styles.kanbanColumn} ms-Grid-col ${columnSizeClass}" data-status="${status}">
<h3 class="ms-fontColor-themePrimary">${status}</h3>`;
// Get all the tasks in the current status
let currentGroupTasks = this.tasks.filter(t => t.Status == status);
// Add a new tile for each task in the current column
currentGroupTasks.forEach(task => {
html += `<div class="${styles.task}" data-taskid="${task.Id}">
<div class="ms-fontSize-xl">${task.Title}</div>
</div>`;
});
// Close the column element
html += `</div>`;
});
// Ends the WebPart HTML
html += `</div>
<div class="ms-Grid-row ${styles.row}">
<div class="ms-Grid-col ms-u-sm12">
<a class="ms-Link linkAddTask">Add Task</a>
</div>
</div>
</div>
</div>
</div>`;
// Apply the generated HTML to the WebPart DOM element
this.domElement.innerHTML = html;
// Make the kanbanColumn elements droppable areas
let webpart = this;
$(this.domElement).find(`.${styles.kanbanColumn}`).droppable({
tolerance: "intersect",
accept: `.${styles.task}`,
activeClass: "ui-state-default",
hoverClass: "ui-state-hover",
drop: (event, ui) => {
// Here the code to execute whenever an element is dropped into a column
let taskItem = $(ui.draggable);
let source = taskItem.parent();
let previousStatus = source.data("status");
let taskId = taskItem.data("taskid");
let target = $(event.target);
let newStatus = target.data("status");
taskItem.appendTo(target);
// If the status has changed, apply the changes
if (previousStatus != newStatus) {
webpart.dataService.updateTaskStatus(taskId, newStatus);
}
}
});
// Make the task items draggable
$(this.domElement).find(`.${styles.task}`).draggable({
classes: {
"ui-draggable-dragging": styles.dragging
},
opacity: 0.7,
helper: "clone",
cursor: "move",
revert: "invalid"
});
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
public onPropertyPaneFieldChanged(propertyName: string, oldValue: string, newValue: string) {
this.config.statusFieldInternalName = this.properties.statusFieldName;
this.config.tasksListId = this.properties.tasksListId;
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.HeaderDescription
},
groups: [
{
groupName: strings.TasksConfigurationGroup,
groupFields: [
PropertyPaneDropdown('tasksListId', {
label: strings.SourceTasksList,
options: this.availableLists.map(l => ({
key: l.Id,
text: l.Title
}))
}),
PropertyPaneDropdown('statusFieldName', {
label: strings.StatusFieldInternalName,
options: this.dataService.getAvailableChoiceFieldsFromLoadedLists().map(f => ({
key: f.InternalName,
text: f.Title
}))
})
]
}
]
}
]
};
}
}
You can see now that the code of the WebPart class only contains things related to the UI. All the data fetching and updating is encapsulated within the service.
Cool ! My concerns are separated, I can maintain it in a better way now !
Exactly, if anything in the SharePoint data schema changes, you only have to adapt the DataService implementation, the UI will not change at all !
But there is even better: Implementation dedicated to current environment.
Let's take the case of Local Workbench or unit testing and the needed mock data. We create a mock implementation for the Data Service, it uses mock in-memory data, however it implements the exact same interface as the real service, that way, the UI layer will never even know whether it is in local workbench or in a real SharePoint page
import { ServiceScope } from "@microsoft/sp-core-library";
import { firstOrDefault } from "../../helpers/CollectionHelper";
import {
IDataService,
IConfigurationService,
ConfigurationServiceKey,
ChoiceFieldType
} from "../";
import { IFieldInfo, IListInfo, ITask } from "../../models";
const mockListsInfo: IListInfo[] = [
{
Id: "##1",
Title: "Tasks List 1",
Fields: [
{
Title: "Title",
InternalName: "Title",
TypeAsString: "Text"
},
{
Title: "Task Status",
InternalName: "Status",
TypeAsString: ChoiceFieldType,
Choices: ["Open", "On going", "Done", "Canceled"]
},
{
Title: "Priority",
InternalName: "Priority",
TypeAsString: ChoiceFieldType,
Choices: ["Low", "Medium", "High"]
}
]
},
{
Id: "##2",
Title: "Tasks List 2",
Fields: [
{
Title: "Title",
InternalName: "Title",
TypeAsString: "Text"
},
{
Title: "Status",
InternalName: "Status",
TypeAsString: ChoiceFieldType,
Choices: ["New", "In Progress", "Completed"]
}
]
}
];
const mockTasks = {
"##1": [
{
Id: 1,
Title: "Task 1 from list 1",
Status: "Open",
Priority: "Low"
},
{
Id: 2,
Title: "Task 2 from list 1",
Status: "On going",
Priority: "Medium"
},
{
Id: 3,
Title: "Task 3 from list 1",
Status: "On going",
Priority: "Medium"
},
{
Id: 4,
Title: "Task 4 from list 1",
Status: "Done",
Priority: "High"
},
{
Id: 5,
Title: "Task 5 from list 1",
Status: "Canceled",
Priority: "Low"
},
],
"##2": [
{
Id: 1,
Title: "Task 1 from list 2",
Status: "New"
},
{
Id: 2,
Title: "Task 2 from list 2",
Status: "New"
},
{
Id: 3,
Title: "Task 3 from list 2",
Status: "In Progress"
},
{
Id: 4,
Title: "Task 4 from list 2",
Status: "Canceled"
},
]
};
export class MockDataService implements IDataService {
private config: IConfigurationService = null;
constructor(serviceScope: ServiceScope) {
serviceScope.whenFinished(() => {
// Configure the required dependencies
this.config = serviceScope.consume(ConfigurationServiceKey);
});
}
public getStatuses(): Promise<string[]> {
return new Promise<string[]>((resolve) => {
let list = firstOrDefault(mockListsInfo, ld => ld.Id == this.config.tasksListId);
if (!list) {
resolve([]);
return;
}
let choiceField = firstOrDefault(list.Fields, f => f.InternalName == this.config.statusFieldInternalName);
resolve((choiceField && choiceField.Choices) || []);
});
}
public getAvailableLists(): Promise<IListInfo[]> {
return new Promise<IListInfo[]>((resolve) => {
resolve(mockListsInfo);
});
}
public getAvailableChoiceFields(): Promise<IFieldInfo[]> {
return this.getAvailableLists().then(lists => {
let list = firstOrDefault(lists, l => l.Id == this.config.tasksListId);
if (!list)
return [];
return list.Fields.filter(f => f.TypeAsString == ChoiceFieldType);
});
}
public getAvailableChoiceFieldsFromLoadedLists() {
let list = firstOrDefault(mockListsInfo, l => l.Id == this.config.tasksListId);
if (!list)
return [];
return list.Fields.filter(f => f.TypeAsString == ChoiceFieldType);
}
public updateTaskStatus(taskId: number, newStatus: string): Promise<any> {
return new Promise<IListInfo[]>((resolve) => {
let tasks: ITask[] = mockTasks[this.config.tasksListId];
// For each task of the list with the Id Task ID (Always only one!), update the status
tasks.filter(t => t.Id == taskId)
.forEach(t => t.Status = newStatus);
resolve();
});
}
public getAllTasks(): Promise<ITask[]> {
return new Promise<ITask[]>((resolve) => {
let tasks: ITask[] = mockTasks[this.config.tasksListId]
.map(t => ({
Id: t.Id,
Title: t.Title,
Status: t[this.config.statusFieldInternalName]
}));
resolve(tasks);
});
}
}
We have now to tell the WebPart to use this implementation when it used in the local workbench or in unit testing. You might have noticed, in the onInit() method of the KanbanBoardWebPart class, I removed the SPFx PnP setup and I added some AppStartup.configure() call. What is that ? A great advantage of the SPFx ServiceScope class is that it holds the default implementation of the services. However, you are able to create child scopes that will contain specific implementation for some services, all the services that have no implementation at the child scope level will take it from its parent scope all the way up until the root ServiceScope. I have a AppStartup class that make all the required configuration and instantiate the appropriate services according to the context, all the consumers of the services will not know which implementation they use, they will just use it !
import {
IWebPartContext,
} from '@microsoft/sp-webpart-base';
import {
Environment,
EnvironmentType,
ServiceScope
} from '@microsoft/sp-core-library';
// sp-pnp-js for SPFx context configuration
import pnp from "sp-pnp-js";
// Services
import {
ConfigurationServiceKey, DataServiceKey,
IConfigurationService, MockDataService
} from "../services";
export class AppStartup {
private static configured: boolean = false;
private static serviceScope: ServiceScope = null;
public static configure(ctx: IWebPartContext, props: any): Promise<ServiceScope> {
switch (Environment.type) {
case EnvironmentType.SharePoint:
case EnvironmentType.ClassicSharePoint:
return AppStartup.configureForSharePointContext(ctx, props);
// case EnvironmentType.Local:
// case EnvironmentType.Test:
default:
return AppStartup.configureForLocalOrTestContext(ctx, props);
}
}
public static getServiceScope(): ServiceScope {
if (AppStartup.configured)
throw new Error("The application is not properly configured");
return AppStartup.serviceScope;
}
private static configureForSharePointContext(ctx: IWebPartContext, props: any): Promise<ServiceScope> {
return new Promise<any>((resolve, reject) => {
ctx.serviceScope.whenFinished(() => {
// Configure PnP Js for working seamlessly with SPFx
pnp.setup({
spfxContext: ctx
});
let config: IConfigurationService = ctx.serviceScope.consume(ConfigurationServiceKey);
// Initialize the config with WebPart Properties
config.statusFieldInternalName = props["statusFieldName"];
config.tasksListId = props["tasksListId"];
AppStartup.serviceScope = ctx.serviceScope;
AppStartup.configured = true;
resolve(ctx.serviceScope);
});
});
}
private static configureForLocalOrTestContext(ctx: IWebPartContext, props: any): Promise<ServiceScope> {
return new Promise<any>((resolve, reject) => {
// Here create a dedicated service scope for test or local context
const childScope: ServiceScope = ctx.serviceScope.startNewChild();
// Register the services that will override default implementation
childScope.createAndProvide(DataServiceKey, MockDataService);
// Must call the finish() method to make sure the child scope is ready to be used
childScope.finish();
childScope.whenFinished(() => {
// If other services must be used, it must done HERE
AppStartup.serviceScope = childScope;
AppStartup.configured = true;
resolve(childScope);
});
});
}
}
In my solution, the AppStartup class is the only one that knows about the current environment (Local, Test, SharePoint, ClassicSharePoint). I designed the class to be responsible of returning the appropriate scope of implementations according to the context. Take a look on the configureForLocalOrTestContext() method to check how to register specific implementation of a service for a given key. The configure() method that is called by the KanbanBoardWebPart class performs all the required startup configuration and returns the ServiceScope that will be used by all the layers of the application.
Conclusion
With this implementation, all concerns are separated and are easier to maintain, fix, improve or replace ! The only item you have to pass to the units is the ServiceScope. I encourage you to read the following articles about the SPFx Service Scope:
- https://dev.office.com/sharepoint/reference/spdx/sp-core-library/servicescope
- https://github.com/SharePoint/sp-dev-docs/wiki/Tech-Note:-ServiceScope-API
You can see the whole solution on the V2 branch on my github repo Hope you liked this post and it will be helpful.
Yannick