DEV Community

Dennis
Dennis

Posted on

Learning TDD by doing: Tagging members in Umbraco's Rich Text Editor

In the system that I'm building, I need the ability to mention Umbraco members in text in the website. In order to do that, I need to build an extension to Umbraco's Rich Text Editor: TinyMCE.

Context

As a content editor, I want to tag members in a message or article so that they get notified about new content about them.

I looked at similar implementations, like in Slack or on X. Slack uses a special html tag for mentions during writing, but then sends the data to the backend with a token with a specific format. I decided to take a similar approach, but for now forget about the translation step. In content, a mention will look like this:

<mention user-id="1324" class="mceNonEditable">@D_Inventor</mention>
Enter fullscreen mode Exit fullscreen mode

Initial exploration

Before I started building, I was looking for ways to hook into TinyMCE in Umbraco. This is one of my least favourite things to extend in the Umbraco backoffice. I have done this before though, and I found it easiest to extend the editor if I create a decorator on Umbraco's tinyMceService in AngularJS. In TinyMCE's documentation, I found a feature called 'autoCompleters', which did exactly what I needed, so there was my hook into the editor. My initial code (without any testing yet), looked like this:

rtedecorator.$inject = ["$delegate"];
export function rtedecorator($delegate: any) {
  const original = $delegate.initializeEditor;

  $delegate.initializeEditor = function (args: any) {
    original.apply($delegate, arguments);

    args.editor.contentStyles.push("mention { background-color: #f7f3c1; }");
    args.editor.ui.registry.addAutocompleter("mentions", {
      trigger: "@",
      fetch: (
        pattern: string,
        maxResults: number,
        _fetchOptions: Record<string, unknown>
      ): Promise<IMceAutocompleteItem[]>
        // TODO: fetch from backend
        => Promise.resolve([{ type: "autocompleteitem", value: "1234", text: "D_Inventor" }]),
      onAction: (api: any, rng: Range, value: string): void => {
        // TODO: business logic
        api.hide();
      },
    });
  };

  return $delegate;
}
Enter fullscreen mode Exit fullscreen mode

I'm using vite and typescript in this project, but I don't have any types for TinyMCE installed. For now I'll keep the any and just try to avoid TinyMCE as much as possible.

Building with TDD

I decided to use jest for testing. I found an easy getting started and I quickly managed to get something working.

✅ Success
I learned a new tool for unit testing in frontend code. I succesfully applied the tool to write a frontend with unit tests

I wrote my first test:

mention-manager.test.ts

describe("MentionsManager.fetch", () => {
  let sut: MentionsManager;
  let items: IMention[];

  beforeEach(() => {
    items = [];
    sut = new MentionsManager();
  });

  test("should be able to fetch one result", async () => {
    items.push({ userId: "1234", userName: "D_Inventor" });
    const result = await sut.fetch(1);
    expect(result).toHaveLength(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

I was somewhat surprised by the strictness of the typescript compiler. Working in steps here really meant not adding anything that you aren't actually using yet. For example, I wanted to add a reference to the "UI", because I knew I was going to use that later, but I couldn't actually compile the MentionsManager until I used everything that I put in the constructor.

After a few rounds of red, green and refactor, I ended up with these tests:

mention-manager.test.ts

describe("MentionsManager.fetch", () => {
  let sut: MentionsManager;
  let items: IMention[];

  beforeEach(() => {
    items = [];
    sut = new MentionsManager(() => Promise.resolve(items));
  });

  test("should be able to fetch one result", async () => {
    items.push({ userId: "1234", userName: "D_Inventor" });
    const result = await sut.fetch(1);
    expect(result).toHaveLength(1);
  });

  test("should be able to fetch empty result", async () => {
    const result = await sut.fetch(1);
    expect(result).toHaveLength(0);
  });

  test("should be able to fetch many results", async () => {
    items.push({ userId: "1324", userName: "D_Inventor" }, { userId: "3456", userName: "D_Inventor2" });
    const result = await sut.fetch(2);
    expect(result).toHaveLength(2);
  });

  test("should return empty list upon error", () => {
    const sut = new MentionsManager(() => {
      throw new Error("Something went wrong while fetching");
    }, {} as IMentionsUI);
    return expect(sut.fetch(1)).resolves.toHaveLength(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

With this logic in place, I could fetch mentions from any source and show them in the RTE through the 'fetch' hook.
I used the same approach to create a 'pick' method to take the selected member and insert the mention into the editor. This is the code that I ended up with:

mention-manager.ts

export class MentionsManager {
  private mentions: IMention[] = [];

  constructor(
    private source: MentionsAPI,
    private ui: IMentionsUI
  ) {}

  async fetch(take: number, query?: string): Promise<IMention[]> {
    try {
      const result = await this.source(take, query);
      if (result.length === 0) return [];
      this.mentions = result;

      return result;
    } catch {
      return [];
    }
  }

  pick(id: string, location: Range): void {
    const mention = this.mentions.find((m) => m.userId === id);
    if (!mention) return;

    this.ui.insertMention(mention, location);
  }
}
Enter fullscreen mode Exit fullscreen mode
❓ Uncertainty
The Range interface is a built-in type that is really difficult to mock and this interface leaks an implementation detail into my business logic. I feel like there might've been a better way to do this.

Retrospect

Overall, I think I ended up with simple code that is easy to change. There are still parts of this code that I don't really like. I wanted the business logic to drive the UI, but the code ended up more like a simple store that also does a single call to the UI. I wonder if I could more strongly wrap the UI to get more use out of the manager.

Top comments (0)