opf/openproject

View on GitHub
frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts

Summary

Maintainability
A
35 mins
Test Coverage
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2024 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 { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { Component, OnInit, ViewChild } from '@angular/core';
import { EditFieldComponent } from 'core-app/shared/components/fields/edit/edit-field.component';
import { ValueOption } from 'core-app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component';
import { NgSelectComponent } from '@ng-select/ng-select';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';

@Component({
  templateUrl: './multi-select-edit-field.component.html',
})
export class MultiSelectEditFieldComponent extends EditFieldComponent implements OnInit {
  @ViewChild(NgSelectComponent, { static: true }) public ngSelectComponent:NgSelectComponent;

  @InjectField() I18n!:I18nService;

  public availableOptions:any[] = [];

  public text = {
    requiredPlaceholder: this.I18n.t('js.placeholders.selection'),
    placeholder: this.I18n.t('js.placeholders.default'),
    save: this.I18n.t('js.inplace.button_save', { attribute: this.schema.name }),
    cancel: this.I18n.t('js.inplace.button_cancel', { attribute: this.schema.name }),
  };

  public appendTo:any = null;

  public currentValueInvalid = false;

  public showAddNewUserButton:boolean;

  private hiddenOverflowContainer = '.__hidden_overflow_container';

  private nullOption:ValueOption;

  private _selectedOption:ValueOption[];

  /** Since we need to wait for values to be loaded, remember if the user activated this field */
  private requestFocus = false;

  ngOnInit() {
    this.nullOption = { name: this.text.placeholder, href: null };
    this.showAddNewUserButton = this.schema.type === 'User';

    this.handler
      .$onUserActivate
      .pipe(
        this.untilDestroyed(),
      )
      .subscribe(() => {
        this.requestFocus = this.availableOptions.length === 0;

        // If we already have all values loaded, open now.
        if (!this.requestFocus) {
          this.openAutocompleteSelectField();
        }
      });

    super.ngOnInit();
    this.appendTo = this.overflowingSelector;
  }

  public get value() {
    const val = this.resource[this.name];
    return val ? val[0] : val;
  }

  /**
   * Map the selected hal resource(s) to the value options so that ngOptions will track them.
   * We cannot pass the HalResources themselves as angular will copy them on every digest due to trackBy
   * @returns {any}
   */
  public buildSelectedOption() {
    const value:HalResource[] = this.resource[this.name];
    return value ? _.castArray(value).map((val) => this.findValueOption(val)) : [];
  }

  public get selectedOption() {
    return this._selectedOption;
  }

  /**
   * Map the ValueOption to the actual HalResource option
   * @param val
   */
  public set selectedOption(val:ValueOption[]) {
    this._selectedOption = val;
    const mapper = (val:ValueOption) => {
      const option = _.find(this.availableOptions, (o) => o.href === val.href) || this.nullOption;

      // Special case 'null' value, which angular
      // only understands in ng-options as an empty string.
      if (option && option.href === '') {
        option.href = null;
      }

      return option;
    };

    this.resource[this.name] = _.castArray(val).map((el) => mapper(el));
  }

  public onOpen() {
    jQuery(this.hiddenOverflowContainer).one('scroll', () => {
      this.ngSelectComponent.close();
    });
  }

  public onClose() {
    // Nothing to do
  }

  public repositionDropdown() {
    if (this.ngSelectComponent && this.ngSelectComponent.dropdownPanel) {
      setTimeout(() => this.ngSelectComponent.dropdownPanel.adjustPosition(), 0);
    }
  }

  private openAutocompleteSelectField() {
    // The timeout takes care that the opening is added to the end of the current call stack.
    // Thus we can be sure that the autocompleter is rendered and ready to be opened.
    const that = this;
    window.setTimeout(() => {
      that.ngSelectComponent.open();
    }, 0);
  }

  private findValueOption(option?:HalResource):ValueOption {
    let result;

    if (option) {
      result = _.find(this.availableOptions, (valueOption) => valueOption.href === option.href)!;
    }

    return result || this.nullOption;
  }

  private setValues(availableValues:any[], sortValuesByName = false) {
    if (sortValuesByName) {
      availableValues.sort((a:any, b:any) => {
        const nameA = a.name.toLowerCase();
        const nameB = b.name.toLowerCase();
        return nameA < nameB ? -1 : nameA > nameB ? 1 : 0;
      });
    }

    this.availableOptions = availableValues || [];
    this._selectedOption = this.buildSelectedOption();
    this.checkCurrentValueValidity();

    if (this.availableOptions.length > 0 && this.requestFocus) {
      this.openAutocompleteSelectField();
      this.requestFocus = false;
    }
  }

  protected initialize() {
    super.initialize();
    this.loadValues();
  }

  private loadValues() {
    const { allowedValues } = this.schema;
    if (Array.isArray(allowedValues)) {
      this.setValues(allowedValues);
    } else if (this.schema.allowedValues) {
      return this.schema.allowedValues.$load().then((values:CollectionResource) => {
        // The select options of the project shall be sorted
        if (values.count > 0 && (values.elements[0] as any)._type === 'Project') {
          this.setValues(values.elements, true);
        } else {
          this.setValues(values.elements);
        }
      });
    } else {
      this.setValues([]);
    }
    return Promise.resolve();
  }

  private checkCurrentValueValidity() {
    if (this.value) {
      this.currentValueInvalid = !!(
        // (If value AND)
        // MultiSelect AND there is no value which href is not in the options hrefs
        (!_.some(this.value, (value:HalResource) => _.some(this.availableOptions, (option) => (option.href === value.href))))
      );
    } else {
      // If no value but required
      this.currentValueInvalid = !!this.schema.required;
    }
  }
}