A SharePoint File Explorer based on Managed Metadata and SPFx

A SharePoint File Explorer based on Managed Metadata and SPFx

Folders vs metadata is a very old discussion. I think it’s even older than the early days of Web2.0 where it nevertheless became a major topic again. Nowadays users still like to work or think in folder structures and for instance organize their files like that. One of the downsides of physical folders is that a file can only reside in one except duplicates or links which of course have their downsides, too. So what if you would have a typical “file explorer view” with a tree structure based on managed metadata where you can also have typical drag&drop options to copy, move or link your objects? There you are:

The solution shown here depends on two things, a managed folder-like hierarchical termset and file objects holding 1-n terms of it in a managed metadata column.

Those two need to be combined: A tree based on the termset and the file objects incorporated into that tree so it’s visible which (and how much) files reside under which node / branch.

The Termset Tree

Build

The structural tree needs to be built based on a given termset. That termset is set as the used one inside the managed metadata column from where it can be gathered at runtime. As Managed Metadata operations are best implemented with PnPJS, those package needs to be installed:

npm install @pnp/sp --save

And inside the base web part class PnPJS needs to be initialized with the SharePoint context:

export default class TaxonomyFileExplorerWebPart extends BaseClientSideWebPart<ITaxonomyFileExplorerWebPartProps> {
  protected onInit(): Promise<void> {
    return super.onInit().then(_ => {
      sp.setup({
        spfxContext: this.context
      });
    });
  }

Trees can be built recursive. For each term a node will be built and children will be added as childnodes. Calling the same function in a recursive way will only end once a leaf is reached and the current node has no children anymore. In code, which creates the tree data model, it looks like this:

public async getTermset (termsetID: string) {
    // list all the terms available in this term set by term set id
    const termset: IOrderedTermInfo[] = await sp.termStore.sets.getById(termsetID).getAllChildrenAsOrderedTree();
    const termnodes: ITermNode[] = [];
    termset.forEach(async ti => {
      const tn = this.getTermnode(ti);
      termnodes.push(tn);
    });
    return termnodes;
  }

  private getTermnode (term: IOrderedTermInfo): ITermNode {
    const node: ITermNode = {
      guid: term.id,
      childDocuments: 0,
      name: term.defaultLabel,
      children: [],
      subFiles: []
    };
    if (term.childrenCount > 0) {
      const ctnodes: ITermNode[] = [];
      term.children.forEach(ct => {
        const ctnode: ITermNode = this.getTermnode(ct);
        node.childDocuments += ctnode.childDocuments;
        ctnodes.push(ctnode);
      });
      node.children = ctnodes;
    } 
    return node;
  }

The recursive pattern works simple and well because of the getAllChildrenAsOrderedTree() function in line 3. This function ensures that first the parent terms are returned from where it’s best to start. Iterating from there for each term a ITermNode is created by getTermNode(...). Inside that function the given term is checked for children and if existing the getTermNode(...) calls itself again for each childnode and so on until a term has no children anymore.

Incorporate files

Finally the given files are checked if they belong to the given term. If so IFileItem is stored with the node. This is done separately but with the same pattern. Reason for that is to work with caching (the termset won’t change frequently) and with in-Memory changes of the files (drag&drop operations) later.

public incorporateFiles (terms: ITermNode[], files: IFileItem[]): ITermNode[] {
    terms.forEach(term => {
      term = this.incorporateFilesIntoTerm(term, files);
    });
    return terms;
}
private incorporateFilesIntoTerm (term: ITermNode, files: IFileItem[]): ITermNode {
    term.childDocuments = 0;
    term.subFiles = [];
    if (term.children.length > 0) {
      term.children.forEach(ct => {
        ct = this.incorporateFilesIntoTerm(ct, files);
        term.childDocuments += ct.childDocuments;
      });
    }
    files.forEach(fi => {
      if (fi.termGuid.indexOf(term.guid.toLowerCase()) > -1) {
        term.childDocuments++;
        term.subFiles.push(fi);
      }
    });
    return term;
}

Important to note might be that “current” values are reset at the beginning which makes no sense at the start but in a second run, when the files were updated but the term tree is still the old one. And to restart from scratch loading all with server calls and so on will be less performant.

Render

Rendering the term tree works quite the same recursive way. First in the parent component the root terms are rendered:

<ul>
  {terms.map(nc => { return <TermLabel node={nc} 
                          renderFiles={renderFiles} 
                          resetChecked={resetChecked} 
                          selectedNode={selectedTermnode}
                          addTerm={addTerm}
                          replaceTerm={replaceTerm}
                          copyFile={copyFile} />; })}
</ul>

Some interesting attributes are handed over here. Some callbacks are for later file operations (addTerm, replaceTerm, copyFile) which all take place in the component holding the whole tree but also the rendering of the files (renderFiles) once a node is selected and resetChecked is responsible to uncheck all other nodes in case a new one is selected. This is the way how you can “traverse” such a tree.

Inside the TermLabel component the current termnode is rendered as a <LI> and the children are mapped quite the same way than in above’s parent component:

<li className={styles.termLabel}>            
          <div ref={linkRef} className={`${styles.label} ${props.selectedNode===props.node.guid ? styles.checkedLabel : ""}`} onClick={nodeSelected} onDrop={drop} onDragOver={dragOver}>
              <label>
                  {props.node.children.length > 0 ? currentExpandIcon : <i className={styles.emptyicon}>&nbsp;</i>}
                  <Icon className={styles.icon} iconName="FabricFolder" />
                  {props.node.name}{props.node.childDocuments>0?<span className={styles.fileCount}>{props.node.childDocuments}</span>:""}
              </label>
          </div>
...
          {showChildren && <ul className={`${props.node.children.length > 0 ? styles.liFilled : ""}`}>
              {props.node.children.map(nc => { return <TermLabel node={nc} 
                                    renderFiles={props.renderFiles} 
                                    resetChecked={props.resetChecked} 
                                    selectedNode={props.selectedNode}
                                    addTerm={props.addTerm}
                                    replaceTerm={props.replaceTerm} />; })}
          </ul>}            
      </li>

The part inside the <Label> looks quite sophisticated but it ensures the collapse/expand icon in case of children but also the count of files matching this node or any children.

Drag and Drop

The files on the right side can be dropped to any tree node on the left targeting the corresponding term. Doing so normally a multi-select column would simply add that term. But there would be other options:

  • Add the new term but keep the existing ones (similar linking the file to a new folder on top)
  • Replace the existing term(s) by the new one (similar moving the file from one folder to another)
  • Copy the file to a new one and give only the new one the new term (similar to copy the file to a new folder (duplicating))

Having those options in normal file explorer would be offered on dragging with the secondary (right) mouse button. In HTML5 this is not possible but we can offer the user to press a modifier key (such as Ctrl, Shift, Alt) and offer the user a contextual menu only on (Ctrl) key pressed. This looks like:

Drag&Drop options (by Ctrl pressed)

To implement it, the FileLabel needs this:

const drag = (ev) => {
    ev.dataTransfer.setData("text/plain", JSON.stringify(props.file));
  };

  return (
    <li className={styles.fileLabel} draggable={true} onDragStart={drag}>
      <Icon {...getFileTypeIconProps({ extension: props.file.extension, size: 16 })} />
      <a className={styles.filelink} draggable={false} href={props.file.url}>{props.file.title}</a>
    </li>   
  );

Here especially the current file, that is its representing JSon object is put to the dataTransfer object.

While the target component, that is the TermLabel uses that functionality:

  const drop = (ev) => {    
    ev.preventDefault();
    var data = ev.dataTransfer.getData("text");
    const file: IFileItem = JSON.parse(data);
    setDroppedFile(file);
    if (ev.ctrlKey) {
      setShowContextualMenu(true);
    }
    else {
      addNewTerm(file); // Default option: Simply add the new (target) term to existing ones
    }
  };

  const dragOver = (ev) => {
    ev.preventDefault();
  };
    
    const menuItems: IContextualMenuItem[] = [
    {
      key: 'copyItem',
      text: 'Create new file with term (Copy)',
      onClick: () => copyWithNewTerm(droppedFile)
    },
    {
      key: 'moveItem',
      text: 'Replace with new term (Move)',
      onClick: () => replaceByNewTerm(droppedFile)
    },
    {
      key: 'linkItem',
      text: 'Add new term (Link)',
      onClick: () => addNewTerm(droppedFile)
    }];
  return (
      <li className={`${styles.termLabel} ${props.node.children.length > 0 || props.node.subFiles.length > 0 ? styles.liFilled : ""}`}>            
          <div ref={linkRef} className={`${styles.label} ${props.selectedNode===props.node.guid ? styles.checkedLabel : ""}`} onClick={nodeSelected} onDrop={drop} onDragOver={dragOver}>
              <label>
                  {props.node.children.length > 0 ? currentExpandIcon : <i className={styles.emptyicon}>&nbsp;</i>}
                  <Icon className={styles.icon} iconName="FabricFolder" />
                  {props.node.name}{props.node.childDocuments>0?<span className={styles.fileCount}>{props.node.childDocuments}</span>:""}
              </label>
          </div>
          <ContextualMenu
            items={menuItems}
            hidden={!showContextualMenu}
            target={linkRef}
            onItemClick={hideContextualMenu}
            onDismiss={hideContextualMenu}
          />

In the drop event first the custom data about the file properties are taken from dataTransfer object where it has been put on drag. Next is to check if the ctrlKey is pressed. If so the ContextualMenu component is shown handling the three options.

New file from ‘outside’ web part

Additionally it is possible to drop a file from your computer’s desktop or (real) file explorer. This can be detected onDrop by the following code:

if (ev.dataTransfer.types.indexOf('Files') > -1) {
      const dt = ev.dataTransfer;
      let files =  Array.prototype.slice.call(dt.files);
      files.forEach(fileToUpload => {
        uploadWithNewTerm(fileToUpload);
      });
}

In the case of the file is not yet present in the library of course only the “Create” of a new one makes sense and as there is no existing metadata of course only the target label needs to be set.

Another option theoretically possible would be when the file comes from the same webpart but handling a different library (browser open side-by-side). This scenario could be detected from the file object’s url for instance and would result in the same operation create a new file with target label only.

File operations

Last not least a quick look at the different file operations.

Move

A move is represented by overwriting the existing content of the managed metadata column by the target term where the file was dropped. In the SPService class the corresponding function looks like this:

public async updateTaxonomyItemByReplace (file: IFileItem, fieldName: string, newTaxonomyValue: string) {   
    const itemID: number = parseInt(file.id);

    await sp.web.lists.getByTitle(this.listName).items.getById(itemID).validateUpdateListItem([{
      ErrorMessage: null,
      FieldName: fieldName,
      FieldValue: newTaxonomyValue,
      HasException: false
    }]);
  }

I am using the handy validateUpdateListItem function from PnPJS here as for instance described by Alex Terentiev.

Maybe worth to note is the format of the newTaxonomyValue which is <label>|<termguid> and can be easily created at the TermLabel component already from the given props:

const newTaxonomyValue = `${props.node.name}|${props.node.guid}`;
Replacing by the new term (Move)

Adding the new term to the existing one is only slightly different than above:

public async updateTaxonomyItemByAdd (file: IFileItem, fieldName: string, newTaxonomyValue: string) {   
    const itemID: number = parseInt(file.id);
    let fieldValues = file.taxValue.join(';');
    fieldValues += `;${newTaxonomyValue}`;

    await sp.web.lists.getByTitle(this.listName).items.getById(itemID).validateUpdateListItem([{
      ErrorMessage: null,
      FieldName: fieldName,
      FieldValue: fieldValues,
      HasException: false
    }]);
  }

The difference is that the existing values need to be written again as well. So the array of taxValue is joined by ; and the new value is added finally. The write operation than stays the same as above.

Adding a new term to existing ones (Link)

Copy

Creating a new file by copying the existing one is a bit more. The file needs to be copied and afterwards the managed metadata needs to be overwritten the same way than in the Move case.

public async newTaxonomyItemByCopy (file: IFileItem, fieldName: string, newTaxonomyValue: string): Promise<IFileItem> {
    const fileUrl: URL = new URL(file.url);
    const currentFileNamePart = file.title.replace(`.${file.extension}`, '');
    const newFilename = `${currentFileNamePart}_Copy.${file.extension}`;
    const destinationUrl = decodeURI(fileUrl.pathname).replace(file.title, newFilename);
    await sp.web.getFileByServerRelativePath(decodeURI(fileUrl.pathname)).copyByPath(destinationUrl, false, true);
    const newFileItemPromise = await sp.web.getFileByServerRelativePath(destinationUrl).getItem();
    const newFileItem = await newFileItemPromise.get();
    const itemID: number = parseInt(newFileItem.Id);

    await sp.web.lists.getByTitle(this.listName).items.getById(itemID).validateUpdateListItem([{
      ErrorMessage: null,
      FieldName: fieldName,
      FieldValue: newTaxonomyValue,
      HasException: false
    }]);
    const newFile: IFileItem = {
      extension: file.extension,
      id: itemID.toString(),
      taxValue: [newTaxonomyValue],
      termGuid: [newTaxonomyValue.split('|')[1]],
      title: newFilename,
      url: fileUrl.host + '/' + destinationUrl
    };
    return newFile;
  }

First some operations needs to be done to create a server-relative (and decoded!) url of the current file but also a new name for the destination file and path. Next the file is copied and the listItem for that new file needs to be retrieved. Having that (and it’s Id!) the newTaxonomyValue can be written to it as in the Move case. Finally the new file is also returned as an object so the tree can be re-rendered without doing server calls once again.

Copying the file to a new one with the new term (Copy)

Create

If a file was not present yet but is dropped from the file explorer itself for instance the file operation is slightly different but not far away from the copy operation. Different is that the file needs to be created from a stream object in JavaScript which will be uploaded to SharePoint. First the file is taken from the dataTransfer object as seen above and then uploaded with PnPJS.

public async newTaxonomyItemByUpload (file: any, fieldName: string, newTaxonomyValue: string): Promise<IFileItem> {
    const libraryRoot = await sp.web.lists.getByTitle(this.listName).rootFolder.get();
    const result = await sp.web.getFolderByServerRelativeUrl(libraryRoot.ServerRelativeUrl).files.add(file.name, file, true);
    const fileNameParts = result.data.Name.split('.');
    const newFileItemPromise = await sp.web.getFileByServerRelativePath(result.data.ServerRelativeUrl).getItem();
    const newFileItem = await newFileItemPromise.get();

    const itemID: number = parseInt(newFileItem.Id);
    await sp.web.lists.getByTitle(this.listName).items.getById(itemID).validateUpdateListItem([{
      ErrorMessage: null,
      FieldName: fieldName,
      FieldValue: newTaxonomyValue,
      HasException: false
    }]);

    const newFile: IFileItem = {
      extension: fileNameParts[fileNameParts.length - 1],
      id: itemID.toString(),
      taxValue: [newTaxonomyValue],
      termGuid: [newTaxonomyValue.split('|')[1]],
      title: file.name,
      url: result.data.ServerRelativeUrl
    };
    return newFile;
  }

The main difference here is only the Add operation in line three. Afterwards it’s quite the same than in Copy: Detecting the listItem of the new file and writing the metadata to it.

Beside the described functionality there is even a bit more in this solution but the rest, I guess, you can easily detect in the full solution available in my GitHub repository.

Update: I meanwhile updated my repo to SPFx 1.15.0. One new feature was added (expand/collapse all nodes) and I transformed all my callback functions from inside JSX components to useCallback hooks. Furthermore I upgraded PnPJS to v3.5.1 which enabled me to establish context only in service classes with serviceScope. Once I find time I might also update this blogpost. Meanwhile refer to my commits for comparison.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.

3 thoughts on “A SharePoint File Explorer based on Managed Metadata and SPFx

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 )

Twitter picture

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

Facebook photo

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

Connecting to %s