elabftw/elabftw

View on GitHub
src/ts/show.ts

Summary

Maintainability
D
2 days
Test Coverage
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
import {
  getCheckedBoxes,
  getEntity,
  notif,
  permissionsToJson,
  reloadElements,
  reloadEntitiesShow,
  TomSelect,
} from './misc';
import { Action, Model } from './interfaces';
import 'bootstrap/js/src/modal.js';
import i18next from 'i18next';
import EntityClass from './Entity.class';
import FavTag from './FavTag.class';
import { Api } from './Apiv2.class';
import $ from 'jquery';

document.addEventListener('DOMContentLoaded', () => {
  if (!document.getElementById('info')) {
    return;
  }
  const about = document.getElementById('info').dataset;
  // only run in show mode or on search page (which is kinda show mode too)
  const pages = ['show', 'search'];
  if (!pages.includes(about.page)) {
    return;
  }

  const entity = getEntity();
  const EntityC = new EntityClass(entity.type);
  const FavTagC = new FavTag();
  const ApiC = new Api();

  // THE CHECKBOXES
  const nothingSelectedError = {
    'msg': i18next.t('nothing-selected'),
    'res': false,
  };

  // background color for selected entities
  const bgColor = '#c4f9ff';

  if (document.getElementById('favtagsPanel')) {
    document.getElementById('favtagsPanel').addEventListener('keyup', event => {
      const el = (event.target as HTMLInputElement);
      const query = el.value;
      if (el.matches('[data-action="favtags-search"]')) {
        // find all links that are endpoints
        document.querySelectorAll('[data-action="add-tag-filter"]').forEach(el => {
          // begin by showing all so they don't stay hidden
          el.removeAttribute('hidden');
          // now simply hide the ones that don't match the query
          if (!(el as HTMLElement).innerText.toLowerCase().includes(query)) {
            el.setAttribute('hidden', '');
          }
        });
      }
    });
  }

  // get query param value as number
  function getParamNum(param: string): number {
    const params = new URLSearchParams(document.location.search);
    let val = params.get(param);
    if (!val) {
      val = '0';
    }
    return parseInt(val, 10);
  }

  /////////////////////////////////////////
  // CHANGE LISTENER FOR SELECT ELEMENTS //
  // The select elements don't use a click event because on firefox the click is triggered on the option
  // and on chrome it is on the select instead
  /////////////////////////////////////////
  document.getElementById('container').addEventListener('change', event => {
    const el = (event.target as HTMLSelectElement);
    // EXPORT SELECTED
    if (el.matches('[data-action="export-selected-entities"]')) {
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }
      const format = el.value;
      // reset selection so button can be used again with same format
      el.selectedIndex = 0;
      window.location.href = `make.php?format=${format}&type=${about.type}&id=${checked.map(value => value.id).join('+')}`;

    // UPDATE CATEGORY OR STATUS
    } else if (el.matches('[data-action="update-catstat-selected-entities"]')) {
      const ajaxs = [];
      // get the item id of all checked boxes
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }
      // loop on it and update the status/item type
      checked.forEach(chk => {
        const params = {};
        params[el.dataset.target] = el.value;
        ajaxs.push(ApiC.patch(`${about.type}/${chk.id}`, params));
      });
      // reload the page once it's done
      // a simple reload would not work here
      // we need to use when/then
      $.when.apply(null, ajaxs).then(function() {
        reloadEntitiesShow();
      });
      notif({'msg': 'Saved', 'res': true});

    // UPDATE VISIBILITY
    } else if (el.matches('[data-action="update-visibility-selected-entities"]')) {
      const ajaxs = [];
      // get the item id of all checked boxes
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }
      // loop on it and update the status/item type
      checked.forEach(chk => {
        ajaxs.push(ApiC.patch(`${about.type}/${chk.id}`, {'canread': permissionsToJson(parseInt(el.value, 10), [])}));
      });
      // reload the page once it's done
      // a simple reload would not work here
      // we need to use when/then
      $.when.apply(null, ajaxs).then(function() {
        reloadEntitiesShow();
      });
      notif({'msg': 'Saved', 'res': true});
    }
  });

  // the "load more" button triggers a reloading of div#showModeContent
  // so we keep track of the expanded and selected entities
  function getExpandedAndSelectedEntities(): void {
    const expanded = (document.querySelector('[data-action="expand-all-entities"]') as HTMLLinkElement).dataset.status === 'opened';
    const expendedEntities: string[] = [];
    const selectedEntities: string[] = [];
    document.querySelectorAll('[data-action="checkbox-entity"]').forEach((item: HTMLInputElement) => {
      if (item.checked) {
        selectedEntities.push(item.dataset.id);
      }
      if (!document.getElementById(item.dataset.randomid).hidden) {
        expendedEntities.push(item.dataset.id);
      }
    });
    document.getElementById('showModeContent').dataset.expandedAndSelectedEntities = JSON.stringify({expanded, selectedEntities, expendedEntities});
  }

  function setExpandedAndSelectedEntities(): void {
    const state = JSON.parse(document.getElementById('showModeContent').dataset.expandedAndSelectedEntities);
    if (state.expanded) {
      const linkEl = document.querySelector('[data-action="expand-all-entities"]') as HTMLLinkElement;
      linkEl.dataset.status = 'opened';
      linkEl.textContent = linkEl.dataset.collapse;
      document.querySelectorAll('[data-action="toggle-body"]').forEach((toggleButton: HTMLButtonElement) => {
        toggleButton.click();
      });
    }
    if (state.selectedEntities.length > 0) {
      document.getElementById('withSelected').classList.remove('d-none');
    }
    document.querySelectorAll('[data-action="checkbox-entity"]').forEach((item: HTMLInputElement) => {
      if (state.selectedEntities.includes(item.dataset.id)) {
        item.click();
      }
      if (!state.expanded && state.expendedEntities.includes(item.dataset.id)) {
        (document.querySelector(`[data-action="toggle-body"][data-id="${item.dataset.id}"]`) as HTMLButtonElement).click();
      }
    });
  }

  /////////////////////////
  // MAIN CLICK LISTENER //
  /////////////////////////
  document.getElementById('container').addEventListener('click', event => {
    const el = (event.target as HTMLElement);
    const params = new URLSearchParams(document.location.search);
    // LOAD MORE
    if (el.matches('[data-action="load-more"]')) {
      // we keep track of the expanded and selected entities
      getExpandedAndSelectedEntities();
      // NOTE: in an ideal world, we can request the delta elements in json via api and inject them in page
      // this would avoid having to re-query all items every time, especially after a few clicks where limit is a few hundreds, might bring strain on mysql servers
      // so here the strategy is simply to increase the "limit" to show more stuff

      // we want to know if the newly applied limit actually brought new items
      // because if not, we disable the button
      // so simply count them
      const previousNumber = document.querySelectorAll('.entity').length;
      // this will be 0 if the button has not been clicked yet
      const queryLimit = getParamNum('limit');
      const usualLimit = parseInt(about.limit, 10);
      let newLimit = queryLimit + usualLimit;
      // handle edge case for first click
      if (queryLimit < usualLimit) {
        newLimit = usualLimit * 2;
      }
      params.set('limit', String(newLimit));
      history.replaceState(null, '', `?${params.toString()}`);
      reloadEntitiesShow().then(() => {
        // expand and select what was expanded and selected
        setExpandedAndSelectedEntities();
        // remove Load more button if no new entries appeared
        const newNumber = document.querySelectorAll('.entity').length;
        if (previousNumber === newNumber) {
          document.getElementById('loadMoreBtn').remove();
        }
      });

    // TOGGLE FAVTAGS PANEL
    } else if (el.matches('[data-action="toggle-favtags"]')) {
      FavTagC.toggle();

    // TOGGLE DISPLAY
    } else if (el.matches('[data-action="toggle-items-layout"]')) {
      ApiC.notifOnSaved = false;
      ApiC.getJson(`${Model.User}/me`).then(json => {
        let target = 'it';
        if (json['display_mode'] === 'it') {
          target = 'tb';
        }
        ApiC.patch(`${Model.User}/me`, {'display_mode': target}).then(() => {
          reloadEntitiesShow();
        });
      });

    // a tag has been clicked/selected, add it in url and load the page
    } else if (el.matches('[data-action="add-tag-filter"]')) {
      params.set('tags[]', el.dataset.tag);
      // clear out any offset from a previous query
      params.delete('offset');
      history.replaceState(null, '', `?${params.toString()}`);
      document.querySelectorAll('[data-action="add-tag-filter"]').forEach(el => {
        el.classList.remove('selected');
      });
      el.classList.add('selected');
      reloadEntitiesShow(el.dataset.tag);

    // TOGGLE PIN
    } else if (el.matches('[data-action="toggle-pin"]')) {
      ApiC.patch(`${entity.type}/${parseInt(el.dataset.id, 10)}`, {'action': Action.Pin}).then(() => el.closest('.entity').remove());

    // remove a favtag
    } else if (el.matches('[data-action="destroy-favtags"]')) {
      FavTagC.destroy(parseInt(el.dataset.id, 10)).then(() => reloadElements(['favtagsTagsDiv']));

    // SORT COLUMN IN TABULAR MODE
    } else if (el.matches('[data-action="reorder-entities"]')) {
      const params = new URLSearchParams(document.location.search);
      let sort = 'desc';
      if (params.get('order') === el.dataset.orderby
        && params.get('sort') === 'desc'
      ) {
        sort = 'asc';
      }
      params.set('sort', sort);
      params.set('order', el.dataset.orderby);
      window.location.href = `?${params.toString()}`;

    // CHECK AN ENTITY BOX
    } else if (el.matches('[data-action="checkbox-entity"]')) {
      ['advancedSelectOptions', 'withSelected'].forEach(id => {
        const el = document.getElementById(id);
        const scroll = el.classList.contains('d-none');
        el.classList.remove('d-none');
        if (id === 'withSelected' && scroll && el.getBoundingClientRect().bottom > 0) {
          window.scrollBy({top: el.offsetHeight, behavior: 'instant'});
        }
      });
      if ((el as HTMLInputElement).checked) {
        (el.closest('.entity') as HTMLElement).style.backgroundColor = bgColor;
      } else {
        (el.closest('.entity') as HTMLElement).style.backgroundColor = '';
      }

    // EXPAND ALL
    } else if (el.matches('[data-action="expand-all-entities"]')) {
      event.preventDefault();
      if (el.dataset.status === 'closed') {
        el.dataset.status = 'opened';
        el.innerText = el.dataset.collapse;
      } else {
        el.dataset.status = 'closed';
        el.innerText = el.dataset.expand;
      }
      const status = el.dataset.status;
      document.querySelectorAll('[data-action="toggle-body"]').forEach((toggleButton: HTMLElement) => {
        const isHidden = document.getElementById(toggleButton.dataset.randid).hidden;
        if ((status === 'opened' && !isHidden)
          || (status === 'closed' && isHidden)
        ) {
          return;
        }
        toggleButton.click();
      });

    // SELECT ALL CHECKBOXES
    } else if (el.matches('[data-action="select-all-entities"]')) {
      event.preventDefault();
      // check all boxes and set background color
      document.querySelectorAll('.entity input[type=checkbox]').forEach(box => {
        (box as HTMLInputElement).checked = true;
        (box.closest('.entity') as HTMLElement).style.backgroundColor = bgColor;
      });
      // show advanced options and withSelected menu
      ['advancedSelectOptions', 'withSelected'].forEach(id => {
        document.getElementById(id).classList.remove('d-none');
      });

    // UNSELECT ALL CHECKBOXES
    } else if (el.matches('[data-action="unselect-all-entities"]')) {
      event.preventDefault();
      document.querySelectorAll('.entity input[type=checkbox]').forEach(box => {
        (box as HTMLInputElement).checked = false;
        (box.closest('.entity') as HTMLElement).style.backgroundColor = '';
      });
      // hide menu
      ['advancedSelectOptions', 'withSelected'].forEach(id => {
        document.getElementById(id).classList.add('d-none');
      });

    // INVERT SELECTION
    } else if (el.matches('[data-action="invert-entities-selection"]')) {
      event.preventDefault();
      document.querySelectorAll('.entity input[type=checkbox]').forEach(box => {
        (box as HTMLInputElement).checked = !(box as HTMLInputElement).checked;
        let newBgColor = '';
        if ((box as HTMLInputElement).checked) {
          newBgColor = bgColor;
        }
        (box.closest('.entity') as HTMLElement).style.backgroundColor = newBgColor;
      });


    // THE LOCK BUTTON FOR CHECKED BOXES
    } else if (el.matches('[data-action="lock-selected-entities"]')) {
      // get the item id of all checked boxes
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }

      // loop over it and lock entities
      const results = [];
      checked.forEach(chk => {
        results.push(EntityC.patchAction(chk.id, Action.Lock));
      });

      Promise.all(results).then(() => {
        reloadEntitiesShow();
      });


    // THE TIMESTAMP BUTTON FOR CHECKED BOXES
    } else if (el.matches('[data-action="timestamp-selected-entities"]')) {
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }
      // loop on it and timestamp it
      checked.forEach(chk => {
        EntityC.patchAction(chk.id, Action.Timestamp).then(() => reloadEntitiesShow());
      });

    // THE ARCHIVE BUTTON FOR CHECKED BOXES
    } else if (el.matches('[data-action="archive-selected-entities"]')) {
      // get the item id of all checked boxes
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }

      // loop over it and lock entities
      const results = [];
      checked.forEach(chk => {
        results.push(EntityC.patchAction(chk.id, Action.Archive));
      });

      Promise.all(results).then(() => {
        reloadEntitiesShow();
      });

    // THE DELETE BUTTON FOR CHECKED BOXES
    } else if (el.matches('[data-action="destroy-selected-entities"]')) {
      // get the item id of all checked boxes
      const checked = getCheckedBoxes();
      if (checked.length === 0) {
        notif(nothingSelectedError);
        return;
      }
      // ask for confirmation
      if (!confirm(i18next.t('generic-delete-warning'))) {
        return;
      }
      // loop on it and delete stuff (use curly braces to avoid implicit return)
      checked.forEach(chk => {EntityC.destroy(chk.id).then(() => document.getElementById(`parent_${chk.randomid}`).remove());});
    }
  });

  // we don't want the favtags opener on search page
  // when a search is done, about.page will be show
  // so check for the type param in url that will be present on search page
  const params = new URLSearchParams(document.location.search.slice(1));
  if (!params.get('type')) {
    document.getElementById('sidepanel-buttons').removeAttribute('hidden');
  }

  // FAVTAGS PANEL
  if (localStorage.getItem('isfavtagsOpen') === '1') {
    FavTagC.toggle();
  }

  new TomSelect('#tagFilter', {
    plugins: {
      checkbox_options: {
        checkedClassNames: ['ts-checked'],
        uncheckedClassNames: ['ts-unchecked'],
      },
      clear_button: {},
      dropdown_input: {},
      no_active_items: {},
      remove_button: {},
    },
  });
});