Convert `Acp/Form/Builder/Field/Devtools/Project/Instructions` to TypeScript
authorMatthias Schmidt <gravatronics@live.com>
Tue, 12 Jan 2021 09:59:37 +0000 (10:59 +0100)
committerMatthias Schmidt <gravatronics@live.com>
Tue, 12 Jan 2021 09:59:37 +0000 (10:59 +0100)
wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts [new file with mode: 0644]

index dd78c563c5d9cfed5ad45be2b30b8d302fb52ec5..0540afe1ba209a601d6bc7e7c4f5bc72c67749ee 100644 (file)
 /**
  * Manages the instructions entered in a devtools project instructions form field.
  *
- * @author     Matthias Schmidt
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
- * @since      5.2
+ * @author  Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
+ * @since 5.2
  */
-define([
-    'Dom/ChangeListener',
-    'Dom/Traverse',
-    'Dom/Util',
-    'EventKey',
-    'Language',
-    'Ui/Confirmation',
-    'Ui/Dialog',
-    'WoltLabSuite/Core/Ui/Sortable/List'
-], function (DomChangeListener, DomTraverse, DomUtil, EventKey, Language, UiConfirmation, UiDialog, UiSortableList) {
+define(["require", "exports", "tslib", "../../../../../../Core", "../../../../../../Language", "../../../../../../Dom/Traverse", "../../../../../../Dom/Change/Listener", "../../../../../../Dom/Util", "../../../../../../Ui/Sortable/List", "../../../../../../Ui/Dialog", "../../../../../../Ui/Confirmation"], function (require, exports, tslib_1, Core, Language, DomTraverse, Listener_1, Util_1, List_1, Dialog_1, UiConfirmation) {
     "use strict";
-    var _applicationPips = ['acpTemplate', 'file', 'script', 'template'];
-    /**
-     * @constructor
-     */
-    function Instructions(formFieldId, instructionsTemplate, instructionsEditDialogTemplate, instructionEditDialogTemplate, pipDefaultFilenames, existingInstructions) {
-        this.init(formFieldId, instructionsTemplate, instructionsEditDialogTemplate, instructionEditDialogTemplate, pipDefaultFilenames, existingInstructions || []);
-    }
-    ;
-    Instructions.prototype = {
-        /**
-         * Initializes the instructions handler.
-         *
-         * @param      {string}        formFieldId                     id of the associated form field
-         * @param      {Template}      instructionsTemplate            template used for a new set of instructions
-         * @param      {Template}      instructionsEditDialogTemplate  template used for instructions edit dialogs
-         * @param      {Template}      instructionEditDialogTemplate   template used for instruction edit dialogs
-         * @param      {object}        pipDefaultFilenames             maps pip names to their default filenames
-         * @param      {object[]}      existingInstructions            data of existing instructions
-         */
-        init: function (formFieldId, instructionsTemplate, instructionsEditDialogTemplate, instructionEditDialogTemplate, pipDefaultFilenames, existingInstructions) {
-            this._formFieldId = formFieldId;
-            this._instructionsTemplate = instructionsTemplate;
-            this._instructionsEditDialogTemplate = instructionsEditDialogTemplate;
-            this._instructionEditDialogTemplate = instructionEditDialogTemplate;
-            this._instructionsCounter = 0;
-            this._pipDefaultFilenames = pipDefaultFilenames;
-            this._instructionCounter = 0;
-            this._instructionsList = elById(this._formFieldId + '_instructionsList');
-            if (this._instructionsList === null) {
-                throw new Error("Cannot find package list for packages field with id '" + this._formFieldId + "'.");
-            }
-            this._instructionsType = elById(this._formFieldId + '_instructionsType');
-            if (this._instructionsType === null) {
-                throw new Error("Cannot find instruction type form field for instructions field with id '" + this._formFieldId + "'.");
-            }
-            this._instructionsType.addEventListener('change', this._toggleFromVersionFormField.bind(this));
-            this._fromVersion = elById(this._formFieldId + '_fromVersion');
-            if (this._fromVersion === null) {
-                throw new Error("Cannot find from version form field for instructions field with id '" + this._formFieldId + "'.");
-            }
-            this._fromVersion.addEventListener('keypress', this._instructionsKeyPress.bind(this));
-            this._addButton = elById(this._formFieldId + '_addButton');
-            if (this._addButton === null) {
-                throw new Error("Cannot find add button for instructions field with id '" + this._formFieldId + "'.");
-            }
-            this._addButton.addEventListener('click', this._addInstructions.bind(this));
-            this._form = this._instructionsList.closest('form');
-            if (this._form === null) {
-                throw new Error("Cannot find form element for instructions field with id '" + this._formFieldId + "'.");
-            }
-            this._form.addEventListener('submit', this._submit.bind(this));
-            var hasInstallInstructions = false;
-            for (var index in existingInstructions) {
-                var instructions = existingInstructions[index];
-                if (instructions.type === 'install') {
-                    hasInstallInstructions = true;
-                    break;
-                }
-            }
+    Core = tslib_1.__importStar(Core);
+    Language = tslib_1.__importStar(Language);
+    DomTraverse = tslib_1.__importStar(DomTraverse);
+    Listener_1 = tslib_1.__importDefault(Listener_1);
+    Util_1 = tslib_1.__importDefault(Util_1);
+    List_1 = tslib_1.__importDefault(List_1);
+    Dialog_1 = tslib_1.__importDefault(Dialog_1);
+    UiConfirmation = tslib_1.__importStar(UiConfirmation);
+    class Instructions {
+        constructor(formFieldId, instructionsTemplate, instructionsEditDialogTemplate, instructionEditDialogTemplate, pipDefaultFilenames, existingInstructions) {
+            this.instructionCounter = 0;
+            this.instructionsCounter = 0;
+            this.formFieldId = formFieldId;
+            this.instructionsTemplate = instructionsTemplate;
+            this.instructionsEditDialogTemplate = instructionsEditDialogTemplate;
+            this.instructionEditDialogTemplate = instructionEditDialogTemplate;
+            this.pipDefaultFilenames = pipDefaultFilenames;
+            this.instructionsList = document.getElementById(`${this.formFieldId}_instructionsList`);
+            if (this.instructionsList === null) {
+                throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
+            }
+            this.instructionsType = document.getElementById(`${this.formFieldId}_instructionsType`);
+            if (this.instructionsType === null) {
+                throw new Error(`Cannot find instruction type form field for instructions field with id '${this.formFieldId}'.`);
+            }
+            this.instructionsType.addEventListener("change", () => this.toggleFromVersionFormField());
+            this.fromVersion = document.getElementById(`${this.formFieldId}_fromVersion`);
+            if (this.fromVersion === null) {
+                throw new Error(`Cannot find from version form field for instructions field with id '${this.formFieldId}'.`);
+            }
+            this.fromVersion.addEventListener("keypress", (ev) => this.instructionsKeyPress(ev));
+            this.addButton = document.getElementById(`${this.formFieldId}_addButton`);
+            if (this.addButton === null) {
+                throw new Error(`Cannot find add button form field for instructions field with id '${this.formFieldId}'.`);
+            }
+            this.addButton.addEventListener("click", (ev) => this.addInstructions(ev));
+            this.form = this.instructionsList.closest("form");
+            if (this.form === null) {
+                throw new Error(`Cannot find form element for instructions field with id '${this.formFieldId}'.`);
+            }
+            this.form.addEventListener("submit", () => this.submit());
+            const hasInstallInstructions = existingInstructions.some((instructions) => instructions.type === "install");
             // ensure that there are always installation instructions
             if (!hasInstallInstructions) {
-                this._addInstructionsByData({
-                    fromVersion: '',
-                    type: 'install'
+                this.addInstructionsByData({
+                    fromVersion: "",
+                    type: "install",
                 });
             }
-            existingInstructions.forEach(this._addInstructionsByData.bind(this));
-            DomChangeListener.trigger();
-        },
+            existingInstructions.forEach((instructions) => this.addInstructionsByData(instructions));
+            Listener_1.default.trigger();
+        }
         /**
-         * Adds an instruction to a set of instructions as a consequence
-         * of the given event. If the instruction data is invalid, an
-         * error message is shown and no instruction is added.
-         *
-         * @param      {Event}         event   event that triggered trying to add the instruction
+         * Adds an instruction to a set of instructions as a consequence of the given event.
+         * If the instruction data is invalid, an error message is shown and no instruction is added.
          */
-        _addInstruction: function (event) {
+        addInstruction(event) {
             event.preventDefault();
             event.stopPropagation();
-            var instructionsId = elData(event.currentTarget.closest('li.section'), 'instructions-id');
+            const instructionsId = event.currentTarget.closest("li.section").dataset
+                .instrictionsId;
             // note: data will be validated/filtered by the server
-            var pipField = elById(this._formFieldId + '_instructions' + instructionsId + '_pip');
+            const pipField = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`);
             // ignore pressing button if no PIP has been selected
             if (!pipField.value) {
                 return;
             }
-            var valueField = elById(this._formFieldId + '_instructions' + instructionsId + '_value');
-            var runStandaloneField = elById(this._formFieldId + '_instructions' + instructionsId + '_runStandalone');
-            var applicationField = elById(this._formFieldId + '_instructions' + instructionsId + '_application');
-            this._addInstructionByData(instructionsId, {
-                application: _applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : '',
+            const valueField = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_value`);
+            const runStandaloneField = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_runStandalone`);
+            const applicationField = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_application`);
+            this.addInstructionByData(instructionsId, {
+                application: Instructions.applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : "",
                 pip: pipField.value,
                 runStandalone: ~~runStandaloneField.checked,
-                value: valueField.value
+                value: valueField.value,
             });
             // empty fields
-            pipField.value = '';
-            valueField.value = '';
+            pipField.value = "";
+            valueField.value = "";
             runStandaloneField.checked = false;
-            applicationField.value = '';
-            elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription').innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
-            this._toggleApplicationFormField(instructionsId);
-            DomChangeListener.trigger();
-        },
+            applicationField.value = "";
+            document.getElementById(`${this.formFieldId}_instructions${instructionsId}_valueDescription`).innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+            this.toggleApplicationFormField(instructionsId);
+            Listener_1.default.trigger();
+        }
         /**
          * Adds an instruction to the set of instructions with the given id.
-         *
-         * @param      {int}           instructionsId
-         * @param      {object}        instructionData
          */
-        _addInstructionByData: function (instructionsId, instructionData) {
-            var instructionId = ++this._instructionCounter;
-            var instructionList = elById(this._formFieldId + '_instructions' + instructionsId + '_instructionList');
-            var listItem = elCreate('li');
-            listItem.className = 'sortableNode';
-            listItem.id = this._formFieldId + '_instruction' + instructionId;
-            elData(listItem, 'instruction-id', instructionId);
-            elData(listItem, 'application', instructionData.application);
-            elData(listItem, 'pip', instructionData.pip);
-            elData(listItem, 'runStandalone', instructionData.runStandalone);
-            elData(listItem, 'value', instructionData.value);
-            var content = '' +
-                '<div class="sortableNodeLabel">' +
-                '      <div class="jsDevtoolsProjectInstruction">' +
-                '              ' + Language.get('wcf.acp.devtools.project.instruction.instruction', instructionData);
+        addInstructionByData(instructionsId, instructionData) {
+            const instructionId = ++this.instructionCounter;
+            const instructionList = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_instructionList`);
+            const listItem = document.createElement("li");
+            listItem.className = "sortableNode";
+            listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+            listItem.dataset.instructionId = instructionId.toString();
+            listItem.dataset.application = instructionData.application;
+            listItem.dataset.pip = instructionData.pip;
+            listItem.dataset.runStandalone = instructionData.runStandalone ? "true" : "false";
+            listItem.dataset.value = instructionData.value;
+            let content = `
+      <div class="sortableNodeLabel">
+        <div class="jsDevtoolsProjectInstruction">
+          ${Language.get("wcf.acp.devtools.project.instruction.instruction", instructionData)}
+    `;
             if (instructionData.errors) {
-                for (var index in instructionData.errors) {
-                    content += '<small class="innerError">' + instructionData.errors[index] + '</small>';
-                }
+                instructionData.errors.forEach((error) => {
+                    content += `<small class="innerError">${error}</small>`;
+                });
             }
-            content += '' +
-                '      </div>' +
-                '      <span class="statusDisplay sortableButtonContainer">' +
-                '              <span class="icon icon16 fa-pencil pointer jsTooltip" id="' + this._formFieldId + '_instruction' + instructionId + '_editButton" title="' + Language.get('wcf.global.button.edit') + '"></span>' +
-                '              <span class="icon icon16 fa-times pointer jsTooltip" id="' + this._formFieldId + '_instruction' + instructionId + '_deleteButton" title="' + Language.get('wcf.global.button.delete') + '"></span>' +
-                '      </span>' +
-                '</div>';
+            content += `
+        </div>
+        <span class="statusDisplay sortableButtonContainer">
+          <span class="icon icon16 fa-pencil pointer jsTooltip" id="${this.formFieldId}_instruction${instructionId}_editButton" title="${Language.get("wcf.global.button.edit")}"></span>
+          <span class="icon icon16 fa-times pointer jsTooltip" id="${this.formFieldId}_instruction${instructionId}_deleteButton" title="${Language.get("wcf.global.button.delete")}"></span>
+        </span>
+      </div>
+    `;
             listItem.innerHTML = content;
             instructionList.appendChild(listItem);
-            elById(this._formFieldId + '_instruction' + instructionId + '_deleteButton').addEventListener('click', this._removeInstruction.bind(this));
-            elById(this._formFieldId + '_instruction' + instructionId + '_editButton').addEventListener('click', this._editInstruction.bind(this));
-        },
+            document
+                .getElementById(`${this.formFieldId}_instruction${instructionsId}_deleteButton`)
+                .addEventListener("click", (ev) => this.removeInstruction(ev));
+            document
+                .getElementById(`${this.formFieldId}_instruction${instructionsId}_editButton`)
+                .addEventListener("click", (ev) => this.editInstruction(ev));
+        }
         /**
-         * Adds a set of instructions as a consequenc of the given event.
-         * If the instructions data is invalid, an error message is shown
-         * and no instruction set is added.
+         * Adds a set of instructions.
          *
-         * @param      {Event}         event   event that triggered trying to add the instructions
+         * If the instructions data is invalid, an error message is shown and no instruction set is added.
          */
-        _addInstructions: function (event) {
+        addInstructions(event) {
             event.preventDefault();
             event.stopPropagation();
             // validate data
-            if (!this._validateInstructionsType() || (this._instructionsType.value === 'update' && !this._validateFromVersion(this._fromVersion))) {
+            if (!this.validateInstructionsType() ||
+                (this.instructionsType.value === "update" && !this.validateFromVersion(this.fromVersion))) {
                 return;
             }
-            this._addInstructionsByData({
-                fromVersion: this._instructionsType.value === 'update' ? this._fromVersion.value : '',
-                type: this._instructionsType.value
+            this.addInstructionsByData({
+                fromVersion: this.instructionsType.value === "update" ? this.fromVersion.value : "",
+                type: this.instructionsType.value,
             });
             // empty fields
-            this._instructionsType.value = '';
-            this._fromVersion.value = '';
-            this._toggleFromVersionFormField();
-            DomChangeListener.trigger();
-        },
+            this.instructionsType.value = "";
+            this.fromVersion.value = "";
+            this.toggleFromVersionFormField();
+            Listener_1.default.trigger();
+        }
         /**
          * Adds a set of instructions.
-         *
-         * @param      {object}        instructionData
          */
-        _addInstructionsByData: function (instructionsData) {
-            var instructionsId = ++this._instructionsCounter;
-            var listItem = elCreate('li');
-            listItem.className = 'section';
-            listItem.innerHTML = this._instructionsTemplate.fetch({
+        addInstructionsByData(instructionsData) {
+            const instructionsId = ++this.instructionsCounter;
+            const listItem = document.createElement("li");
+            listItem.className = "section";
+            listItem.innerHTML = this.instructionsTemplate.fetch({
                 instructionsId: instructionsId,
-                sectionTitle: Language.get('wcf.acp.devtools.project.instructions.type.' + instructionsData.type + '.title', {
-                    fromVersion: instructionsData.fromVersion
+                sectionTitle: Language.get(`wcf.acp.devtools.project.instructions.type.${instructionsData.type}.title`, {
+                    fromVersion: instructionsData.fromVersion,
                 }),
-                type: instructionsData.type
+                type: instructionsData.type,
             });
-            listItem.id = this._formFieldId + '_instructions' + instructionsId;
-            elData(listItem, 'instructions-id', instructionsId);
-            elData(listItem, 'type', instructionsData.type);
-            elData(listItem, 'fromVersion', instructionsData.fromVersion);
-            elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription');
-            this._instructionsList.appendChild(listItem);
-            var instructionListContainer = elById(this._formFieldId + '_instructions' + instructionsId + '_instructionListContainer');
+            listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+            listItem.dataset.instructionsId = instructionsId.toString();
+            listItem.dataset.type = instructionsData.type;
+            listItem.dataset.fromVersion = instructionsData.fromVersion;
+            this.instructionsList.appendChild(listItem);
+            const instructionListContainer = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_instructionListContainer`);
             if (Array.isArray(instructionsData.errors)) {
-                instructionsData.errors.forEach(function (errorMessage) {
-                    var small = elCreate('small');
-                    small.className = 'innerError';
-                    small.innerHTML = errorMessage;
-                    instructionListContainer.parentNode.insertBefore(small, instructionListContainer);
+                instructionsData.errors.forEach((errorMessage) => {
+                    Util_1.default.innerError(instructionListContainer, errorMessage, true);
                 });
             }
-            new UiSortableList({
+            new List_1.default({
                 containerId: instructionListContainer.id,
                 isSimpleSorting: true,
                 options: {
-                    toleranceElement: '> div'
-                }
+                    toleranceElement: "> div",
+                },
             });
-            var deleteButton = elById(this._formFieldId + '_instructions' + instructionsId + '_deleteButton');
-            if (instructionsData.type === 'update') {
-                elById(this._formFieldId + '_instructions' + instructionsId + '_deleteButton').addEventListener('click', this._removeInstructions.bind(this));
-                elById(this._formFieldId + '_instructions' + instructionsId + '_editButton').addEventListener('click', this._editInstructions.bind(this));
-            }
-            elById(this._formFieldId + '_instructions' + instructionsId + '_pip').addEventListener('change', this._changeInstructionPip.bind(this));
-            elById(this._formFieldId + '_instructions' + instructionsId + '_value').addEventListener('keypress', this._instructionKeyPress.bind(this));
-            elById(this._formFieldId + '_instructions' + instructionsId + '_addButton').addEventListener('click', this._addInstruction.bind(this));
+            if (instructionsData.type === "update") {
+                document
+                    .getElementById(`${this.formFieldId}_instructions${instructionsId}_deleteButton`)
+                    .addEventListener("click", (ev) => this.removeInstructions(ev));
+                document
+                    .getElementById(`${this.formFieldId}_instructions${instructionsId}_editButton`)
+                    .addEventListener("click", (ev) => this.editInstructions(ev));
+            }
+            document
+                .getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`)
+                .addEventListener("change", (ev) => this.changeInstructionPip(ev));
+            document
+                .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)
+                .addEventListener("keypress", (ev) => this.instructionKeyPress(ev));
+            document
+                .getElementById(`${this.formFieldId}_instructions${instructionsId}_addButton`)
+                .addEventListener("click", (ev) => this.addInstruction(ev));
             if (instructionsData.instructions) {
-                for (var index in instructionsData.instructions) {
-                    this._addInstructionByData(instructionsId, instructionsData.instructions[index]);
-                }
+                instructionsData.instructions.forEach((instruction) => {
+                    this.addInstructionByData(instructionsId, instruction);
+                });
             }
-        },
+        }
         /**
-         * Is called if the selected package installation plugin of an
-         * instruction is changed.
-         *
-         * @param      {Event}         event           change event
+         * Is called if the selected package installation plugin of an instruction is changed.
          */
-        _changeInstructionPip: function (event) {
-            var pip = event.currentTarget.value;
-            var instructionsId = elData(event.currentTarget.closest('li.section'), 'instructions-id');
-            var description = elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription');
+        changeInstructionPip(event) {
+            const target = event.currentTarget;
+            const pip = target.value;
+            const instructionsId = target.closest("li.section").dataset.instructionsId;
+            const description = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_valueDescription`);
             // update value description
-            if (this._pipDefaultFilenames[pip] !== '') {
-                description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description.defaultFilename', {
-                    defaultFilename: this._pipDefaultFilenames[pip]
+            if (this.pipDefaultFilenames[pip] !== "") {
+                description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description.defaultFilename", {
+                    defaultFilename: this.pipDefaultFilenames[pip],
                 });
             }
             else {
-                description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
+                description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
             }
-            var valueDlClassList = elById(this._formFieldId + '_instructions' + instructionsId + '_value').closest('dl').classList;
-            var applicationDl = elById(this._formFieldId + '_instructions' + instructionsId + '_application').closest('dl');
             // toggle application selector
-            this._toggleApplicationFormField(instructionsId);
-        },
+            this.toggleApplicationFormField(instructionsId);
+        }
         /**
          * Opens a dialog to edit an existing instruction.
-         *
-         * @param      {Event}         event   edit button click event
          */
-        _editInstruction: function (event) {
-            var listItem = event.currentTarget.closest('li');
-            var instructionId = elData(listItem, 'instruction-id');
-            var application = elData(listItem, 'application');
-            var pip = elData(listItem, 'pip');
-            var runStandalone = elDataBool(listItem, 'runStandalone');
-            var value = elData(listItem, 'value');
-            var dialogContent = this._instructionEditDialogTemplate.fetch({
+        editInstruction(event) {
+            const listItem = event.currentTarget.closest("li");
+            const instructionId = listItem.dataset.instructionId;
+            const application = listItem.dataset.application;
+            const pip = listItem.dataset.pip;
+            const runStandalone = Core.stringToBool(listItem.dataset.runStandalone);
+            const value = listItem.dataset.value;
+            const dialogContent = this.instructionEditDialogTemplate.fetch({
                 runStandalone: runStandalone,
-                value: value
+                value: value,
             });
-            var dialogId = 'instructionEditDialog' + instructionId;
-            if (!UiDialog.getDialog(dialogId)) {
-                UiDialog.openStatic(dialogId, dialogContent, {
-                    onSetup: function (content) {
-                        var applicationSelect = elBySel('select[name=application]', content);
-                        var pipSelect = elBySel('select[name=pip]', content);
-                        var runStandaloneInput = elBySel('input[name=runStandalone]', content);
-                        var valueInput = elBySel('input[name=value]', content);
+            const dialogId = "instructionEditDialog" + instructionId;
+            if (!Dialog_1.default.getDialog(dialogId)) {
+                Dialog_1.default.openStatic(dialogId, dialogContent, {
+                    onSetup: (content) => {
+                        const applicationSelect = content.querySelector("select[name=application]");
+                        const pipSelect = content.querySelector("select[name=pip]");
+                        const runStandaloneInput = content.querySelector("input[name=runStandalone]");
+                        const valueInput = content.querySelector("input[name=value]");
                         // set values of `select` elements
                         applicationSelect.value = application;
                         pipSelect.value = pip;
-                        var submit = function () {
-                            var listItem = elById(this._formFieldId + '_instruction' + instructionId);
-                            elData(listItem, 'application', _applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : '');
-                            elData(listItem, 'pip', pipSelect.value);
-                            elData(listItem, 'runStandalone', ~~runStandaloneInput.checked);
-                            elData(listItem, 'value', valueInput.value);
+                        const submit = () => {
+                            const listItem = document.getElementById(`${this.formFieldId}_instruction${instructionId}`);
+                            listItem.dataset.application =
+                                Instructions.applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : "";
+                            listItem.dataset.pip = pipSelect.value;
+                            listItem.dataset.runStandalone = runStandaloneInput.checked ? "1" : "0";
+                            listItem.dataset.value = valueInput.value;
                             // note: data will be validated/filtered by the server
-                            elByClass('jsDevtoolsProjectInstruction', listItem)[0].innerHTML = Language.get('wcf.acp.devtools.project.instruction.instruction', {
-                                application: elData(listItem, 'application'),
-                                pip: elData(listItem, 'pip'),
-                                runStandalone: elDataBool(listItem, 'runStandalone'),
-                                value: elData(listItem, 'value'),
+                            listItem.querySelector(".jsDevtoolsProjectInstruction").innerHTML = Language.get("wcf.acp.devtools.project.instruction.instruction", {
+                                application: listItem.dataset.application,
+                                pip: listItem.dataset.pip,
+                                runStandalone: listItem.dataset.runStandalone,
+                                value: listItem.dataset.value,
                             });
-                            DomChangeListener.trigger();
-                            UiDialog.close(dialogId);
-                        }.bind(this);
-                        valueInput.addEventListener('keypress', function (event) {
-                            if (EventKey.Enter(event)) {
+                            Listener_1.default.trigger();
+                            Dialog_1.default.close(dialogId);
+                        };
+                        valueInput.addEventListener("keypress", (event) => {
+                            if (event.key === "Enter") {
                                 submit();
                             }
                         });
-                        elBySel('button[data-type=submit]', content).addEventListener('click', submit);
-                        var pipChange = function () {
-                            var pip = pipSelect.value;
-                            if (_applicationPips.indexOf(pip) !== -1) {
-                                elShow(applicationSelect.closest('dl'));
+                        content.querySelector("button[data-type=submit]").addEventListener("click", submit);
+                        const pipChange = () => {
+                            const pip = pipSelect.value;
+                            if (Instructions.applicationPips.indexOf(pip) !== -1) {
+                                Util_1.default.show(applicationSelect.closest("dl"));
                             }
                             else {
-                                elHide(applicationSelect.closest('dl'));
+                                Util_1.default.hide(applicationSelect.closest("dl"));
                             }
-                            var description = DomTraverse.nextByTag(valueInput, 'SMALL');
-                            if (this._pipDefaultFilenames[pip] !== '') {
-                                description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description.defaultFilename', {
-                                    defaultFilename: this._pipDefaultFilenames[pip]
+                            const description = DomTraverse.nextByTag(valueInput, "SMALL");
+                            if (this.pipDefaultFilenames[pip] !== "") {
+                                description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description.defaultFilename", {
+                                    defaultFilename: this.pipDefaultFilenames[pip],
                                 });
                             }
                             else {
-                                description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
+                                description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
                             }
-                        }.bind(this);
-                        pipSelect.addEventListener('change', pipChange);
+                        };
+                        pipSelect.addEventListener("change", pipChange);
                         pipChange();
-                    }.bind(this),
-                    title: Language.get('wcf.acp.devtools.project.instruction.edit')
+                    },
+                    title: Language.get("wcf.acp.devtools.project.instruction.edit"),
                 });
             }
             else {
-                UiDialog.openStatic(dialogId);
+                Dialog_1.default.openStatic(dialogId, null);
             }
-        },
+        }
         /**
          * Opens a dialog to edit an existing set of instructions.
-         *
-         * @param      {Event}         event   edit button click event
          */
-        _editInstructions: function (event) {
-            var listItem = event.currentTarget.closest('li');
-            var instructionsId = elData(listItem, 'instructions-id');
-            var fromVersion = elData(listItem, 'fromVersion');
-            var dialogContent = this._instructionsEditDialogTemplate.fetch({
-                fromVersion: fromVersion
+        editInstructions(event) {
+            const listItem = event.currentTarget.closest("li");
+            const instructionsId = listItem.dataset.instructionsId;
+            const fromVersion = listItem.dataset.fromVersion;
+            const dialogContent = this.instructionsEditDialogTemplate.fetch({
+                fromVersion: fromVersion,
             });
-            var dialogId = 'instructionsEditDialog' + instructionsId;
-            if (!UiDialog.getDialog(dialogId)) {
-                UiDialog.openStatic(dialogId, dialogContent, {
-                    onSetup: function (content) {
-                        var fromVersion = elBySel('input[name=fromVersion]', content);
-                        var submit = function () {
-                            if (!this._validateFromVersion(fromVersion)) {
+            const dialogId = "instructionsEditDialog" + instructionsId;
+            if (!Dialog_1.default.getDialog(dialogId)) {
+                Dialog_1.default.openStatic(dialogId, dialogContent, {
+                    onSetup: (content) => {
+                        const fromVersion = content.querySelector("input[name=fromVersion]");
+                        const submit = () => {
+                            if (!this.validateFromVersion(fromVersion)) {
                                 return;
                             }
-                            var instructions = elById(this._formFieldId + '_instructions' + instructionsId);
-                            elData(instructions, 'fromVersion', fromVersion.value);
-                            elByClass('jsInstructionsTitle', instructions)[0].textContent = Language.get('wcf.acp.devtools.project.instructions.type.update.title', {
-                                fromVersion: fromVersion.value
+                            const instructions = document.getElementById(`${this.formFieldId}_instructions${instructionsId}`);
+                            instructions.dataset.fromVersion = fromVersion.value;
+                            instructions.querySelector(".jsInstructionsTitle").innerHTML = Language.get("wcf.acp.devtools.project.instructions.type.update.title", {
+                                fromVersion: fromVersion.value,
                             });
-                            DomChangeListener.trigger();
-                            UiDialog.close(dialogId);
-                        }.bind(this);
-                        fromVersion.addEventListener('keypress', function (event) {
-                            if (EventKey.Enter(event)) {
+                            Listener_1.default.trigger();
+                            Dialog_1.default.close(dialogId);
+                        };
+                        fromVersion.addEventListener("keypress", (event) => {
+                            if (event.key === "Enter") {
                                 submit();
                             }
                         });
-                        elBySel('button[data-type=submit]', content).addEventListener('click', submit);
-                    }.bind(this),
-                    title: Language.get('wcf.acp.devtools.project.instructions.edit')
+                        content.querySelector("button[data-type=submit]").addEventListener("click", submit);
+                    },
+                    title: Language.get("wcf.acp.devtools.project.instructions.edit"),
                 });
             }
             else {
-                UiDialog.openStatic(dialogId);
+                Dialog_1.default.openStatic(dialogId, null);
             }
-        },
-        /**
-         * Returns the error element for the given form field element.
-         * If `createIfNonExistent` is not given or `false`, `null` is returned
-         * if there is no error element, otherwise an empty error element
-         * is created and returned.
-         *
-         * @param      {?boolean}      createIfNonExistent
-         * @return     {?HTMLElement}
-         */
-        _getErrorElement: function (element, createIfNoNExistent) {
-            var error = DomTraverse.nextByClass(element, 'innerError');
-            if (error === null && createIfNoNExistent) {
-                error = elCreate('small');
-                error.className = 'innerError';
-                DomUtil.insertAfter(error, element);
-            }
-            return error;
-        },
-        /**
-         * Returns the error element for the from version form field.
-         * If `createIfNonExistent` is not given or `false`, `null` is returned
-         * if there is no error element, otherwise an empty error element
-         * is created and returned.
-         *
-         * @param      {?boolean}      createIfNonExistent
-         * @return     {?HTMLElement}
-         */
-        _getFromVersionErrorElement: function (inputField, createIfNonExistent) {
-            return this._getErrorElement(inputField, createIfNonExistent);
-        },
-        /**
-         * Returns the error element for the instruction type form field.
-         * If `createIfNonExistent` is not given or `false`, `null` is returned
-         * if there is no error element, otherwise an empty error element
-         * is created and returned.
-         *
-         * @param      {?boolean}      createIfNonExistent
-         * @return     {?HTMLElement}
-         */
-        _getInstructionsTypeErrorElement: function (createIfNonExistent) {
-            return this._getErrorElement(this._instructionsType, createIfNonExistent);
-        },
+        }
         /**
-         * Adds an instruction after pressing ENTER in a relevant text
-         * field.
-         *
-         * @param      {Event}         event
+         * Adds an instruction after pressing ENTER in a relevant text field.
          */
-        _instructionKeyPress: function (event) {
-            if (EventKey.Enter(event)) {
-                this._addInstruction(event);
+        instructionKeyPress(event) {
+            if (event.key === "Enter") {
+                this.addInstruction(event);
             }
-        },
+        }
         /**
-         * Adds a set of instruction after pressing ENTER in a relevant
-         * text field.
-         *
-         * @param      {Event}         event
+         * Adds a set of instruction after pressing ENTER in a relevant text field.
          */
-        _instructionsKeyPress: function (event) {
-            if (EventKey.Enter(event)) {
-                this._addInstructions(event);
+        instructionsKeyPress(event) {
+            if (event.key === "Enter") {
+                this.addInstructions(event);
             }
-        },
+        }
         /**
          * Removes an instruction by clicking on its delete button.
-         *
-         * @param      {Event}         event           delete button click event
          */
-        _removeInstruction: function (event) {
-            var instruction = event.currentTarget.closest('li');
+        removeInstruction(event) {
+            const instruction = event.currentTarget.closest("li");
             UiConfirmation.show({
-                confirm: function () {
-                    elRemove(instruction);
+                confirm: () => {
+                    instruction.remove();
                 },
-                message: Language.get('wcf.acp.devtools.project.instruction.delete.confirmMessages')
+                message: Language.get("wcf.acp.devtools.project.instruction.delete.confirmMessages"),
             });
-        },
+        }
         /**
          * Removes a set of instructions by clicking on its delete button.
          *
          * @param      {Event}         event           delete button click event
          */
-        _removeInstructions: function (event) {
-            var instructions = event.currentTarget.closest('li');
+        removeInstructions(event) {
+            const instructions = event.currentTarget.closest("li");
             UiConfirmation.show({
-                confirm: function () {
-                    elRemove(instructions);
+                confirm: () => {
+                    instructions.remove();
                 },
-                message: Language.get('wcf.acp.devtools.project.instructions.delete.confirmMessages')
+                message: Language.get("wcf.acp.devtools.project.instructions.delete.confirmMessages"),
             });
-        },
+        }
         /**
-         * Adds all necessary (hidden) form fields to the form when
-         * submitting the form.
+         * Adds all necessary (hidden) form fields to the form when submitting the form.
          */
-        _submit: function (event) {
-            DomTraverse.childrenByTag(this._instructionsList, 'LI').forEach(function (instructions, instructionsIndex) {
-                var namePrefix = this._formFieldId + '[' + instructionsIndex + ']';
-                var instructionsType = elCreate('input');
-                elAttr(instructionsType, 'type', 'hidden');
-                elAttr(instructionsType, 'name', namePrefix + '[type]');
-                instructionsType.value = elData(instructions, 'type');
-                this._form.appendChild(instructionsType);
-                if (instructionsType.value === 'update') {
-                    var fromVersion = elCreate('input');
-                    elAttr(fromVersion, 'type', 'hidden');
-                    elAttr(fromVersion, 'name', this._formFieldId + '[' + instructionsIndex + '][fromVersion]');
-                    fromVersion.value = elData(instructions, 'fromVersion');
-                    this._form.appendChild(fromVersion);
+        submit() {
+            DomTraverse.childrenByTag(this.instructionsList, "LI").forEach((instructions, instructionsIndex) => {
+                const namePrefix = `${this.formFieldId}[${instructionsIndex}]`;
+                const instructionsType = document.createElement("input");
+                instructionsType.type = "hidden";
+                instructionsType.name = `${namePrefix}[type]`;
+                instructionsType.value = instructions.dataset.type;
+                this.form.appendChild(instructionsType);
+                if (instructionsType.value === "update") {
+                    const fromVersion = document.createElement("input");
+                    fromVersion.type = "hidden";
+                    fromVersion.name = `${this.formFieldId}[${instructionsIndex}][fromVersion]`;
+                    fromVersion.value = instructions.dataset.fromVersion;
+                    this.form.appendChild(fromVersion);
                 }
-                DomTraverse.childrenByTag(elById(instructions.id + '_instructionList'), 'LI').forEach(function (instruction, instructionIndex) {
-                    var namePrefix = this._formFieldId + '[' + instructionsIndex + '][instructions][' + instructionIndex + ']';
-                    ['pip', 'value', 'runStandalone'].forEach((function (property) {
-                        var element = elCreate('input');
-                        elAttr(element, 'type', 'hidden');
-                        elAttr(element, 'name', namePrefix + '[' + property + ']');
-                        element.value = elData(instruction, property);
-                        this._form.appendChild(element);
-                    }).bind(this));
-                    if (_applicationPips.indexOf(elData(instruction, 'pip')) !== -1) {
-                        var application = elCreate('input');
-                        elAttr(application, 'type', 'hidden');
-                        elAttr(application, 'name', namePrefix + '[application]');
-                        application.value = elData(instruction, 'application');
-                        this._form.appendChild(application);
+                DomTraverse.childrenByTag(document.getElementById(`${instructions.id}_instructionList`), "LI").forEach((instruction, instructionIndex) => {
+                    const namePrefix = `${this.formFieldId}[${instructionsIndex}][instructions][${instructionIndex}]`;
+                    ["pip", "value", "runStandalone"].forEach((property) => {
+                        const element = document.createElement("input");
+                        element.type = "hidden";
+                        element.name = `${namePrefix}[${property}]`;
+                        element.value = instruction.dataset[property];
+                        this.form.appendChild(element);
+                    });
+                    if (Instructions.applicationPips.indexOf(instruction.dataset.pip) !== -1) {
+                        const application = document.createElement("input");
+                        application.type = "hidden";
+                        application.name = `${namePrefix}[application]`;
+                        application.value = instruction.dataset.application;
+                        this.form.appendChild(application);
                     }
-                }.bind(this));
-            }.bind(this));
-        },
+                });
+            });
+        }
         /**
-         * Toggles the visibility of the application form field based on
-         * the selected pip for the instructions with the given id.
-         *
-         * @param      {int}   instructionsId          id of the relevant instruction set
+         * Toggles the visibility of the application form field based on the selected pip for the instructions with the given id.
          */
-        _toggleApplicationFormField: function (instructionsId) {
-            var pip = elById(this._formFieldId + '_instructions' + instructionsId + '_pip').value;
-            var valueDlClassList = elById(this._formFieldId + '_instructions' + instructionsId + '_value').closest('dl').classList;
-            var applicationDl = elById(this._formFieldId + '_instructions' + instructionsId + '_application').closest('dl');
-            if (_applicationPips.indexOf(pip) !== -1) {
-                valueDlClassList.remove('col-md-9');
-                valueDlClassList.add('col-md-7');
-                elShow(applicationDl);
+        toggleApplicationFormField(instructionsId) {
+            const pip = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`)
+                .value;
+            const valueDlClassList = document
+                .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)
+                .closest("dl").classList;
+            const applicationDl = document
+                .getElementById(`${this.formFieldId}_instructions${instructionsId}_application`)
+                .closest("dl");
+            if (Instructions.applicationPips.indexOf(pip) !== -1) {
+                valueDlClassList.remove("col-md-9");
+                valueDlClassList.add("col-md-7");
+                Util_1.default.show(applicationDl);
             }
             else {
-                valueDlClassList.remove('col-md-7');
-                valueDlClassList.add('col-md-9');
-                elHide(applicationDl);
+                valueDlClassList.remove("col-md-7");
+                valueDlClassList.add("col-md-9");
+                Util_1.default.hide(applicationDl);
             }
-        },
+        }
         /**
-         * Toggles the visibility of the `fromVersion` form field based on
-         * the selected instructions type.
+         * Toggles the visibility of the `fromVersion` form field based on the selected instructions type.
          */
-        _toggleFromVersionFormField: function () {
-            var instructionsTypeList = this._instructionsType.closest('dl').classList;
-            var fromVersionDl = this._fromVersion.closest('dl');
-            if (this._instructionsType.value === 'update') {
-                instructionsTypeList.remove('col-md-10');
-                instructionsTypeList.add('col-md-5');
-                elShow(fromVersionDl);
+        toggleFromVersionFormField() {
+            const instructionsTypeList = this.instructionsType.closest("dl").classList;
+            const fromVersionDl = this.fromVersion.closest("dl");
+            if (this.instructionsType.value === "update") {
+                instructionsTypeList.remove("col-md-10");
+                instructionsTypeList.add("col-md-5");
+                Util_1.default.show(fromVersionDl);
             }
             else {
-                instructionsTypeList.remove('col-md-5');
-                instructionsTypeList.add('col-md-10');
-                elHide(fromVersionDl);
+                instructionsTypeList.remove("col-md-5");
+                instructionsTypeList.add("col-md-10");
+                Util_1.default.hide(fromVersionDl);
             }
-        },
+        }
         /**
-         * Returns `true` if the currently entered update "from version"
-         * is valid. Otherwise `false` is returned and an error message
-         * is shown.
-         *
-         * @return     {boolean}
+         * Returns `true` if the currently entered update "from version" is valid. Otherwise `false` is returned and an error
+         * message is shown.
          */
-        _validateFromVersion: function (inputField) {
-            var version = inputField.value;
-            if (version === '') {
-                this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.global.form.error.empty');
+        validateFromVersion(inputField) {
+            const version = inputField.value;
+            if (version === "") {
+                Util_1.default.innerError(inputField, Language.get("wcf.global.form.error.empty"));
                 return false;
             }
             if (version.length > 50) {
-                this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.maximumLength');
+                Util_1.default.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
                 return false;
             }
             // wildcard versions are checked on the server side
-            if (version.indexOf('*') === -1) {
-                // see `wcf\data\package\Package::isValidVersion()`
-                if (!version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(\ (a|alpha|b|beta|d|dev|rc|pl)\ ([0-9]+))?$/i)) {
-                    this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.format');
+            if (version.indexOf("*") === -1) {
+                if (!Instructions.versionRegExp.test(version)) {
+                    Util_1.default.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
                     return false;
                 }
             }
-            else if (!version.replace('*', '0').match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(\ (a|alpha|b|beta|d|dev|rc|pl)\ ([0-9]+))?$/i)) {
-                this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.format');
+            else if (!Instructions.versionRegExp.test(version.replace("*", "0"))) {
+                Util_1.default.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
                 return false;
             }
             // remove outdated errors
-            var error = this._getFromVersionErrorElement(inputField);
-            if (error !== null) {
-                elRemove(error);
-            }
+            Util_1.default.innerError(inputField, "");
             return true;
-        },
+        }
         /**
          * Returns `true` if the entered update instructions type is valid.
          * Otherwise `false` is returned and an error message is shown.
-         *
-         * @return     {boolean}
          */
-        _validateInstructionsType: function () {
-            if (this._instructionsType.value !== 'install' && this._instructionsType.value !== 'update') {
-                if (this._instructionsType.value === '') {
-                    this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.global.form.error.empty');
+        validateInstructionsType() {
+            if (this.instructionsType.value !== "install" && this.instructionsType.value !== "update") {
+                if (this.instructionsType.value === "") {
+                    Util_1.default.innerError(this.instructionsType, Language.get("wcf.global.form.error.empty"));
                 }
                 else {
-                    this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.global.form.error.noValidSelection');
+                    Util_1.default.innerError(this.instructionsType, Language.get("wcf.global.form.error.noValidSelection"));
                 }
                 return false;
             }
-            // there may only be one set of installation instructions 
-            if (this._instructionsType.value === 'install') {
-                var hasInstall = false;
-                [].forEach.call(this._instructionsList.children, function (instructions) {
-                    if (elData(instructions, 'type') === 'install') {
-                        hasInstall = true;
-                    }
-                });
+            // there may only be one set of installation instructions
+            if (this.instructionsType.value === "install") {
+                const hasInstall = Array.from(this.instructionsList.children).some((instructions) => instructions.dataset.type === "install");
                 if (hasInstall) {
-                    this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.acp.devtools.project.instructions.type.update.error.duplicate');
+                    Util_1.default.innerError(this.instructionsType, Language.get("wcf.acp.devtools.project.instructions.type.update.error.duplicate"));
                     return false;
                 }
             }
             // remove outdated errors
-            var error = this._getInstructionsTypeErrorElement();
-            if (error !== null) {
-                elRemove(error);
-            }
+            Util_1.default.innerError(this.instructionsType, "");
             return true;
         }
-    };
+    }
+    Instructions.applicationPips = ["acpTemplate", "file", "script", "template"];
+    // see `wcf\data\package\Package::isValidPackageName()`
+    Instructions.packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
+    // see `wcf\data\package\Package::isValidVersion()`
+    Instructions.versionRegExp = new RegExp(/^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i);
+    Core.enableLegacyInheritance(Instructions);
     return Instructions;
 });
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.js
deleted file mode 100644 (file)
index 3e73c41..0000000
+++ /dev/null
@@ -1,775 +0,0 @@
-/**
- * Manages the instructions entered in a devtools project instructions form field. 
- * 
- * @author     Matthias Schmidt
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
- * @since      5.2
- */
-define([
-       'Dom/ChangeListener',
-       'Dom/Traverse',
-       'Dom/Util',
-       'EventKey',
-       'Language',
-       'Ui/Confirmation',
-       'Ui/Dialog',
-       'WoltLabSuite/Core/Ui/Sortable/List'
-], function(
-       DomChangeListener,
-       DomTraverse,
-       DomUtil,
-       EventKey,
-       Language,
-       UiConfirmation,
-       UiDialog,
-       UiSortableList
-) {
-       "use strict";
-       
-       var _applicationPips = ['acpTemplate', 'file', 'script', 'template'];
-       
-       /**
-        * @constructor
-        */
-       function Instructions(
-               formFieldId,
-               instructionsTemplate,
-               instructionsEditDialogTemplate,
-               instructionEditDialogTemplate,
-               pipDefaultFilenames,
-               existingInstructions
-       ) {
-               this.init(
-                       formFieldId,
-                       instructionsTemplate,
-                       instructionsEditDialogTemplate,
-                       instructionEditDialogTemplate,
-                       pipDefaultFilenames,
-                       existingInstructions || []
-               );
-       };
-       Instructions.prototype = {
-               /**
-                * Initializes the instructions handler.
-                * 
-                * @param       {string}        formFieldId                     id of the associated form field
-                * @param       {Template}      instructionsTemplate            template used for a new set of instructions
-                * @param       {Template}      instructionsEditDialogTemplate  template used for instructions edit dialogs
-                * @param       {Template}      instructionEditDialogTemplate   template used for instruction edit dialogs
-                * @param       {object}        pipDefaultFilenames             maps pip names to their default filenames
-                * @param       {object[]}      existingInstructions            data of existing instructions
-                */
-               init: function(
-                       formFieldId,
-                       instructionsTemplate,
-                       instructionsEditDialogTemplate,
-                       instructionEditDialogTemplate,
-                       pipDefaultFilenames,
-                       existingInstructions
-               ) {
-                       this._formFieldId = formFieldId;
-                       this._instructionsTemplate = instructionsTemplate;
-                       this._instructionsEditDialogTemplate = instructionsEditDialogTemplate;
-                       this._instructionEditDialogTemplate = instructionEditDialogTemplate;
-                       this._instructionsCounter = 0;
-                       this._pipDefaultFilenames = pipDefaultFilenames;
-                       this._instructionCounter = 0;
-                       
-                       this._instructionsList = elById(this._formFieldId + '_instructionsList');
-                       if (this._instructionsList === null) {
-                               throw new Error("Cannot find package list for packages field with id '" + this._formFieldId + "'.");
-                       }
-                       
-                       this._instructionsType = elById(this._formFieldId + '_instructionsType');
-                       if (this._instructionsType === null) {
-                               throw new Error("Cannot find instruction type form field for instructions field with id '" + this._formFieldId + "'.");
-                       }
-                       this._instructionsType.addEventListener('change', this._toggleFromVersionFormField.bind(this));
-                       
-                       this._fromVersion = elById(this._formFieldId + '_fromVersion');
-                       if (this._fromVersion === null) {
-                               throw new Error("Cannot find from version form field for instructions field with id '" + this._formFieldId + "'.");
-                       }
-                       this._fromVersion.addEventListener('keypress', this._instructionsKeyPress.bind(this));
-                       
-                       this._addButton = elById(this._formFieldId + '_addButton');
-                       if (this._addButton === null) {
-                               throw new Error("Cannot find add button for instructions field with id '" + this._formFieldId + "'.");
-                       }
-                       this._addButton.addEventListener('click', this._addInstructions.bind(this));
-                       
-                       this._form = this._instructionsList.closest('form');
-                       if (this._form === null) {
-                               throw new Error("Cannot find form element for instructions field with id '" + this._formFieldId + "'.");
-                       }
-                       this._form.addEventListener('submit', this._submit.bind(this));
-                       
-                       var hasInstallInstructions = false;
-                       
-                       for (var index in existingInstructions) {
-                               var instructions = existingInstructions[index];
-                               
-                               if (instructions.type === 'install') {
-                                       hasInstallInstructions = true;
-                                       break;
-                               }
-                       }
-                       
-                       // ensure that there are always installation instructions
-                       if (!hasInstallInstructions) {
-                               this._addInstructionsByData({
-                                       fromVersion: '',
-                                       type: 'install'
-                               });
-                       }
-                       
-                       existingInstructions.forEach(this._addInstructionsByData.bind(this));
-                       
-                       DomChangeListener.trigger();
-               },
-               
-               /**
-                * Adds an instruction to a set of instructions as a consequence
-                * of the given event. If the instruction data is invalid, an
-                * error message is shown and no instruction is added.
-                * 
-                * @param       {Event}         event   event that triggered trying to add the instruction
-                */
-               _addInstruction: function(event) {
-                       event.preventDefault();
-                       event.stopPropagation();
-                       
-                       var instructionsId = elData(event.currentTarget.closest('li.section'), 'instructions-id');
-                       
-                       // note: data will be validated/filtered by the server
-                       
-                       var pipField = elById(this._formFieldId + '_instructions' + instructionsId + '_pip');
-                       
-                       // ignore pressing button if no PIP has been selected
-                       if (!pipField.value) {
-                               return;
-                       }
-                       
-                       var valueField = elById(this._formFieldId + '_instructions' + instructionsId + '_value');
-                       var runStandaloneField = elById(this._formFieldId + '_instructions' + instructionsId + '_runStandalone');
-                       var applicationField = elById(this._formFieldId + '_instructions' + instructionsId + '_application');
-                       
-                       this._addInstructionByData(instructionsId, {
-                               application: _applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : '',
-                               pip: pipField.value,
-                               runStandalone: ~~runStandaloneField.checked,
-                               value: valueField.value
-                       });
-                       
-                       // empty fields
-                       pipField.value = '';
-                       valueField.value = '';
-                       runStandaloneField.checked = false;
-                       applicationField.value = '';
-                       elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription').innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
-                       this._toggleApplicationFormField(instructionsId);
-                       
-                       DomChangeListener.trigger();
-               },
-               
-               /**
-                * Adds an instruction to the set of instructions with the given id.
-                * 
-                * @param       {int}           instructionsId
-                * @param       {object}        instructionData
-                */
-               _addInstructionByData: function(instructionsId, instructionData) {
-                       var instructionId = ++this._instructionCounter;
-                       
-                       var instructionList = elById(this._formFieldId + '_instructions' + instructionsId + '_instructionList');
-                       
-                       var listItem = elCreate('li');
-                       listItem.className = 'sortableNode';
-                       listItem.id = this._formFieldId + '_instruction' + instructionId;
-                       elData(listItem, 'instruction-id', instructionId);
-                       elData(listItem, 'application', instructionData.application);
-                       elData(listItem, 'pip', instructionData.pip);
-                       elData(listItem, 'runStandalone', instructionData.runStandalone);
-                       elData(listItem, 'value', instructionData.value);
-                       
-                       var content = '' +
-                               '<div class="sortableNodeLabel">' +
-                               '       <div class="jsDevtoolsProjectInstruction">' +
-                               '               ' + Language.get('wcf.acp.devtools.project.instruction.instruction', instructionData);
-                       
-                       if (instructionData.errors) {
-                               for (var index in instructionData.errors) {
-                                       content += '<small class="innerError">' + instructionData.errors[index] + '</small>';
-                               }
-                       }
-                       
-                       content += '' +
-                                       '       </div>' +
-                               '       <span class="statusDisplay sortableButtonContainer">' +
-                               '               <span class="icon icon16 fa-pencil pointer jsTooltip" id="' + this._formFieldId + '_instruction' + instructionId + '_editButton" title="' + Language.get('wcf.global.button.edit') + '"></span>' +
-                               '               <span class="icon icon16 fa-times pointer jsTooltip" id="' + this._formFieldId + '_instruction' + instructionId + '_deleteButton" title="' + Language.get('wcf.global.button.delete') + '"></span>' +
-                               '       </span>' +
-                               '</div>';
-                       
-                       listItem.innerHTML = content;
-                       
-                       instructionList.appendChild(listItem);
-                       
-                       elById(this._formFieldId + '_instruction' + instructionId + '_deleteButton').addEventListener('click', this._removeInstruction.bind(this));
-                       elById(this._formFieldId + '_instruction' + instructionId + '_editButton').addEventListener('click', this._editInstruction.bind(this));
-               },
-               
-               /**
-                * Adds a set of instructions as a consequenc of the given event.
-                * If the instructions data is invalid, an error message is shown
-                * and no instruction set is added.
-                * 
-                * @param       {Event}         event   event that triggered trying to add the instructions
-                */
-               _addInstructions: function(event) {
-                       event.preventDefault();
-                       event.stopPropagation();
-                       
-                       // validate data
-                       if (!this._validateInstructionsType() || (this._instructionsType.value === 'update' && !this._validateFromVersion(this._fromVersion))) {
-                               return;
-                       }
-                       
-                       this._addInstructionsByData({
-                               fromVersion: this._instructionsType.value === 'update' ? this._fromVersion.value : '',
-                               type: this._instructionsType.value
-                       });
-                       
-                       // empty fields
-                       this._instructionsType.value = '';
-                       this._fromVersion.value = '';
-                       
-                       this._toggleFromVersionFormField();
-                       
-                       DomChangeListener.trigger();
-               },
-               
-               /**
-                * Adds a set of instructions.
-                * 
-                * @param       {object}        instructionData
-                */
-               _addInstructionsByData: function(instructionsData) {
-                       var instructionsId = ++this._instructionsCounter;
-                       
-                       var listItem = elCreate('li');
-                       listItem.className = 'section';
-                       listItem.innerHTML = this._instructionsTemplate.fetch({
-                               instructionsId: instructionsId,
-                               sectionTitle: Language.get('wcf.acp.devtools.project.instructions.type.' + instructionsData.type + '.title', {
-                                       fromVersion: instructionsData.fromVersion
-                               }),
-                               type: instructionsData.type
-                       });
-                       
-                       listItem.id = this._formFieldId + '_instructions' + instructionsId;
-                       elData(listItem, 'instructions-id', instructionsId);
-                       elData(listItem, 'type', instructionsData.type);
-                       elData(listItem, 'fromVersion', instructionsData.fromVersion);
-                       
-                       elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription')
-                       
-                       this._instructionsList.appendChild(listItem);
-                       
-                       var instructionListContainer = elById(this._formFieldId + '_instructions' + instructionsId + '_instructionListContainer');
-                       if (Array.isArray(instructionsData.errors)) {
-                               instructionsData.errors.forEach(function(errorMessage) {
-                                       var small = elCreate('small');
-                                       small.className = 'innerError';
-                                       small.innerHTML = errorMessage;
-
-                                       instructionListContainer.parentNode.insertBefore(small, instructionListContainer);
-                               });
-                       }
-                       
-                       new UiSortableList({
-                               containerId: instructionListContainer.id,
-                               isSimpleSorting: true,
-                               options: {
-                                       toleranceElement: '> div'
-                               }
-                       });
-                       
-                       var deleteButton = elById(this._formFieldId + '_instructions' + instructionsId + '_deleteButton');
-                       if (instructionsData.type === 'update') {
-                               elById(this._formFieldId + '_instructions' + instructionsId + '_deleteButton').addEventListener('click', this._removeInstructions.bind(this));
-                               elById(this._formFieldId + '_instructions' + instructionsId + '_editButton').addEventListener('click', this._editInstructions.bind(this));
-                       }
-                       
-                       elById(this._formFieldId + '_instructions' + instructionsId + '_pip').addEventListener('change', this._changeInstructionPip.bind(this));
-                       elById(this._formFieldId + '_instructions' + instructionsId + '_value').addEventListener('keypress', this._instructionKeyPress.bind(this));
-                       elById(this._formFieldId + '_instructions' + instructionsId + '_addButton').addEventListener('click', this._addInstruction.bind(this));
-                       
-                       if (instructionsData.instructions) {
-                               for (var index in instructionsData.instructions) {
-                                       this._addInstructionByData(instructionsId, instructionsData.instructions[index]);
-                               }
-                       }
-               },
-               
-               /**
-                * Is called if the selected package installation plugin of an
-                * instruction is changed.
-                * 
-                * @param       {Event}         event           change event
-                */
-               _changeInstructionPip: function(event) {
-                       var pip = event.currentTarget.value;
-                       var instructionsId = elData(event.currentTarget.closest('li.section'), 'instructions-id');
-                       var description = elById(this._formFieldId + '_instructions' + instructionsId + '_valueDescription');
-                       
-                       // update value description
-                       if (this._pipDefaultFilenames[pip] !== '') {
-                               description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description.defaultFilename', {
-                                       defaultFilename: this._pipDefaultFilenames[pip]
-                               });
-                       }
-                       else {
-                               description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
-                       }
-                       
-                       var valueDlClassList = elById(this._formFieldId + '_instructions' + instructionsId + '_value').closest('dl').classList;
-                       var applicationDl = elById(this._formFieldId + '_instructions' + instructionsId + '_application').closest('dl');
-                       
-                       // toggle application selector
-                       this._toggleApplicationFormField(instructionsId);
-               },
-               
-               /**
-                * Opens a dialog to edit an existing instruction.
-                * 
-                * @param       {Event}         event   edit button click event
-                */
-               _editInstruction: function(event) {
-                       var listItem = event.currentTarget.closest('li');
-                       
-                       var instructionId = elData(listItem, 'instruction-id');
-                       var application = elData(listItem, 'application');
-                       var pip = elData(listItem, 'pip');
-                       var runStandalone = elDataBool(listItem, 'runStandalone');
-                       var value = elData(listItem, 'value');
-                       
-                       var dialogContent = this._instructionEditDialogTemplate.fetch({
-                               runStandalone: runStandalone,
-                               value: value
-                       });
-                       
-                       var dialogId = 'instructionEditDialog' + instructionId;
-                       if (!UiDialog.getDialog(dialogId)) {
-                               UiDialog.openStatic(dialogId, dialogContent, {
-                                       onSetup: function(content) {
-                                               var applicationSelect = elBySel('select[name=application]', content);
-                                               var pipSelect = elBySel('select[name=pip]', content);
-                                               var runStandaloneInput = elBySel('input[name=runStandalone]', content);
-                                               var valueInput = elBySel('input[name=value]', content);
-                                               
-                                               // set values of `select` elements
-                                               applicationSelect.value = application;
-                                               pipSelect.value = pip;
-                                               
-                                               var submit = function() {
-                                                       var listItem = elById(this._formFieldId + '_instruction' + instructionId);
-                                                       elData(listItem, 'application', _applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : '');
-                                                       elData(listItem, 'pip', pipSelect.value);
-                                                       elData(listItem, 'runStandalone', ~~runStandaloneInput.checked);
-                                                       elData(listItem, 'value', valueInput.value);
-                                                       
-                                                       // note: data will be validated/filtered by the server
-                                                       
-                                                       elByClass('jsDevtoolsProjectInstruction', listItem)[0].innerHTML = Language.get('wcf.acp.devtools.project.instruction.instruction', {
-                                                               application: elData(listItem, 'application'),
-                                                               pip: elData(listItem, 'pip'),
-                                                               runStandalone: elDataBool(listItem, 'runStandalone'),
-                                                               value: elData(listItem, 'value'),
-                                                       });
-                                                       
-                                                       DomChangeListener.trigger();
-                                                       
-                                                       UiDialog.close(dialogId);
-                                               }.bind(this);
-                                               
-                                               valueInput.addEventListener('keypress', function(event) {
-                                                       if (EventKey.Enter(event)) {
-                                                               submit();
-                                                       }
-                                               });
-                                               
-                                               elBySel('button[data-type=submit]', content).addEventListener('click', submit);
-                                               
-                                               var pipChange = function() {
-                                                       var pip = pipSelect.value;
-                                                       
-                                                       if (_applicationPips.indexOf(pip) !== -1) {
-                                                               elShow(applicationSelect.closest('dl'));
-                                                       }
-                                                       else {
-                                                               elHide(applicationSelect.closest('dl'));
-                                                       }
-                                                       
-                                                       var description = DomTraverse.nextByTag(valueInput, 'SMALL');
-                                                       if (this._pipDefaultFilenames[pip] !== '') {
-                                                               description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description.defaultFilename', {
-                                                                       defaultFilename: this._pipDefaultFilenames[pip]
-                                                               });
-                                                       }
-                                                       else {
-                                                               description.innerHTML = Language.get('wcf.acp.devtools.project.instruction.value.description');
-                                                       }
-                                               }.bind(this);
-                                               
-                                               pipSelect.addEventListener('change', pipChange);
-                                               pipChange();
-                                       }.bind(this),
-                                       title: Language.get('wcf.acp.devtools.project.instruction.edit')
-                               });
-                       }
-                       else {
-                               UiDialog.openStatic(dialogId);
-                       }
-               },
-               
-               /**
-                * Opens a dialog to edit an existing set of instructions.
-                * 
-                * @param       {Event}         event   edit button click event
-                */
-               _editInstructions: function(event) {
-                       var listItem = event.currentTarget.closest('li');
-                       
-                       var instructionsId = elData(listItem, 'instructions-id');
-                       var fromVersion = elData(listItem, 'fromVersion');
-                       
-                       var dialogContent = this._instructionsEditDialogTemplate.fetch({
-                               fromVersion: fromVersion
-                       });
-                       
-                       var dialogId = 'instructionsEditDialog' + instructionsId;
-                       if (!UiDialog.getDialog(dialogId)) {
-                               UiDialog.openStatic(dialogId, dialogContent, {
-                                       onSetup: function (content) {
-                                               var fromVersion = elBySel('input[name=fromVersion]', content);
-                                               
-                                               var submit = function () {
-                                                       if (!this._validateFromVersion(fromVersion)) {
-                                                               return;
-                                                       }
-                                                       
-                                                       var instructions = elById(this._formFieldId + '_instructions' + instructionsId);
-                                                       elData(instructions, 'fromVersion', fromVersion.value);
-                                                       
-                                                       elByClass('jsInstructionsTitle', instructions)[0].textContent = Language.get('wcf.acp.devtools.project.instructions.type.update.title', {
-                                                               fromVersion: fromVersion.value
-                                                       });
-                                                       
-                                                       DomChangeListener.trigger();
-                                                       
-                                                       UiDialog.close(dialogId);
-                                               }.bind(this);
-                                               
-                                               fromVersion.addEventListener('keypress', function (event) {
-                                                       if (EventKey.Enter(event)) {
-                                                               submit();
-                                                       }
-                                               });
-                                               
-                                               elBySel('button[data-type=submit]', content).addEventListener('click', submit);
-                                       }.bind(this),
-                                       title: Language.get('wcf.acp.devtools.project.instructions.edit')
-                               });
-                       }
-                       else {
-                               UiDialog.openStatic(dialogId);
-                       }
-               },
-               
-               /**
-                * Returns the error element for the given form field element.
-                * If `createIfNonExistent` is not given or `false`, `null` is returned
-                * if there is no error element, otherwise an empty error element
-                * is created and returned.
-                *
-                * @param       {?boolean}      createIfNonExistent
-                * @return      {?HTMLElement}
-                */
-               _getErrorElement: function(element, createIfNoNExistent) {
-                       var error = DomTraverse.nextByClass(element, 'innerError');
-                       
-                       if (error === null && createIfNoNExistent) {
-                               error = elCreate('small');
-                               error.className = 'innerError';
-                               
-                               DomUtil.insertAfter(error, element);
-                       }
-                       
-                       return error;
-               },
-               
-               /**
-                * Returns the error element for the from version form field.
-                * If `createIfNonExistent` is not given or `false`, `null` is returned
-                * if there is no error element, otherwise an empty error element
-                * is created and returned.
-                *
-                * @param       {?boolean}      createIfNonExistent
-                * @return      {?HTMLElement}
-                */
-               _getFromVersionErrorElement: function(inputField, createIfNonExistent) {
-                       return this._getErrorElement(inputField, createIfNonExistent);
-               },
-               
-               /**
-                * Returns the error element for the instruction type form field.
-                * If `createIfNonExistent` is not given or `false`, `null` is returned
-                * if there is no error element, otherwise an empty error element
-                * is created and returned.
-                *
-                * @param       {?boolean}      createIfNonExistent
-                * @return      {?HTMLElement}
-                */
-               _getInstructionsTypeErrorElement: function(createIfNonExistent) {
-                       return this._getErrorElement(this._instructionsType, createIfNonExistent);
-               },
-               
-               /**
-                * Adds an instruction after pressing ENTER in a relevant text
-                * field.
-                * 
-                * @param       {Event}         event
-                */
-               _instructionKeyPress: function(event) {
-                       if (EventKey.Enter(event)) {
-                               this._addInstruction(event);
-                       }
-               },
-               
-               /**
-                * Adds a set of instruction after pressing ENTER in a relevant
-                * text field.
-                * 
-                * @param       {Event}         event
-                */
-               _instructionsKeyPress: function(event) {
-                       if (EventKey.Enter(event)) {
-                               this._addInstructions(event);
-                       }
-               },
-               
-               /**
-                * Removes an instruction by clicking on its delete button.
-                * 
-                * @param       {Event}         event           delete button click event
-                */
-               _removeInstruction: function(event) {
-                       var instruction = event.currentTarget.closest('li');
-                       
-                       UiConfirmation.show({
-                               confirm: function() {
-                                       elRemove(instruction);
-                               },
-                               message: Language.get('wcf.acp.devtools.project.instruction.delete.confirmMessages')
-                       });
-               },
-               
-               /**
-                * Removes a set of instructions by clicking on its delete button.
-                * 
-                * @param       {Event}         event           delete button click event
-                */
-               _removeInstructions: function(event) {
-                       var instructions = event.currentTarget.closest('li');
-                       
-                       UiConfirmation.show({
-                               confirm: function() {
-                                       elRemove(instructions);
-                               },
-                               message: Language.get('wcf.acp.devtools.project.instructions.delete.confirmMessages')
-                       });
-               },
-               
-               /**
-                * Adds all necessary (hidden) form fields to the form when
-                * submitting the form.
-                */
-               _submit: function(event) {
-                       DomTraverse.childrenByTag(this._instructionsList, 'LI').forEach(function(instructions, instructionsIndex) {
-                               var namePrefix = this._formFieldId + '[' + instructionsIndex + ']';
-                               
-                               var instructionsType = elCreate('input');
-                               elAttr(instructionsType, 'type', 'hidden');
-                               elAttr(instructionsType, 'name', namePrefix + '[type]')
-                               instructionsType.value = elData(instructions, 'type');
-                               this._form.appendChild(instructionsType);
-                               
-                               if (instructionsType.value === 'update') {
-                                       var fromVersion = elCreate('input');
-                                       elAttr(fromVersion, 'type', 'hidden');
-                                       elAttr(fromVersion, 'name', this._formFieldId + '[' + instructionsIndex + '][fromVersion]')
-                                       fromVersion.value = elData(instructions, 'fromVersion');
-                                       this._form.appendChild(fromVersion);
-                               }
-                               
-                               DomTraverse.childrenByTag(elById(instructions.id + '_instructionList'), 'LI').forEach(function(instruction, instructionIndex) {
-                                       var namePrefix = this._formFieldId + '[' + instructionsIndex + '][instructions][' + instructionIndex + ']';
-                                       
-                                       ['pip', 'value', 'runStandalone'].forEach((function(property) {
-                                               var element = elCreate('input');
-                                               elAttr(element, 'type', 'hidden');
-                                               elAttr(element, 'name', namePrefix + '[' + property + ']');
-                                               element.value = elData(instruction, property);
-                                               this._form.appendChild(element);
-                                       }).bind(this));
-                                       
-                                       if (_applicationPips.indexOf(elData(instruction, 'pip')) !== -1) {
-                                               var application = elCreate('input');
-                                               elAttr(application, 'type', 'hidden');
-                                               elAttr(application, 'name', namePrefix + '[application]');
-                                               application.value = elData(instruction, 'application');
-                                               this._form.appendChild(application);
-                                       }
-                               }.bind(this));
-                       }.bind(this));
-               },
-               
-               /**
-                * Toggles the visibility of the application form field based on
-                * the selected pip for the instructions with the given id.
-                *
-                * @param       {int}   instructionsId          id of the relevant instruction set
-                */
-               _toggleApplicationFormField: function(instructionsId) {
-                       var pip = elById(this._formFieldId + '_instructions' + instructionsId + '_pip').value;
-                       
-                       var valueDlClassList = elById(this._formFieldId + '_instructions' + instructionsId + '_value').closest('dl').classList;
-                       var applicationDl = elById(this._formFieldId + '_instructions' + instructionsId + '_application').closest('dl');
-                       
-                       if (_applicationPips.indexOf(pip) !== -1) {
-                               valueDlClassList.remove('col-md-9');
-                               valueDlClassList.add('col-md-7');
-                               elShow(applicationDl);
-                       }
-                       else {
-                               valueDlClassList.remove('col-md-7');
-                               valueDlClassList.add('col-md-9');
-                               elHide(applicationDl);
-                       }
-               },
-               
-               /**
-                * Toggles the visibility of the `fromVersion` form field based on
-                * the selected instructions type.
-                */
-               _toggleFromVersionFormField: function() {
-                       var instructionsTypeList = this._instructionsType.closest('dl').classList;
-                       var fromVersionDl = this._fromVersion.closest('dl');
-                       
-                       if (this._instructionsType.value === 'update') {
-                               instructionsTypeList.remove('col-md-10');
-                               instructionsTypeList.add('col-md-5');
-                               elShow(fromVersionDl);
-                       }
-                       else {
-                               instructionsTypeList.remove('col-md-5');
-                               instructionsTypeList.add('col-md-10');
-                               elHide(fromVersionDl);
-                       }
-               },
-               
-               /**
-                * Returns `true` if the currently entered update "from version"
-                * is valid. Otherwise `false` is returned and an error message
-                * is shown.
-                * 
-                * @return      {boolean}
-                */
-               _validateFromVersion: function(inputField) {
-                       var version = inputField.value;
-                       
-                       if (version === '') {
-                               this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.global.form.error.empty');
-                               
-                               return false;
-                       }
-                       
-                       if (version.length > 50) {
-                               this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.maximumLength');
-                               
-                               return false;
-                       }
-                       
-                       // wildcard versions are checked on the server side
-                       if (version.indexOf('*') === -1) {
-                               // see `wcf\data\package\Package::isValidVersion()`
-                               if (!version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(\ (a|alpha|b|beta|d|dev|rc|pl)\ ([0-9]+))?$/i)) {
-                                       this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.format');
-                                       
-                                       return false;
-                               }
-                       }
-                       else if (!version.replace('*', '0').match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(\ (a|alpha|b|beta|d|dev|rc|pl)\ ([0-9]+))?$/i)) {
-                               this._getFromVersionErrorElement(inputField, true).textContent = Language.get('wcf.acp.devtools.project.packageVersion.error.format');
-                               
-                               return false;
-                       }
-                       
-                       // remove outdated errors
-                       var error = this._getFromVersionErrorElement(inputField);
-                       if (error !== null) {
-                               elRemove(error);
-                       }
-                       
-                       return true;
-               },
-               
-               /**
-                * Returns `true` if the entered update instructions type is valid.
-                * Otherwise `false` is returned and an error message is shown.
-                * 
-                * @return      {boolean}
-                */
-               _validateInstructionsType: function() {
-                       if (this._instructionsType.value !== 'install' && this._instructionsType.value !== 'update') {
-                               if (this._instructionsType.value === '') {
-                                       this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.global.form.error.empty');
-                               }
-                               else {
-                                       this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.global.form.error.noValidSelection');
-                               }
-                               
-                               return false;
-                       }
-                       
-                       // there may only be one set of installation instructions 
-                       if (this._instructionsType.value === 'install') {
-                               var hasInstall = false;
-                               [].forEach.call(this._instructionsList.children, function(instructions) {
-                                       if (elData(instructions, 'type') === 'install') {
-                                               hasInstall = true;
-                                       }
-                               });
-                               
-                               if (hasInstall) {
-                                       this._getInstructionsTypeErrorElement(true).textContent = Language.get('wcf.acp.devtools.project.instructions.type.update.error.duplicate');
-                                       
-                                       return false;
-                               }
-                       }
-                       
-                       // remove outdated errors
-                       var error = this._getInstructionsTypeErrorElement();
-                       if (error !== null) {
-                               elRemove(error);
-                       }
-                       
-                       return true;
-               }
-       };
-       
-       return Instructions;
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts
new file mode 100644 (file)
index 0000000..1c782b8
--- /dev/null
@@ -0,0 +1,712 @@
+/**
+ * Manages the instructions entered in a devtools project instructions form field.
+ *
+ * @author  Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
+ * @since 5.2
+ */
+
+import * as Core from "../../../../../../Core";
+import Template from "../../../../../../Template";
+import * as Language from "../../../../../../Language";
+import * as DomTraverse from "../../../../../../Dom/Traverse";
+import DomChangeListener from "../../../../../../Dom/Change/Listener";
+import DomUtil from "../../../../../../Dom/Util";
+import UiSortableList from "../../../../../../Ui/Sortable/List";
+import UiDialog from "../../../../../../Ui/Dialog";
+import * as UiConfirmation from "../../../../../../Ui/Confirmation";
+
+interface Instruction {
+  application: string;
+  errors?: string[];
+  pip: string;
+  runStandalone: number;
+  value: string;
+}
+
+interface InstructionsData {
+  errors?: string[];
+  fromVersion?: string;
+  instructions?: Instruction[];
+  type: "install" | "update";
+}
+
+type InstructionsId = number | string;
+
+class Instructions {
+  protected readonly addButton: HTMLAnchorElement;
+  protected readonly form: HTMLFormElement;
+  protected readonly formFieldId: string;
+  protected readonly fromVersion: HTMLInputElement;
+  protected instructionCounter = 0;
+  protected instructionsCounter = 0;
+  protected readonly instructionsEditDialogTemplate: Template;
+  protected readonly instructionsList: HTMLUListElement;
+  protected readonly instructionsType: HTMLSelectElement;
+  protected readonly instructionsTemplate: Template;
+  protected readonly instructionEditDialogTemplate: Template;
+  protected readonly pipDefaultFilenames: { [k: string]: string };
+
+  protected static applicationPips = ["acpTemplate", "file", "script", "template"];
+
+  // see `wcf\data\package\Package::isValidPackageName()`
+  protected static packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
+
+  // see `wcf\data\package\Package::isValidVersion()`
+  protected static versionRegExp = new RegExp(
+    /^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i,
+  );
+
+  constructor(
+    formFieldId: string,
+    instructionsTemplate: Template,
+    instructionsEditDialogTemplate: Template,
+    instructionEditDialogTemplate: Template,
+    pipDefaultFilenames: { [k: string]: string },
+    existingInstructions: InstructionsData[],
+  ) {
+    this.formFieldId = formFieldId;
+    this.instructionsTemplate = instructionsTemplate;
+    this.instructionsEditDialogTemplate = instructionsEditDialogTemplate;
+    this.instructionEditDialogTemplate = instructionEditDialogTemplate;
+    this.pipDefaultFilenames = pipDefaultFilenames;
+
+    this.instructionsList = document.getElementById(`${this.formFieldId}_instructionsList`) as HTMLUListElement;
+    if (this.instructionsList === null) {
+      throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
+    }
+
+    this.instructionsType = document.getElementById(`${this.formFieldId}_instructionsType`) as HTMLSelectElement;
+    if (this.instructionsType === null) {
+      throw new Error(`Cannot find instruction type form field for instructions field with id '${this.formFieldId}'.`);
+    }
+    this.instructionsType.addEventListener("change", () => this.toggleFromVersionFormField());
+
+    this.fromVersion = document.getElementById(`${this.formFieldId}_fromVersion`) as HTMLInputElement;
+    if (this.fromVersion === null) {
+      throw new Error(`Cannot find from version form field for instructions field with id '${this.formFieldId}'.`);
+    }
+    this.fromVersion.addEventListener("keypress", (ev) => this.instructionsKeyPress(ev));
+
+    this.addButton = document.getElementById(`${this.formFieldId}_addButton`) as HTMLAnchorElement;
+    if (this.addButton === null) {
+      throw new Error(`Cannot find add button form field for instructions field with id '${this.formFieldId}'.`);
+    }
+    this.addButton.addEventListener("click", (ev) => this.addInstructions(ev));
+
+    this.form = this.instructionsList.closest("form")!;
+    if (this.form === null) {
+      throw new Error(`Cannot find form element for instructions field with id '${this.formFieldId}'.`);
+    }
+    this.form.addEventListener("submit", () => this.submit());
+
+    const hasInstallInstructions = existingInstructions.some((instructions) => instructions.type === "install");
+
+    // ensure that there are always installation instructions
+    if (!hasInstallInstructions) {
+      this.addInstructionsByData({
+        fromVersion: "",
+        type: "install",
+      });
+    }
+
+    existingInstructions.forEach((instructions) => this.addInstructionsByData(instructions));
+
+    DomChangeListener.trigger();
+  }
+
+  /**
+   * Adds an instruction to a set of instructions as a consequence of the given event.
+   * If the instruction data is invalid, an error message is shown and no instruction is added.
+   */
+  protected addInstruction(event: Event): void {
+    event.preventDefault();
+    event.stopPropagation();
+
+    const instructionsId = ((event.currentTarget as HTMLElement).closest("li.section") as HTMLElement).dataset
+      .instrictionsId!;
+
+    // note: data will be validated/filtered by the server
+
+    const pipField = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_pip`,
+    ) as HTMLInputElement;
+
+    // ignore pressing button if no PIP has been selected
+    if (!pipField.value) {
+      return;
+    }
+
+    const valueField = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_value`,
+    ) as HTMLInputElement;
+    const runStandaloneField = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_runStandalone`,
+    ) as HTMLInputElement;
+    const applicationField = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_application`,
+    ) as HTMLSelectElement;
+
+    this.addInstructionByData(instructionsId, {
+      application: Instructions.applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : "",
+      pip: pipField.value,
+      runStandalone: ~~runStandaloneField.checked,
+      value: valueField.value,
+    });
+
+    // empty fields
+    pipField.value = "";
+    valueField.value = "";
+    runStandaloneField.checked = false;
+    applicationField.value = "";
+    document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_valueDescription`,
+    )!.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+    this.toggleApplicationFormField(instructionsId);
+
+    DomChangeListener.trigger();
+  }
+
+  /**
+   * Adds an instruction to the set of instructions with the given id.
+   */
+  protected addInstructionByData(instructionsId: InstructionsId, instructionData: Instruction): void {
+    const instructionId = ++this.instructionCounter;
+
+    const instructionList = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_instructionList`,
+    )!;
+
+    const listItem = document.createElement("li");
+    listItem.className = "sortableNode";
+    listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+    listItem.dataset.instructionId = instructionId.toString();
+    listItem.dataset.application = instructionData.application;
+    listItem.dataset.pip = instructionData.pip;
+    listItem.dataset.runStandalone = instructionData.runStandalone ? "true" : "false";
+    listItem.dataset.value = instructionData.value;
+
+    let content = `
+      <div class="sortableNodeLabel">
+        <div class="jsDevtoolsProjectInstruction">
+          ${Language.get("wcf.acp.devtools.project.instruction.instruction", instructionData)}
+    `;
+
+    if (instructionData.errors) {
+      instructionData.errors.forEach((error) => {
+        content += `<small class="innerError">${error}</small>`;
+      });
+    }
+
+    content += `
+        </div>
+        <span class="statusDisplay sortableButtonContainer">
+          <span class="icon icon16 fa-pencil pointer jsTooltip" id="${
+            this.formFieldId
+          }_instruction${instructionId}_editButton" title="${Language.get("wcf.global.button.edit")}"></span>
+          <span class="icon icon16 fa-times pointer jsTooltip" id="${
+            this.formFieldId
+          }_instruction${instructionId}_deleteButton" title="${Language.get("wcf.global.button.delete")}"></span>
+        </span>
+      </div>
+    `;
+
+    listItem.innerHTML = content;
+
+    instructionList.appendChild(listItem);
+
+    document
+      .getElementById(`${this.formFieldId}_instruction${instructionsId}_deleteButton`)!
+      .addEventListener("click", (ev) => this.removeInstruction(ev));
+    document
+      .getElementById(`${this.formFieldId}_instruction${instructionsId}_editButton`)!
+      .addEventListener("click", (ev) => this.editInstruction(ev));
+  }
+
+  /**
+   * Adds a set of instructions.
+   *
+   * If the instructions data is invalid, an error message is shown and no instruction set is added.
+   */
+  protected addInstructions(event: Event): void {
+    event.preventDefault();
+    event.stopPropagation();
+
+    // validate data
+    if (
+      !this.validateInstructionsType() ||
+      (this.instructionsType.value === "update" && !this.validateFromVersion(this.fromVersion))
+    ) {
+      return;
+    }
+
+    this.addInstructionsByData({
+      fromVersion: this.instructionsType.value === "update" ? this.fromVersion.value : "",
+      type: this.instructionsType.value as "install" | "update",
+    });
+
+    // empty fields
+    this.instructionsType.value = "";
+    this.fromVersion.value = "";
+
+    this.toggleFromVersionFormField();
+
+    DomChangeListener.trigger();
+  }
+
+  /**
+   * Adds a set of instructions.
+   */
+  protected addInstructionsByData(instructionsData: InstructionsData): void {
+    const instructionsId = ++this.instructionsCounter;
+
+    const listItem = document.createElement("li");
+    listItem.className = "section";
+    listItem.innerHTML = this.instructionsTemplate.fetch({
+      instructionsId: instructionsId,
+      sectionTitle: Language.get(`wcf.acp.devtools.project.instructions.type.${instructionsData.type}.title`, {
+        fromVersion: instructionsData.fromVersion,
+      }),
+      type: instructionsData.type,
+    });
+    listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+    listItem.dataset.instructionsId = instructionsId.toString();
+    listItem.dataset.type = instructionsData.type;
+    listItem.dataset.fromVersion = instructionsData.fromVersion;
+
+    this.instructionsList.appendChild(listItem);
+
+    const instructionListContainer = document.getElementById(
+      `${this.formFieldId}_instructions${instructionsId}_instructionListContainer`,
+    )!;
+    if (Array.isArray(instructionsData.errors)) {
+      instructionsData.errors.forEach((errorMessage) => {
+        DomUtil.innerError(instructionListContainer, errorMessage, true);
+      });
+    }
+
+    new UiSortableList({
+      containerId: instructionListContainer.id,
+      isSimpleSorting: true,
+      options: {
+        toleranceElement: "> div",
+      },
+    });
+
+    if (instructionsData.type === "update") {
+      document
+        .getElementById(`${this.formFieldId}_instructions${instructionsId}_deleteButton`)!
+        .addEventListener("click", (ev) => this.removeInstructions(ev));
+      document
+        .getElementById(`${this.formFieldId}_instructions${instructionsId}_editButton`)!
+        .addEventListener("click", (ev) => this.editInstructions(ev));
+    }
+
+    document
+      .getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`)!
+      .addEventListener("change", (ev) => this.changeInstructionPip(ev));
+
+    document
+      .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
+      .addEventListener("keypress", (ev) => this.instructionKeyPress(ev));
+
+    document
+      .getElementById(`${this.formFieldId}_instructions${instructionsId}_addButton`)!
+      .addEventListener("click", (ev) => this.addInstruction(ev));
+
+    if (instructionsData.instructions) {
+      instructionsData.instructions.forEach((instruction) => {
+        this.addInstructionByData(instructionsId, instruction);
+      });
+    }
+  }
+
+  /**
+   * Is called if the selected package installation plugin of an instruction is changed.
+   */
+  protected changeInstructionPip(event: Event): void {
+    const target = event.currentTarget as HTMLInputElement;
+
+    const pip = target.value;
+    const instructionsId = (target.closest("li.section") as HTMLElement).dataset.instructionsId!;
+    const description = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_valueDescription`)!;
+
+    // update value description
+    if (this.pipDefaultFilenames[pip] !== "") {
+      description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description.defaultFilename", {
+        defaultFilename: this.pipDefaultFilenames[pip],
+      });
+    } else {
+      description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+    }
+
+    // toggle application selector
+    this.toggleApplicationFormField(instructionsId);
+  }
+
+  /**
+   * Opens a dialog to edit an existing instruction.
+   */
+  protected editInstruction(event: Event): void {
+    const listItem = (event.currentTarget as HTMLElement).closest("li")!;
+
+    const instructionId = listItem.dataset.instructionId!;
+    const application = listItem.dataset.application!;
+    const pip = listItem.dataset.pip!;
+    const runStandalone = Core.stringToBool(listItem.dataset.runStandalone!);
+    const value = listItem.dataset.value!;
+
+    const dialogContent = this.instructionEditDialogTemplate.fetch({
+      runStandalone: runStandalone,
+      value: value,
+    });
+
+    const dialogId = "instructionEditDialog" + instructionId;
+    if (!UiDialog.getDialog(dialogId)) {
+      UiDialog.openStatic(dialogId, dialogContent, {
+        onSetup: (content) => {
+          const applicationSelect = content.querySelector("select[name=application]") as HTMLSelectElement;
+          const pipSelect = content.querySelector("select[name=pip]") as HTMLInputElement;
+          const runStandaloneInput = content.querySelector("input[name=runStandalone]") as HTMLInputElement;
+          const valueInput = content.querySelector("input[name=value]") as HTMLInputElement;
+
+          // set values of `select` elements
+          applicationSelect.value = application;
+          pipSelect.value = pip;
+
+          const submit = () => {
+            const listItem = document.getElementById(`${this.formFieldId}_instruction${instructionId}`)!;
+            listItem.dataset.application =
+              Instructions.applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : "";
+            listItem.dataset.pip = pipSelect.value;
+            listItem.dataset.runStandalone = runStandaloneInput.checked ? "1" : "0";
+            listItem.dataset.value = valueInput.value;
+
+            // note: data will be validated/filtered by the server
+
+            listItem.querySelector(".jsDevtoolsProjectInstruction")!.innerHTML = Language.get(
+              "wcf.acp.devtools.project.instruction.instruction",
+              {
+                application: listItem.dataset.application,
+                pip: listItem.dataset.pip,
+                runStandalone: listItem.dataset.runStandalone,
+                value: listItem.dataset.value,
+              },
+            );
+
+            DomChangeListener.trigger();
+
+            UiDialog.close(dialogId);
+          };
+
+          valueInput.addEventListener("keypress", (event) => {
+            if (event.key === "Enter") {
+              submit();
+            }
+          });
+
+          content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
+
+          const pipChange = () => {
+            const pip = pipSelect.value;
+
+            if (Instructions.applicationPips.indexOf(pip) !== -1) {
+              DomUtil.show(applicationSelect.closest("dl")!);
+            } else {
+              DomUtil.hide(applicationSelect.closest("dl")!);
+            }
+
+            const description = DomTraverse.nextByTag(valueInput, "SMALL")!;
+            if (this.pipDefaultFilenames[pip] !== "") {
+              description.innerHTML = Language.get(
+                "wcf.acp.devtools.project.instruction.value.description.defaultFilename",
+                {
+                  defaultFilename: this.pipDefaultFilenames[pip],
+                },
+              );
+            } else {
+              description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+            }
+          };
+
+          pipSelect.addEventListener("change", pipChange);
+          pipChange();
+        },
+        title: Language.get("wcf.acp.devtools.project.instruction.edit"),
+      });
+    } else {
+      UiDialog.openStatic(dialogId, null);
+    }
+  }
+
+  /**
+   * Opens a dialog to edit an existing set of instructions.
+   */
+  protected editInstructions(event: Event): void {
+    const listItem = (event.currentTarget as HTMLElement).closest("li")!;
+
+    const instructionsId = listItem.dataset.instructionsId!;
+    const fromVersion = listItem.dataset.fromVersion;
+
+    const dialogContent = this.instructionsEditDialogTemplate.fetch({
+      fromVersion: fromVersion,
+    });
+
+    const dialogId = "instructionsEditDialog" + instructionsId;
+    if (!UiDialog.getDialog(dialogId)) {
+      UiDialog.openStatic(dialogId, dialogContent, {
+        onSetup: (content) => {
+          const fromVersion = content.querySelector("input[name=fromVersion]") as HTMLInputElement;
+
+          const submit = () => {
+            if (!this.validateFromVersion(fromVersion)) {
+              return;
+            }
+
+            const instructions = document.getElementById(`${this.formFieldId}_instructions${instructionsId}`)!;
+            instructions.dataset.fromVersion = fromVersion.value;
+
+            instructions.querySelector(".jsInstructionsTitle")!.innerHTML = Language.get(
+              "wcf.acp.devtools.project.instructions.type.update.title",
+              {
+                fromVersion: fromVersion.value,
+              },
+            );
+
+            DomChangeListener.trigger();
+
+            UiDialog.close(dialogId);
+          };
+
+          fromVersion.addEventListener("keypress", (event) => {
+            if (event.key === "Enter") {
+              submit();
+            }
+          });
+
+          content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
+        },
+        title: Language.get("wcf.acp.devtools.project.instructions.edit"),
+      });
+    } else {
+      UiDialog.openStatic(dialogId, null);
+    }
+  }
+
+  /**
+   * Adds an instruction after pressing ENTER in a relevant text field.
+   */
+  protected instructionKeyPress(event: KeyboardEvent): void {
+    if (event.key === "Enter") {
+      this.addInstruction(event);
+    }
+  }
+
+  /**
+   * Adds a set of instruction after pressing ENTER in a relevant text field.
+   */
+  protected instructionsKeyPress(event: KeyboardEvent): void {
+    if (event.key === "Enter") {
+      this.addInstructions(event);
+    }
+  }
+
+  /**
+   * Removes an instruction by clicking on its delete button.
+   */
+  protected removeInstruction(event: Event): void {
+    const instruction = (event.currentTarget as HTMLElement).closest("li")!;
+
+    UiConfirmation.show({
+      confirm: () => {
+        instruction.remove();
+      },
+      message: Language.get("wcf.acp.devtools.project.instruction.delete.confirmMessages"),
+    });
+  }
+
+  /**
+   * Removes a set of instructions by clicking on its delete button.
+   *
+   * @param    {Event}         event           delete button click event
+   */
+  protected removeInstructions(event: Event): void {
+    const instructions = (event.currentTarget as HTMLElement).closest("li")!;
+
+    UiConfirmation.show({
+      confirm: () => {
+        instructions.remove();
+      },
+      message: Language.get("wcf.acp.devtools.project.instructions.delete.confirmMessages"),
+    });
+  }
+
+  /**
+   * Adds all necessary (hidden) form fields to the form when submitting the form.
+   */
+  protected submit(): void {
+    DomTraverse.childrenByTag(this.instructionsList, "LI").forEach((instructions, instructionsIndex) => {
+      const namePrefix = `${this.formFieldId}[${instructionsIndex}]`;
+
+      const instructionsType = document.createElement("input");
+      instructionsType.type = "hidden";
+      instructionsType.name = `${namePrefix}[type]`;
+      instructionsType.value = instructions.dataset.type!;
+      this.form.appendChild(instructionsType);
+
+      if (instructionsType.value === "update") {
+        const fromVersion = document.createElement("input");
+        fromVersion.type = "hidden";
+        fromVersion.name = `${this.formFieldId}[${instructionsIndex}][fromVersion]`;
+        fromVersion.value = instructions.dataset.fromVersion!;
+        this.form.appendChild(fromVersion);
+      }
+
+      DomTraverse.childrenByTag(document.getElementById(`${instructions.id}_instructionList`)!, "LI").forEach(
+        (instruction, instructionIndex) => {
+          const namePrefix = `${this.formFieldId}[${instructionsIndex}][instructions][${instructionIndex}]`;
+
+          ["pip", "value", "runStandalone"].forEach((property) => {
+            const element = document.createElement("input");
+            element.type = "hidden";
+            element.name = `${namePrefix}[${property}]`;
+            element.value = instruction.dataset[property]!;
+            this.form.appendChild(element);
+          });
+
+          if (Instructions.applicationPips.indexOf(instruction.dataset.pip!) !== -1) {
+            const application = document.createElement("input");
+            application.type = "hidden";
+            application.name = `${namePrefix}[application]`;
+            application.value = instruction.dataset.application!;
+            this.form.appendChild(application);
+          }
+        },
+      );
+    });
+  }
+
+  /**
+   * Toggles the visibility of the application form field based on the selected pip for the instructions with the given id.
+   */
+  protected toggleApplicationFormField(instructionsId: InstructionsId): void {
+    const pip = (document.getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`) as HTMLInputElement)
+      .value;
+
+    const valueDlClassList = document
+      .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
+      .closest("dl")!.classList;
+    const applicationDl = document
+      .getElementById(`${this.formFieldId}_instructions${instructionsId}_application`)!
+      .closest("dl")!;
+
+    if (Instructions.applicationPips.indexOf(pip) !== -1) {
+      valueDlClassList.remove("col-md-9");
+      valueDlClassList.add("col-md-7");
+      DomUtil.show(applicationDl);
+    } else {
+      valueDlClassList.remove("col-md-7");
+      valueDlClassList.add("col-md-9");
+      DomUtil.hide(applicationDl);
+    }
+  }
+
+  /**
+   * Toggles the visibility of the `fromVersion` form field based on the selected instructions type.
+   */
+  protected toggleFromVersionFormField(): void {
+    const instructionsTypeList = this.instructionsType.closest("dl")!.classList;
+    const fromVersionDl = this.fromVersion.closest("dl")!;
+
+    if (this.instructionsType.value === "update") {
+      instructionsTypeList.remove("col-md-10");
+      instructionsTypeList.add("col-md-5");
+      DomUtil.show(fromVersionDl);
+    } else {
+      instructionsTypeList.remove("col-md-5");
+      instructionsTypeList.add("col-md-10");
+      DomUtil.hide(fromVersionDl);
+    }
+  }
+
+  /**
+   * Returns `true` if the currently entered update "from version" is valid. Otherwise `false` is returned and an error
+   * message is shown.
+   */
+  protected validateFromVersion(inputField: HTMLInputElement): boolean {
+    const version = inputField.value;
+
+    if (version === "") {
+      DomUtil.innerError(inputField, Language.get("wcf.global.form.error.empty"));
+
+      return false;
+    }
+
+    if (version.length > 50) {
+      DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
+
+      return false;
+    }
+
+    // wildcard versions are checked on the server side
+    if (version.indexOf("*") === -1) {
+      if (!Instructions.versionRegExp.test(version)) {
+        DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
+
+        return false;
+      }
+    } else if (!Instructions.versionRegExp.test(version.replace("*", "0"))) {
+      DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
+
+      return false;
+    }
+
+    // remove outdated errors
+    DomUtil.innerError(inputField, "");
+
+    return true;
+  }
+
+  /**
+   * Returns `true` if the entered update instructions type is valid.
+   * Otherwise `false` is returned and an error message is shown.
+   */
+  protected validateInstructionsType(): boolean {
+    if (this.instructionsType.value !== "install" && this.instructionsType.value !== "update") {
+      if (this.instructionsType.value === "") {
+        DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.empty"));
+      } else {
+        DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.noValidSelection"));
+      }
+
+      return false;
+    }
+
+    // there may only be one set of installation instructions
+    if (this.instructionsType.value === "install") {
+      const hasInstall = Array.from(this.instructionsList.children).some(
+        (instructions: HTMLElement) => instructions.dataset.type === "install",
+      );
+
+      if (hasInstall) {
+        DomUtil.innerError(
+          this.instructionsType,
+          Language.get("wcf.acp.devtools.project.instructions.type.update.error.duplicate"),
+        );
+
+        return false;
+      }
+    }
+
+    // remove outdated errors
+    DomUtil.innerError(this.instructionsType, "");
+
+    return true;
+  }
+}
+
+Core.enableLegacyInheritance(Instructions);
+
+export = Instructions;