ilios/frontend

View on GitHub
packages/frontend/app/components/bulk-new-users.js

Summary

Maintainability
B
4 hrs
Test Coverage
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { getOwner } from '@ember/application';
import { action } from '@ember/object';
import CoreObject from '@ember/object/core';
import { service } from '@ember/service';
import { isPresent } from '@ember/utils';
import { all, filter } from 'rsvp';
import { dropTask, restartableTask } from 'ember-concurrency';
import PapaParse from 'papaparse';
import { DateTime } from 'luxon';
import { validatable, Length, NotBlank, IsEmail, Custom } from 'ilios-common/decorators/validation';
import { findById, mapBy } from 'ilios-common/utils/array-helpers';
import { TrackedAsyncData } from 'ember-async-data';
import { cached } from '@glimmer/tracking';

export default class BulkNewUsersComponent extends Component {
  @service flashMessages;
  @service iliosConfig;
  @service intl;
  @service store;
  @service currentUser;
  @service permissionChecker;
  @service dataLoader;

  @tracked file = null;
  @tracked fileUploadError = false;
  @tracked nonStudentMode = true;
  @tracked schoolId;
  @tracked primaryCohortId;
  @tracked proposedUsers = [];
  @tracked savedUserIds = [];
  @tracked savingAuthenticationErrors = [];
  @tracked savingUserErrors = [];
  @tracked selectedUsers = [];
  @tracked validUsers = [];

  userModel = new TrackedAsyncData(this.currentUser.getModel());
  get primarySchool() {
    return findById(this.schoolData.value, this.userModel.value.belongsTo('school').id());
  }

  @cached
  get schoolData() {
    return new TrackedAsyncData(this.dataLoader.loadSchoolsForLearnerGroups());
  }

  @cached
  get schoolsWithPermissionData() {
    return new TrackedAsyncData(
      filter(this.data.schools, async (school) => {
        return this.permissionChecker.canCreateUser(school);
      }),
    );
  }

  get isLoading() {
    return (
      this.userModel.isPending ||
      this.schoolData.isPending ||
      this.schoolsWithPermissionData.isPending
    );
  }

  @cached
  get data() {
    return {
      schools: this.store.peekAll('school'),
      programs: this.store.peekAll('program'),
      programYears: this.store.peekAll('programYear'),
      cohorts: this.store.peekAll('cohort'),
    };
  }

  @cached
  get programs() {
    return this.data.programs.filter(
      (program) => program.belongsTo('school').id() === this.bestSelectedSchool.id,
    );
  }

  @cached
  get programYears() {
    const programIds = this.programs.map(({ id }) => id);

    return this.data.programYears.filter((programYear) =>
      programIds.includes(programYear.belongsTo('program').id()),
    );
  }

  @cached
  get schoolCohorts() {
    const programYearIds = this.programYears.map(({ id }) => id);

    return this.data.cohorts.filter((cohort) =>
      programYearIds.includes(cohort.belongsTo('programYear').id()),
    );
  }

  get cohorts() {
    const cohortsWithData = this.schoolCohorts.map((cohort) => {
      const programYear = findById(this.data.programYears, cohort.belongsTo('programYear').id());
      const program = findById(this.data.programs, programYear.belongsTo('program').id());
      return {
        id: cohort.id,
        model: cohort,
        title: program.title + ' ' + cohort.title,
        startYear: Number(programYear.startYear),
        duration: Number(program.duration),
      };
    });

    const lastYear = DateTime.now().minus({ year: 1 }).year;
    return cohortsWithData.filter((obj) => {
      const finalYear = obj.startYear + obj.duration;
      return finalYear > lastYear;
    });
  }

  get sampleData() {
    const sampleUploadFields = [
      'First',
      'Last',
      'Middle',
      'Phone',
      'Email',
      'CampusID',
      'OtherID',
      'Username',
      'Password',
    ];
    const str = sampleUploadFields.join('\t');
    return window.btoa(str);
  }

  get bestSelectedSchool() {
    if (this.schoolId) {
      const currentSchool = findById(this.data.schools, this.schoolId);

      if (currentSchool) {
        return currentSchool;
      }
    }
    return this.primarySchool;
  }

  get bestSelectedCohort() {
    if (this.primaryCohortId) {
      const currentCohort = findById(this.data.cohorts, this.primaryCohortId);

      if (currentCohort) {
        return currentCohort;
      }
    }

    return this.cohorts.reverse()[0];
  }

  @action
  toggleUserSelection(obj) {
    if (this.selectedUsers.includes(obj)) {
      this.selectedUsers = this.selectedUsers.filter((user) => user !== obj);
    } else {
      this.selectedUsers.push(obj);
    }
  }

  @action
  setPrimaryCohort(id) {
    this.primaryCohortId = id;
  }

  async existingUsernames() {
    const authentications = await this.store.findAll('authentication');
    return mapBy(authentications.slice(), 'username').filter(Boolean);
  }

  /**
   * Extract the contents of a file into an array of user like objects
   * @param {Object} file
   *
   * @return array
   **/
  async getFileContents(file) {
    this.fileUploadError = false;
    return new Promise((resolve) => {
      const allowedFileTypes = ['text/plain', 'text/csv', 'text/tab-separated-values'];
      if (!allowedFileTypes.includes(file.type)) {
        const intl = this.intl;
        this.set('fileUploadError', true);
        throw new Error(intl.t('general.fileTypeError', { fileType: file.type }));
      }
      const complete = ({ data }) => {
        const proposedUsers = data.map((arr) => {
          return ProposedUser.create(getOwner(this).ownerInjection(), {
            firstName: isPresent(arr[0]) ? arr[0] : null,
            lastName: isPresent(arr[1]) ? arr[1] : null,
            middleName: isPresent(arr[2]) ? arr[2] : null,
            phone: isPresent(arr[3]) ? arr[3] : null,
            email: isPresent(arr[4]) ? arr[4] : null,
            campusId: isPresent(arr[5]) ? arr[5] : null,
            otherId: isPresent(arr[6]) ? arr[6] : null,
            username: isPresent(arr[7]) ? arr[7] : null,
            password: isPresent(arr[8]) ? arr[8] : null,
          });
        });
        const notHeaderRow = proposedUsers.filter(
          (obj) =>
            String(obj.firstName).toLowerCase() !== 'first' ||
            String(obj.lastName).toLowerCase() !== 'last',
        );
        resolve(notHeaderRow);
      };

      PapaParse.parse(file, {
        complete,
      });
    });
  }

  updateSelectedFile = restartableTask(async (files) => {
    // Check for the various File API support.
    if (window.File && window.FileReader && window.FileList && window.Blob) {
      if (files.length > 0) {
        await this.parseFile.perform(files[0]);
      }
    } else {
      throw new Error(this.intl.t('general.unsupportedBrowserFailure'));
    }
  });

  setSchool = restartableTask(async (id) => {
    this.schoolId = id;
  });

  parseFile = restartableTask(async (file) => {
    const proposedUsers = await this.getFileContents(file);
    const existingUsernames = await this.existingUsernames();
    const filledOutUsers = proposedUsers.map((obj) => {
      obj.existingUsernames = existingUsernames;

      return obj;
    });
    this.validUsers = await filter(filledOutUsers, async (obj) => {
      return await obj.isValid();
    });

    this.selectedUsers = this.validUsers;
    this.proposedUsers = filledOutUsers;
  });

  save = dropTask(async () => {
    this.savedUserIds = [];
    const nonStudentMode = this.nonStudentMode;
    const selectedSchool = this.bestSelectedSchool;
    const selectedCohort = this.bestSelectedCohort;
    const roles = await this.store.findAll('user-role');
    const studentRole = findById(roles.slice(), '4');

    const proposedUsers = this.selectedUsers;

    const validUsers = await filter(proposedUsers, async (obj) => {
      return obj.isValid();
    });

    const records = validUsers.map((userInput) => {
      const {
        firstName,
        lastName,
        middleName,
        phone,
        email,
        campusId,
        otherId,
        addedViaIlios,
        enabled,
        username,
        password,
      } = userInput;
      const user = this.store.createRecord('user', {
        firstName,
        lastName,
        middleName,
        phone,
        email,
        campusId,
        otherId,
        addedViaIlios,
        enabled,
      });
      user.set('school', selectedSchool);

      if (!nonStudentMode) {
        user.set('primaryCohort', selectedCohort?.cohortModel);
        user.set('roles', [studentRole]);
      }

      let authentication = false;
      if (userInput.username) {
        authentication = this.store.createRecord('authentication', { username, password });
        authentication.set('user', user);
      }

      const rhett = { user, userInput };
      if (authentication) {
        rhett.authentication = authentication;
      }

      return rhett;
    });
    let parts;
    while (records.length > 0) {
      try {
        parts = records.splice(0, 10);
        const users = mapBy(parts, 'user');
        await all(users.map((user) => user.save()));
        const authentications = mapBy(parts, 'authentication');
        await all(authentications.map((auth) => auth.save()));
      } catch (e) {
        const userErrors = parts.filter((obj) => obj.user.get('isError'));
        const authenticationErrors = parts.filter(
          (obj) =>
            !userErrors.includes(obj) &&
            isPresent(obj.authentication) &&
            obj.authentication.get('isError'),
        );
        this.savingUserErrors = [...this.savingUserErrors, ...userErrors];
        this.savingAuthenticationErrors = [
          ...this.savingAuthenticationErrors,
          ...authenticationErrors,
        ];
      } finally {
        this.savedUserIds = [...this.savedUserIds, ...mapBy(mapBy(parts, 'user'), 'id')];
      }
    }

    if (this.savingUserErrors.length || this.savingAuthenticationErrors.length) {
      this.flashMessages.warning('general.newUsersCreatedWarning');
    } else {
      this.flashMessages.success('general.newUsersCreatedSuccessfully');
    }

    this.validUsers = [];
    this.selectedUsers = [];
    this.proposedUsers = [];
  });
}

@validatable
class ProposedUser extends CoreObject {
  @service intl;

  @Length(1, 50) @NotBlank() firstName;
  @Length(1, 20) middleName;
  @Length(1, 50) @NotBlank() lastName;
  @Length(1, 100) @Custom('validateUsernameCallback', 'validateUsernameMessageCallback') username;
  @Custom('validatePasswordCallback', 'validatePasswordMessageCallback') password;
  @Length(1, 16) campusId;
  @Length(1, 16) otherId;
  @Length(1, 100) @NotBlank() @IsEmail() email;
  @Length(1, 20) phone;
  addedViaIlios = true;
  enabled = true;

  constructor(data) {
    super(...arguments);
    this.firstName = data.firstName;
    this.lastName = data.lastName;
    this.middleName = data.middleName;
    this.phone = data.phone;
    this.email = data.email;
    this.campusId = data.campusId;
    this.otherId = data.otherId;
    this.username = data.username;
    this.password = data.password;
    this.addErrorDisplayForAllFields();
  }

  async validateUsernameCallback() {
    return !this.existingUsernames.includes(this.username);
  }

  validateUsernameMessageCallback() {
    return this.intl.t('errors.exclusion', { description: this.intl.t('general.username') });
  }

  async validatePasswordCallback() {
    if (!this.username) {
      return true;
    }
    const stringValue = String(this.password).trim();
    return Boolean(stringValue.length);
  }

  validatePasswordMessageCallback() {
    return this.intl.t('errors.blank', { description: this.intl.t('general.password') });
  }
}