opf/openproject

View on GitHub
frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts

Summary

Maintainability
B
5 hrs
Test Coverage
/*
 * -- copyright
 * OpenProject is an open source project management software.
 * Copyright (C) 2023 the OpenProject GmbH
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License version 3.
 *
 * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
 * Copyright (C) 2006-2013 Jean-Philippe Lang
 * Copyright (C) 2010-2013 the ChiliProject Team
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * See COPYRIGHT and LICENSE files for more details.
 * ++
 */

import { Controller } from '@hotwired/stimulus';

export default class CustomFieldsController extends Controller {
  static targets = [
    'dragContainer',
    'format',
    'length',
    'regexp',
    'multiSelect',
    'allowNonOpenVersions',
    'possibleValues',
    'defaultValue',
    'defaultText',
    'defaultLongText',
    'defaultBool',
    'textOrientation',
    'searchable',
    'customOptionRow',
    'customOptionDefaults',
  ];

  static values = {
    format: String,
  };

  declare readonly formatValue:string;
  declare readonly dragContainerTarget:HTMLElement;
  declare readonly hasDragContainerTarget:boolean;

  // Lots of the option fields are optionally, so we use the
  // array notation to loop through instead of a lot of if (has...Target) calls
  declare readonly lengthTargets:HTMLElement[];
  declare readonly regexpTargets:HTMLElement[];
  declare readonly multiSelectTargets:HTMLElement[];
  declare readonly allowNonOpenVersionsTargets:HTMLElement[];
  declare readonly possibleValuesTargets:HTMLElement[];
  declare readonly defaultValueTargets:HTMLElement[];
  declare readonly defaultTextTargets:HTMLElement[];
  declare readonly defaultLongTextTargets:HTMLElement[];
  declare readonly defaultBoolTargets:HTMLElement[];
  declare readonly textOrientationTargets:HTMLElement[];
  declare readonly searchableTargets:HTMLInputElement[];
  declare readonly customOptionRowTargets:HTMLTableRowElement[];
  declare readonly customOptionDefaultsTargets:HTMLInputElement[];

  connect() {
    if (this.hasDragContainerTarget) {
      this.setupDragAndDrop();
    }

    this.toggleFormat(this.formatValue);
  }

  activate(elements:HTMLElement[], active = true) {
    this.toggleVisibility(!active, elements);
    elements.forEach((element) => {
      element
        .querySelectorAll<HTMLInputElement>('input,textarea')
        .forEach((input) => {
          if (!input.matches('.destroy_flag,.-cf-ignore-disabled')) {
            input.disabled = !active;
          }
        });
    });
  }

  toggleVisibility(hidden:boolean, elements:HTMLElement[]) {
    elements
      .forEach((field) => {
        const wrapper = field.closest<HTMLElement>('.form--grouping') || field.closest<HTMLElement>('.form--field');
        if (wrapper) {
          wrapper.hidden = hidden;
        } else {
          field.hidden = hidden;
        }
      });
  }

  hide(...elements:HTMLElement[]) {
    this.toggleVisibility(true, elements);
  }

  show(...elements:HTMLElement[]) {
    this.toggleVisibility(false, elements);
  }

  unsearchable() {
    this.hide(...this.searchableTargets);
    this
      .searchableTargets
      .forEach((target) => (target.checked = false));
  }

  formatChanged(event:{ target:HTMLInputElement }) {
    this.toggleFormat(event.target.value);
  }

  moveUpRow(event:{ target:HTMLElement }) {
    const row = event.target.closest('tr') as HTMLTableRowElement;
    const idx = this.customOptionRowTargets.indexOf(row);
    if (idx > 0) {
      this.customOptionRowTargets[idx - 1].before(row);
    }

    return false;
  }

  moveDownRow(event:{ target:HTMLElement }) {
    const row = event.target.closest('tr') as HTMLTableRowElement;
    const idx = this.customOptionRowTargets.indexOf(row);
    if (idx < this.customOptionRowTargets.length - 1) {
      this.customOptionRowTargets[idx + 1].after(row);
    }

    return false;
  }

  moveRowToTheTop(event:{ target:HTMLElement }) {
    const row = event.target.closest('tr') as HTMLTableRowElement;
    const first = this.customOptionRowTargets[0];

    if (first && first !== row) {
      first.before(row);
    }

    return false;
  }

  moveRowToTheBottom(event:{ target:HTMLElement }) {
    const row = event.target.closest('tr') as HTMLTableRowElement;
    const last = this.customOptionRowTargets[this.customOptionRowTargets.length - 1];

    if (last && last !== row) {
      last.after(row);
    }

    return false;
  }

  removeOption(event:MouseEvent) {
    const self = event.target as HTMLAnchorElement;
    if (self.href === '#' || self.href.endsWith('/0')) {
      const row = self.closest('tr');

      if (row && this.customOptionRowTargets.length > 1) {
        row.remove();
      }

      event.preventDefault();
      event.stopImmediatePropagation();
    }
    return true; // send off deletion
  }

  addOption() {
    const count = this.customOptionRowTargets.length;
    const last = this.customOptionRowTargets[count - 1];
    const dup = last.cloneNode(true) as HTMLElement;

    const input = dup.querySelector('.custom-option-value input') as HTMLInputElement;

    input.setAttribute('name', `custom_field[custom_options_attributes][${count}][value]`);
    input.setAttribute('id', `custom_field_custom_options_attributes_${count}_value`);
    input.value = '';

    dup
      .querySelector('.custom-option-id')
      ?.remove();

    const defaultValueCheckbox = dup.querySelector('input[type="checkbox"]') as HTMLInputElement;
    const defaultValueHidden = dup.querySelector('input[type="hidden"]') as HTMLInputElement;

    defaultValueHidden.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`);
    defaultValueHidden.removeAttribute('id');
    defaultValueCheckbox.setAttribute('name', `custom_field[custom_options_attributes][${count}][default_value]`);
    defaultValueCheckbox.setAttribute('id', `custom_field_custom_options_attributes_${count}_default_value`);
    defaultValueCheckbox.checked = false;

    last.insertAdjacentElement('afterend', dup);

    return false;
  }

  uncheckOtherDefaults(event:{ target:HTMLElement }) {
    const cb = event.target as HTMLInputElement;

    if (cb.checked) {
      const multi = this.multiSelectTargets[0] as HTMLInputElement|undefined;

      if (multi?.checked === false) {
        this.customOptionDefaultsTargets.forEach((el) => (el.checked = false));
        cb.checked = true;
      }
    }
  }

  checkOnlyOne(event:{ target:HTMLElement }) {
    const cb = event.target as HTMLInputElement;

    if (!cb.checked) {
      this.customOptionDefaultsTargets
        .filter((el) => el.checked)
        .slice(1)
        .forEach((el) => (el.checked = false));
    }
  }

  private setupDragAndDrop() {
    // Make custom fields draggable
    // eslint-disable-next-line no-undef
    const drake = dragula([this.dragContainerTarget], {
      isContainer: () => false,
      moves: (el, source, handle:HTMLElement) => handle.classList.contains('dragula-handle'),
      accepts: () => true,
      invalid: () => false,
      direction: 'vertical',
      copy: false,
      copySortSource: false,
      revertOnSpill: true,
      removeOnSpill: false,
      mirrorContainer: this.dragContainerTarget,
      ignoreInputTextSelection: true,
    });

    // Setup autoscroll
    void window.OpenProject.getPluginContext().then((pluginContext) => {
      // eslint-disable-next-line no-new
      new pluginContext.classes.DomAutoscrollService(
        [
          document.getElementById('content-wrapper') as HTMLElement,
        ],
        {
          margin: 25,
          maxSpeed: 10,
          scrollWhenOutside: true,
          autoScroll: () => drake.dragging,
        },
      );
    });
  }

  private toggleFormat(format:string) {
    // defaults (reset these fields before doing anything else)
    this.activate(this.defaultBoolTargets, false);
    this.activate(this.defaultLongTextTargets, false);
    this.activate(this.multiSelectTargets, false);
    this.activate(this.allowNonOpenVersionsTargets, false);
    this.activate(this.textOrientationTargets, false);
    this.activate(this.defaultValueTargets);
    this.activate(this.defaultTextTargets);

    switch (format) {
      case 'list':
        this.activate(this.defaultValueTargets, false);
        this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.defaultValueTargets);
        this.show(...this.searchableTargets, ...this.multiSelectTargets);
        this.activate(this.multiSelectTargets);
        this.activate(this.possibleValuesTargets);
        break;
      case 'bool':
        this.activate(this.defaultBoolTargets);
        this.activate(this.defaultTextTargets, false);
        this.activate(this.possibleValuesTargets, false);
        this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.searchableTargets);
        this.unsearchable();
        break;
      case 'date':
        this.activate(this.defaultValueTargets, false);
        this.activate(this.possibleValuesTargets, false);
        this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.defaultValueTargets);
        this.unsearchable();
        break;
      case 'float':
      case 'int':
        this.activate(this.possibleValuesTargets, false);
        this.show(...this.lengthTargets, ...this.regexpTargets);
        this.unsearchable();
        break;
      case 'user':
        this.activate(this.possibleValuesTargets, false);
        this.show(...this.multiSelectTargets);
        this.activate(this.multiSelectTargets);
        this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.defaultValueTargets);
        this.unsearchable();
        break;
      case 'version':
        this.show(...this.multiSelectTargets, ...this.allowNonOpenVersionsTargets);
        this.activate(this.defaultValueTargets, false);
        this.activate(this.possibleValuesTargets, false);
        this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.defaultValueTargets);
        this.unsearchable();
        break;
      case 'text':
        this.activate(this.defaultLongTextTargets);
        this.activate(this.defaultTextTargets, false);
        this.show(...this.lengthTargets, ...this.regexpTargets, ...this.searchableTargets, ...this.textOrientationTargets);
        this.activate(this.possibleValuesTargets, false);
        this.activate(this.textOrientationTargets);
        break;
      default:
        this.show(...this.lengthTargets, ...this.regexpTargets, ...this.searchableTargets);
        this.activate(this.possibleValuesTargets, false);
        break;
    }
  }
}