ONSdigital / design-system

ONS Design System
https://service-manual.ons.gov.uk/design-system
MIT License
28 stars 17 forks source link

[2 day spike]Introduce `uniqueId` filter implementation for JS and Jinja2 usages of the design system #2466

Open kruncher opened 1 year ago

kruncher commented 1 year ago

Tasks for this ticket:

  1. Investigate the suggestions in this ticket to find out if they are still needed and the impact of implementing them.
  2. Document findings.
  3. Discuss with team to decide whether further tickets/work are required.

The design system could include a default uniqueId filter implementation which JS/nunjucks and Python/Jinja2 projects can register for use in their templates.

JS implementation:

const DEFAULT_OPTIONS = {
  scope: "default",
  suffix: ".id",
  skipFirst: false,
};

export default function createUniqueIdFilter(data) {
  const counters = new Map();

  return function uniqueIdFilter(base, options = null) {
    base = !!base ? base : "";
    options = Object.assign({}, DEFAULT_OPTIONS, options);

    const key = `${options.scope}/${base}`;
    const counter = (counters.get(key) ?? 0) + 1;
    counters.set(key, counter);

    if (options.skipFirst && counter === 1 && base !== "") {
      return base;
    }

    return `${base}${options.suffix ?? ""}.${counter}`;
  }
}

tests:

import createUniqueIdFilter from "./uniqueIdFilter.js";

describe("createUniqueIdFilter()", () => {
  const uniqueIdFilter = createUniqueIdFilter();

  describe("uniqueIdFilter(base, { scope: 'default', suffix: '.id.', skipFirst: false }?)", () => {
    it("returns a different id each time", () => {
      const ids = new Set();

      ids.add(uniqueIdFilter("article"));
      ids.add(uniqueIdFilter("article"));
      ids.add(uniqueIdFilter("article"));

      expect(ids.size).toBe(3);
    });

    it.each([
      [ "article", /^article\.id\.[0-9]+$/ ],
      [ "example-element", /^example-element\.id\.[0-9]+$/],
    ])("returns id with the expected format (%s, %s)", (base, expectedPattern) => {
      const id = uniqueIdFilter(base);

      expect(id).toMatch(expectedPattern);
    });

    it("uses given suffix for identifier", () => {
      const id = uniqueIdFilter("article", { suffix: ".foo" });

      expect(id).toMatch(/^article\.foo\.[0-9]+$/);
    });

    it("removes suffix when `suffix` is `null`", () => {
      const id = uniqueIdFilter("article", { suffix: null });

      expect(id).toMatch(/^article\.[0-9]+$/);
    });

    it("uses clean sequence of identifiers for each provided scope", () => {
      const idsWithScope1 = [
        uniqueIdFilter("article", {
          scope: "https://example.com/some-page",
        }),
        uniqueIdFilter("article", {
          scope: "https://example.com/some-page",
        }),
      ];
      const idsWithScope2 = [
        uniqueIdFilter("example-block", {
          scope: "https://example.com/a-different-page",
        }),
        uniqueIdFilter("example-block", {
          scope: "https://example.com/a-different-page",
        }),
      ];

      expect(idsWithScope1).toEqual([ "article.id.1", "article.id.2" ]);
      expect(idsWithScope2).toEqual([ "example-block.id.1", "example-block.id.2" ]);
    });

    it("omits id counter on first identifier when `skipFirst` is `true`", () => {
      const idsWithScope = [
        uniqueIdFilter("example", {
          scope: "https://example.com/skip-first",
          skipFirst: true,
        }),
        uniqueIdFilter("example", {
          scope: "https://example.com/skip-first",
          skipFirst: true,
        }),
      ];

      expect(idsWithScope).toEqual([ "example", "example.id.2" ]);
    });

    it("does not omit id counter on first identifier when `base` is empty", () => {
      const idsWithScope = [
        uniqueIdFilter(null, {
          scope: "https://example.com/skip-first",
          skipFirst: true,
        }),
        uniqueIdFilter(null, {
          scope: "https://example.com/skip-first",
          skipFirst: true,
        }),
      ];

      expect(idsWithScope).toEqual([ ".id.1", ".id.2" ]);
    });
  });
});

Python implementation:

def createUniqueIdFilter():
    counters = {}

    def uniqueIdFilter(base, options = {}):
        base = base or ""

        scope = options.get("scope", "default")
        suffix = options.get("suffix", ".id.")
        skipFirst = options.get("skipFirst", False)

        key = f'{scope}/{base}'
        counter = counters.get(key, 0) + 1
        counters[key] = counter

        if skipFirst and counter == 1 and base != "":
            return base

        return f'{base}{suffix}{counter}'

    return uniqueIdFilter

The design system getting started documentation should include a section detailing how to register the filter for use in Nunjucks and Jinja2 (by importing the provided uniqueId filter module from the design system).

The design system can then make use of this filter to generate unique identifiers when outputting components which require automatically generated referencing:

{% set exampleThingId = "example-thing.field" | uniqueId %}
<label for="{{ exampleThingId }}">An example label</label>
<input id="{{ exampleThingId }}" />

Which would output something like this:

<label for="example-thing.field.17">An example label</label>
<input id="example-thing.field.17" />
kruncher commented 1 year ago

The following is a useful example of how Jinja2 implements custom filters: https://github.com/ONSdigital/eq-questionnaire-runner/blob/d003ab08a20294a4eba4f6bc9d8ccd42d50f8796/app/jinja_filters.py#L202

kruncher commented 1 year ago

It might be problematic to use the above filter in Flask apps if the Jinja2 environment isn't created fresh for each request because identifier generation would be unique each time the same page renders. This might not be a deal breaker, but it is definitely something worth verifying first.