diff --git a/static/es/services/htmlHelpers.js b/static/es/services/htmlHelpers.js
new file mode 100644
index 000000000..60b9f1471
--- /dev/null
+++ b/static/es/services/htmlHelpers.js
@@ -0,0 +1,43 @@
+class HtmlHelpers {
+
+ // `parseResponse` takes a raw HttpClient response and an options object.
+ // Returns an object with `element` being an contextual fragment of the
+ // HTML in the response and `ifPrefix` being the prefix that was used to
+ // "unique-ify" the ids of the received HTML.
+ // Original Response IDs can optionally be kept by adding `keepIds: true`
+ // to the `options` object.
+ parseResponse(response, options = {}) {
+ return response.text()
+ .then(
+ (responseText) => {
+ const docFrag = document.createRange().createContextualFragment(responseText);
+ let idPrefix;
+ if (!options.keepIds) {
+ idPrefix = this._getIdPrefix();
+ this._prefixIds(docFrag, idPrefix);
+ }
+ return Promise.resolve({ idPrefix, element: docFrag });
+ },
+ Promise.reject,
+ ).catch(console.error);
+ }
+
+ _prefixIds(element, idPrefix) {
+ const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger'];
+
+ idAttrs.forEach((attr) => {
+ Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => {
+ const value = idPrefix + input.getAttribute(attr);
+ input.setAttribute(attr, value);
+ });
+ });
+ }
+
+ _getIdPrefix() {
+ // leading 'r'(andom) to overcome the fact that IDs
+ // starting with a numeric value are not valid in CSS
+ return 'r' + Math.floor(Math.random() * 100000) + '__';
+ }
+}
+
+window.HtmlHelpers = new HtmlHelpers();
diff --git a/static/es/services/httpClient.js b/static/es/services/httpClient.js
new file mode 100644
index 000000000..a0199a8fa
--- /dev/null
+++ b/static/es/services/httpClient.js
@@ -0,0 +1,58 @@
+class HttpClient {
+
+ static ACCEPT = {
+ TEXT_HTML: 'text/html',
+ JSON: 'application/json',
+ };
+
+ _responseInterceptors = [];
+
+ addResponseInterceptor(interceptor) {
+ if (typeof interceptor === 'function') {
+ this._responseInterceptors.push(interceptor);
+ }
+ }
+
+ get(args) {
+ args.method = 'GET';
+ return this._fetch(args);
+ }
+
+ post(args) {
+ args.method = 'POST';
+ return this._fetch(args);
+ }
+
+ _fetch(options) {
+ const requestOptions = {
+ credentials: 'same-origin',
+ ...options,
+ };
+
+ return fetch(options.url, requestOptions)
+ .then(
+ (response) => {
+ this._responseInterceptors.forEach((interceptor) => interceptor(response, options));
+ return Promise.resolve(response);
+ },
+ Promise.reject,
+ ).catch(console.error);
+ }
+}
+
+window.HttpClient = new HttpClient();
+
+// HttpClient ships with its own little interceptor to throw an error
+// if the response does not match the expected content-type
+function contentTypeInterceptor(response, options) {
+ if (!options || !options.accept) {
+ return;
+ }
+
+ const contentType = response.headers.get("content-type");
+ if (!contentType.match(options.accept)) {
+ throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected');
+ }
+}
+
+window.HttpClient.addResponseInterceptor(contentTypeInterceptor);
diff --git a/static/es/services/i18n.js b/static/es/services/i18n.js
new file mode 100644
index 000000000..f36de73ab
--- /dev/null
+++ b/static/es/services/i18n.js
@@ -0,0 +1,34 @@
+ /**
+ * I18n
+ *
+ * This module stores and serves translated strings, according to the users language settings.
+ *
+ * Translations are stored in /messages/frontend/*.msg.
+ *
+ * To make additions to any of these files accessible to JavaScrip Utilities
+ * you need to add them to the respective *.msg file and to the list of FrontendMessages
+ * in /src/Utils/Frontend/I18n.hs.
+ *
+ */
+
+class I18n {
+
+ translations = {};
+
+ add(id, translation) {
+ this.translations[id] = translation;
+ }
+
+ addMany(manyTranslations) {
+ Object.keys(manyTranslations).forEach((key) => this.add(key, manyTranslations[key]));
+ }
+
+ get(id) {
+ if (!this.translations[id]) {
+ throw new Error('I18N Error: Translation missing for »' + id + '«!');
+ }
+ return this.translations[id];
+ }
+}
+
+window.I18n = new I18n();
diff --git a/static/es/services/utilRegistry.js b/static/es/services/utilRegistry.js
new file mode 100644
index 000000000..712721cc6
--- /dev/null
+++ b/static/es/services/utilRegistry.js
@@ -0,0 +1,121 @@
+const DEBUG_MODE = /localhost/.test(window.location.href) && 0;
+
+class UtilRegistry {
+
+ _registeredUtils = [];
+ _activeUtilInstances = [];
+
+ constructor() {
+ document.addEventListener('DOMContentLoaded', () => this.setupAll());
+ }
+
+ /**
+ * function registerUtil
+ *
+ * utils need to have at least these properties:
+ * name: string | utils name, e.g. 'example'
+ * selector: string | utils selector, e.g. '[uw-example]'
+ * setup: Function | utils setup function, see below
+ *
+ * setup function must return instance object with at least these properties:
+ * name: string | utils name
+ * element: HTMLElement | element the util is applied to
+ * destroy: Function | function to destroy the util and remove any listeners
+ *
+ * @param util Object Utility that should be added to the registry
+ */
+ register(util) {
+ if (DEBUG_MODE > 2) {
+ console.log('registering util "' + util.name + '"');
+ console.log({ util });
+ }
+ console.log('register...', { this: this });
+ this._registeredUtils.push(util);
+ }
+
+ deregister(name, destroy) {
+ const utilIndex = this._findUtilIndex(name);
+
+ if (utilIndex >= 0) {
+ if (destroy === true) {
+ this._destroyUtilInstances(name);
+ }
+
+ this._registeredUtils.splice(utilIndex, 1);
+ }
+ }
+
+ setupAll = (scope) => {
+ console.log('setupAll', { scope });
+ if (DEBUG_MODE > 1) {
+ console.info('registered js utilities:');
+ console.table(this._registeredUtils);
+ }
+
+ this._registeredUtils.forEach((util) => this.setup(util, scope));
+ }
+
+ setup(util, scope = document.body) {
+ if (DEBUG_MODE > 2) {
+ console.log('setting up util', { util });
+ }
+
+ if (util && typeof util.setup === 'function') {
+ const elements = this._findUtilElements(util, scope);
+
+ elements.forEach((element) => {
+ let utilInstance = null;
+
+ try {
+ utilInstance = util.setup(element);
+ } catch(err) {
+ if (DEBUG_MODE > 0) {
+ console.warn('Error while trying to initialize a utility!', { util , element, err });
+ }
+ }
+
+ if (utilInstance) {
+ if (DEBUG_MODE > 2) {
+ console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
+ }
+
+ this._activeUtilInstances.push(utilInstance);
+ }
+ });
+ }
+ }
+
+ find(name) {
+ return this._registeredUtils.find((util) => util.name === name);
+ }
+
+ _findUtilElements(util, scope) {
+ if (scope && scope.matches(util.selector)) {
+ return [scope];
+ }
+ return Array.from(scope.querySelectorAll(util.selector));
+ }
+
+ _findUtilIndex(name) {
+ return this._registeredUtils.findIndex((util) => util.name === name);
+ }
+
+ _destroyUtilInstances(name) {
+ this._activeUtilInstances
+ .map((util, index) => ({
+ util: util,
+ index: index,
+ }))
+ .filter((activeUtil) => activeUtil.util.name === name)
+ .forEach((activeUtil) => {
+ // destroy util instance
+ activeUtil.util.destroy();
+ delete this._activeUtilInstances[activeUtil.index];
+ });
+
+ // get rid of now empty array slots
+ this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util);
+ }
+}
+
+window.UtilRegistry = new UtilRegistry();