Opetushallitus/eperusteet-frontend-utils

View on GitHub
vue/src/components/EpEditointi/EditointiStore.ts

Summary

Maintainability
D
2 days
Test Coverage
import * as _ from 'lodash';
import Vue from 'vue';
import VueScrollTo from 'vue-scrollto';
import { reactive, computed } from '@vue/composition-api';
import { Computed } from '../../utils/interfaces';
import { ILukko, Revision } from '../../tyypit';
import VueRouter, { RawLocation } from 'vue-router';
import { fail } from '../../utils/notifications';
import { createLogger } from '../../utils/logger';

export interface EditointiKontrolliValidation {
  valid: boolean;
  message?: string;
}

export interface KayttajaProvider {
  userOid: Computed<string>;
}

export interface EditoitavaFeatures {
  editable?: boolean;
  previewable?: boolean;
  removable?: boolean;
  lockable?: boolean;
  validated?: boolean;
  recoverable?: boolean;
  hideable?: boolean;
  isHidden?: boolean;
  copyable?: boolean;
}

export interface IEditoitava {
  /**
   * Loads most recent version of the data to be edited
   */
  load: (supportDataProvider: (data: any) => void) => Promise<unknown>;

  /**
   * Try to acquire lock. Return true on success.
   */
  acquire?: () => Promise<ILukko | null>;

  /**
   * Release locked resource.
   */
  release?: () => Promise<void>;

  /**
   * Get current lock information if any
   */
  lock?: () => Promise<ILukko | null>;

  /**
   * Start editing of the resource
   */
  start?: () => Promise<void>;

  /**
   * Called right after user cancels editing.
   */
  cancel?: () => Promise<void>;

  /**
   * Save current resource
   */
  save?: (data: any) => Promise<any>;

  /**
   * Remove the resource
   */
  remove?: (data?: any) => Promise<void>;

  /**
   * Returns true if editing is started immediately after data fetch
   */
  editAfterLoad: () => Promise<boolean>;

  /**
   * called after load
   */
  postLoad?: () => Promise<void>;

  /**
   * Get preview url location
   */
  preview?: () => Promise<RawLocation | null>;

  /**
   * Hide the resource
   */
  hide?: (data: any) => Promise<void>;

  /**
   * Unhide the resource
   */
  unHide?: (data: any) => Promise<void>;

  /**
   * Replace current data with restored revision
   */
  restore?: (rev: number) => Promise<void>;

  /**
   * Get all revisions of the resource
   */
  revisions?: () => Promise<Revision[]>;

  /**
   * copy the resource
   */
  copy?: (data: any) => Promise<boolean> | Promise<void>;

  /**
   * Save preventing validations
   */
  validator?: Computed<any>;

  /**
   * Dynamic features that are enabled
   */
  features?: (data: Computed<any>, supportData: Computed<any>) => Computed<EditoitavaFeatures>;
}

export interface EditointiKontrolliRestore {
  numero: number;
  modal?: any;
  routePushLatest?: boolean;
}

export interface EditointiStoreConfig {
  router: VueRouter;
  kayttajaProvider: KayttajaProvider;
}

export class EditointiStore {
  private static allEditingEditors: EditointiStore[] = [];
  private static router: VueRouter;
  private static kayttajaProvider: KayttajaProvider;
  private logger = createLogger(EditointiStore);
  private isFirstRun = true;

  public static install(
    vue: typeof Vue,
    config: EditointiStoreConfig,
  ) {
    if (!config.router) {
      throw new Error('vue-router missing');
    }
    if (!config.kayttajaProvider) {
      throw new Error('kayttajaProvider missing');
    }
    EditointiStore.router = config.router;
    EditointiStore.kayttajaProvider = config.kayttajaProvider;
  }

  private readonly state = reactive({
    data: null as any | null,
    supportData: null as any | null,
    revisions: [] as Revision[],
    backup: null as string | null,
    disabled: true,
    isLoading: false,
    isSaving: false,
    isEditingState: false,
    isRemoved: false,
    isNew: false,
    currentLock: null as ILukko | null,
  });

  public static anyEditing() {
    return EditointiStore.allEditingEditors.length > 0;
  }

  public static async cancelAll() {
    for (const editor of EditointiStore.allEditingEditors) {
      if (editor.cancel) {
        await editor.cancel(true);
      }
    }
  }

  public constructor(
    private config: IEditoitava,
  ) {
    this.logger.debug('Initing editointikontrollit with: ', _.keys(config));
    this.config = config;
  }

  public get hooks() {
    return this.config;
  }

  public readonly data = computed(() => this.state.data);
  public readonly supportData = computed(() => this.state.supportData);
  public readonly revisions = computed(() => this.state.revisions);
  public readonly disabled = computed(() => this.state.disabled);
  public readonly isLoading = computed(() => !this.state.data || this.state.isLoading);
  public readonly isSaving = computed(() => this.state.isSaving);
  public readonly isEditing = computed(() => this.state.isEditingState);
  public readonly isRemoved = computed(() => this.state.isRemoved);
  public readonly validator = computed(() => this.config.validator?.value || {});
  public readonly isNew = computed(() => this.state.isNew);

  public readonly features = computed(() => {
    const Default = {
      editable: true,
      hideable: true,
      isHidden: false,
      lockable: true,
      previewable: false,
      recoverable: true,
      removable: true,
      validated: true,
      copyable: false,
    };

    const provided = this.config.features ? this.config.features(this.data.value, this.supportData.value).value : Default;
    const features = {
      ...Default,
      ...provided,
    };

    const cfg = this.config || {};
    return {
      editable: cfg.save && features.editable,
      hideable: cfg.hide && features.hideable,
      isHidden: features.isHidden || false,
      lockable: cfg.lock && cfg.release && features.lockable,
      recoverable: cfg.restore && cfg.revisions && features.recoverable,
      removable: cfg.remove && features.removable,
      validated: cfg.validator && features.validated,
      previewable: features.previewable || false,
      copyable: features.copyable || false,
    };
  });

  public readonly currentLock = computed(() => {
    const now = new Date();
    const cl = this.state.currentLock;
    if (cl?.oma || (cl?.vanhentuu && now > new Date(cl.vanhentuu as unknown as number))) {
      return null;
    }
    return this.state.currentLock;
  });

  public get hasPreview() {
    return !!this.config.preview;
  }

  public async updateRevisions() {
    if (this.config.revisions) {
      this.logger.debug('Haetaan historia');
      this.state.revisions = await this.config.revisions();
    }
  }

  public async updateLockInfo() {
    if (this.config.lock) {
      this.logger.debug('Haetaan mahdollinen lukko');
      this.state.currentLock = await this.config.lock();
    }
  }

  public async init() {
    this.logger.debug('init');
    this.state.isNew = !!(this.config.editAfterLoad && await this.config.editAfterLoad());
    await this.fetch();
    await this.updateRevisions();
    await this.updateLockInfo();
    this.state.disabled = false;

    if (this.state.isNew && this.isFirstRun) {
      this.isFirstRun = false;
      await this.start();
    }

    if (this.config.postLoad) {
      await this.config.postLoad();
    }
  }

  public async start() {
    this.state.disabled = true;
    this.state.isLoading = true;

    // Ei editointia uudestaan
    if (this.isEditing.value) {
      this.logger.warn('Editointi jo käynnissä');
      this.state.disabled = false;
      return;
    }

    // Poiston jälkeisen editoinnin esto
    if (this.state.isRemoved) {
      this.logger.warn('Poistettua resurssia ei voi editoida');
      this.state.disabled = false;
      return;
    }

    try {
      this.logger.debug('Aloitetaan editointi');
      if (!this.state.isNew) {
        await this.init();
      }
      this.state.isEditingState = true;

      await this.lock();

      if (this.config.start) {
        await this.config.start();
      }
      EditointiStore.allEditingEditors = [
        ...EditointiStore.allEditingEditors,
        this,
      ];
    }
    catch (err) {
      this.logger.error('Editoinnin aloitus epäonnistui:', err);
      this.state.currentLock = null;
    }
    finally {
      this.state.disabled = false;
      this.state.isLoading = false;

      const navbar = document.getElementById('navigation-bar');
      const navbarHeight = navbar ? (-1 * navbar.getBoundingClientRect().height) : 0;
      const target = document.getElementById('scroll-anchor');
      if (target) {
        VueScrollTo.scrollTo('#scroll-anchor', {
          offset: navbarHeight,
          x: false,
          y: true,
        });
      }
    }
  }

  public async lock() {
    // Resurssin lukitseminen
    if (this.config.acquire) {
      this.logger.debug('Lukitaan resurssi');
      this.state.currentLock = await this.config.acquire();
    }
  }

  public async unlock() {
    // Resurssin lukitseminen
    if (this.config.release) {
      this.logger.debug('Vapautetaan resurssi');
      try {
        await this.config.release();
      }
      finally {
        this.state.currentLock = null;
      }
    }
  }

  public async cancel(skipRedirectBack = false) {
    this.state.disabled = true;
    if (!this.isEditing.value) {
      this.logger.warn('Ei voi perua');
      return;
    }

    this.logger.debug('Perutaan editointi');
    if (this.config.cancel) {
      await this.config.cancel!();
    }

    if (this.state.backup) {
      this.state.data = JSON.parse(this.state.backup);
    }
    // this.config.setData!(JSON.parse(this.state.backup));
    this.state.isEditingState = false;
    _.remove(EditointiStore.allEditingEditors, (editor) => editor === this);
    this.state.disabled = false;

    await this.unlock();

    if (this.state.isNew && !skipRedirectBack) {
      EditointiStore.router?.go(-1);
    }
  }

  public async remove() {
    this.state.disabled = true;
    this.state.isEditingState = false;
    _.remove(EditointiStore.allEditingEditors, (editor) => editor === this);
    try {
      if (this.config.remove) {
        await this.config.remove(this.state.data);
        this.logger.debug('Poistettu');
        this.state.isRemoved = true;
      }
    }
    catch (err: any) {
      this.state.isRemoved = false;
      throw err;
    }
    this.state.disabled = false;
  }

  public async save() {
    this.state.disabled = true;
    this.state.isSaving = true;

    if (!this.isEditing.value) {
      this.logger.warn('Ei voi tallentaa ilman editointia');
    }
    else if (this.config.save) {
      try {
        const after = await this.config.save(this.state.data);
        this.logger.success('Tallennettu onnistuneesti');
        await this.unlock();
        await this.fetchRevisions();
        await this.init();
        this.state.isEditingState = false;
        _.remove(EditointiStore.allEditingEditors, (editor) => editor === this);
        if (after && _.isFunction(after)) {
          await after();
        }
      }
      catch (err) {
        this.state.isEditingState = true;
        this.state.disabled = false;
        this.state.isSaving = false;
        throw err;
      }
    }
    else {
      this.logger.debug('Tallentaminen ei mahdollista');
    }
    this.state.disabled = false;
    this.state.isSaving = false;
  }

  public async restore(event: EditointiKontrolliRestore) {
    try {
      await this.config.restore!(event.numero);
      this.logger.success('Palautettu onnistuneesti');

      // Piilotetaan modaali
      if (event.modal && _.isFunction(event.modal.hide)) {
        event.modal.hide();
      }

      // Päivitetään näkymä uusimpaan
      if (event.routePushLatest) {
        await EditointiStore.router?.push({ query: {} });
      }

      await this.fetch();
      await this.fetchRevisions();
    }
    catch (err: any) {
      const syy = _.get(err, 'response.data.syy');
      if (syy) {
        fail('palautus-epaonnistui', err.response.data.syy);
      }
      else {
        this.logger.error('Palautus epäonnistui', err);
        fail('palautus-epaonnistui');
      }
    }
  }

  public async preview() {
    if (this.config.preview) {
      return this.config.preview();
    }
    return null;
  }

  private async fetchRevisions() {
    if (this.config.revisions) {
      this.state.revisions = await this.config.revisions();
    }
  }

  private async fetch() {
    this.state.backup = null;

    const data = await this.config.load((data: any) => {
      this.state.supportData = data;
    });

    if (_.isObject(data) || _.isArray(data)) {
      const dataStr = JSON.stringify(data);
      this.state.backup = dataStr;
      this.state.data = JSON.parse(dataStr);
      return this.state.data;
    }
    else {
      throw new Error('Source must be an object or an array');
    }
  }

  public async clear() {
    this.state.data = null;
    this.state.supportData = null;
  }

  public async hide() {
    this.state.disabled = true;
    this.state.isEditingState = false;
    if (this.config.hide) {
      await this.config.hide(this.state.data);
      await this.init();
      this.logger.debug('Piilotettu');
      this.state.isRemoved = true;
    }
    this.state.disabled = false;
  }

  public async unHide() {
    this.state.disabled = true;
    this.state.isEditingState = false;
    if (this.config.unHide) {
      await this.config.unHide(this.state.data);
      await this.init();
      this.logger.debug('palautettu');
    }
    this.state.disabled = false;
  }

  public async copy() {
    this.state.isSaving = true;
    this.state.disabled = true;
    try {
      if (this.config.copy) {
        const doInit = await this.config.copy(this.state.data);
        this.logger.debug('Kopioitu');

        if (doInit) {
          await this.init();
        }
      }
    }
    finally {
      this.state.disabled = false;
      this.state.isSaving = false;
    }
  }

  public setData(data: any) {
    this.state.data = data;
  }

  public mergeData(data: any) {
    this.state.data = {
      ...this.state.data,
      ...data,
    };
  }
}

export function editointi(config: IEditoitava) {
  return new EditointiStore(config);
}