src/components/rename/Rename.js

import React, { Component, createRef } from 'react';
import { mapDispatchToProps, mapStateToProps } from 'state/dispatch';
import {
  missingFileText,
  missingNameText,
  notEnoughFilesWarning,
  seasonEpisodePrefix,
  tooManyFilesWarning
} from 'utils/constants';

import Controls from 'components/rename/Controls';
import Footer from 'components/footer/Footer';
import Header from 'components/header/Header';
import NameList from 'components/rename/NameList';
import Notice from 'components/dialog/Notice';
import PropTypes from 'prop-types';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import Toast from 'components/dialog/Toast';
import { connect } from 'react-redux';
import { renameFiles } from 'utils/services';
import styles from 'components/rename/scss/Rename.module.scss';

/**
 * @namespace Rename
 * @description - Rename screen of file renamer.
 *
 * @property {Object} state - Object containing Redux mapped props.
 * @property {Object} state.renameData - Object containing updated files and names.
 * @property {string|number} state.selectedFileIndex - represents the selected file index at any given moment.
 * @property {Function} setRenameData - Function to set the renameData object.
 *
 * @todo Refactor to better compartmentalize functionality to allow for better scalability
 * @todo Add remove characters feature
 */

class Rename extends Component {

  constructor(props) {
    super(props);
    this.scrollArea = createRef();
    this.directory = this.props.state.files.directory;
    this.episodes = this.props.state.tvShow.episodes;
    this.fileList = this.props.state.files.fileList;
    this.season = this.props.state.tvShow.season;

    this.state = {
      notice: { hide: true },
      prefix: '',
      showError: { show: false, message: '' },
      showWarning: { show: false, message: '' },
      showSuccess: { show: false, message: '' },
      suffix: ''
    };
  };


  componentDidMount() {
    this.resetFileList();
  };


  getSnapshotBeforeUpdate(prevProps) {

    // Line and scrollable area dimensions
    const lineHeight = 47, rows = 6;
    const scrollViewSize = lineHeight * rows;

    // Props to declarations
    const selection = this.props.state.options.selection;
    const prevSelection = prevProps.state.options.selection;
    const scrollAreaScrollTop = this.scrollArea.current.scrollTop;

    // Size of viewable area and position of selected & prev selected items
    const scrollView = scrollAreaScrollTop + scrollViewSize;
    const selectedPosition = selection * lineHeight;
    const prevSelectedPosition = prevSelection * lineHeight;

    // Determining if below or above viewable area
    const aboveScrollView = selectedPosition > scrollView;
    const belowScrollView = (selectedPosition <= scrollView - scrollViewSize) ||
      (selectedPosition === scrollView && prevSelectedPosition < selectedPosition);

    // If out of view, return selected item scroll top
    if (aboveScrollView || belowScrollView) {
      return selectedPosition;
    }

    // Resolves a few edge-cases
    else if(
      selectedPosition > scrollViewSize &&
      prevSelection === selection - 1 &&
      Number.isInteger(prevSelectedPosition / scrollViewSize) &&
      Number.isInteger(scrollAreaScrollTop / scrollViewSize)
    ) {
      return selectedPosition - lineHeight;
    }

    return null;
  };


  componentDidUpdate(prevProps, prevState, snapshot) {

    const files = this.props.state.options.renameData.files;
    const names = this.props.state.options.renameData.names;
    const noWarnings = !this.state.showWarning.show &&
      !this.state.showError.show &&
      !this.state.showSuccess.show;

    // If selected item scrollTop in snapshot, set it
    if (snapshot !== null)
      this.scrollArea.current.scrollTop = snapshot;

    // If the prefix or suffix changed, update the list
    if(
      prevState.prefix !== this.state.prefix ||
      prevState.suffix !== this.state.suffix
    )
      this.resetFileList(0, true);

    // If there are not enough files and the warning isn't showing, show it
    if(files.includes(missingFileText) && noWarnings)
      this.setShowToast('warning', true, notEnoughFilesWarning);

    // If all files are present and warning is showing, remove it
    else if(
      !files.includes(missingFileText) &&
      !names.includes(missingNameText) &&
      this.state.showWarning.show
    )
      this.setShowToast('warning', false);

    // Configuration for warning messages (e.g., not enough files or too many files)
    if(this.tooManyFiles(prevProps))
      this.setShowToast('warning', true, tooManyFilesWarning);

    else if(this.missingFiles(prevProps))
      this.setShowToast('warning', true, notEnoughFilesWarning);

  };

  // Custom back and/or next button functions passed to the footer
  customFunctions = {
    next: () => this.updateNotice(false)
  };

  resetFileList = (selection = 0, updating) => {
    const {
      episodes,
      fileList,
      props: { setRenameData },
      season,
      state: { prefix, suffix }
    } = this;

    // Determine whether to reset, or just updating (keeping 'No File' indexes)
    let files = updating ?
      this.props.state.options.renameData.files.reduce((acc, filename) => acc = [...acc, filename ], []) :
      fileList.reduce((acc, filename) => acc = [...acc, filename ], []);

    let names = episodes.filter(episode => episode.season === season).map( episode => {
      return `${seasonEpisodePrefix(season, episode.number)}${episode.name}`;
     });

    if(files.length < names.length) {
      const filesNoGaps = names.reduce((acc, item, index) =>
        acc = [ ...acc, files[index] || missingFileText], []);

      files = filesNoGaps;
    }

    else if(files.length > names.length) {

    }

    // Add extension from matching file
    names.forEach((name, index) => {

      // If file the name is matching has an extension
      if(
          /\.([a-zA-Z]|[0-9]){3,4}$/.test(files[index]) ||
          files[index].includes(missingFileText)
        ) {

        // Only provide extension if it's not a 'No File' or 'Too Many Files' index
        const extension = files[index].includes(missingFileText) || name.includes(missingNameText) ?
          '' : files[index].substring(files[index].lastIndexOf('.'));

        // Piece together rename index
        names[index] = `${prefix}${name}${suffix}${extension}`

         // Replace special (unusable in folder) characters
         .replace(/\\|\/|\|/g, '-')
         .replace(/:/g, ',')
         .replace(/"/g, "'")
         .replace(/\*|\?|<|>/g, '');
      }
    });

    // Set data to files and current season (including user-added prefix/suffix)
    setRenameData({ files, names }, selection);
  };


  resetUserMods = callback => this.setState({ prefix: '', suffix: '' }, callback || undefined);


  // Check if there's 'Too Many Files' text here now, and wasn't previously
  tooManyFiles = prevProps => {
    const currentFilesHaveText = this.props.state.options.renameData.names.includes(missingNameText);
    const prevFilesHaveText = prevProps.state.options.renameData.names.includes(missingNameText);
    return currentFilesHaveText && !prevFilesHaveText;
  };


  // Check if there's 'No File' text here now, and wasn't previously
  missingFiles = prevProps => {
    const currentNamesHaveText = this.props.state.options.renameData.files.includes(missingFileText);
    const prevNamesHaveText = prevProps.state.options.renameData.files.includes(missingFileText);
    return currentNamesHaveText && !prevNamesHaveText;
  };


  // Method to set or unset the page warnings
  setShowToast = (type, show, message, callback) => {
    const toastType = type === 'warning' ? 'showWarning' :
      type === 'error' ? 'showError' : 'showSuccess';

    if(!show)
      this.setState({ [toastType]: { show: false, message: '' }}, callback || undefined);

    else
      this.setState({ [toastType]: { show, message } }, callback || undefined);
  };

  updateNotice = (hide, callback) => this.setState({ notice: { hide } }, callback || undefined);

  // Method to update user added prefix
  updatePrefix = event => {
    this.setState({ prefix: event.target.value }, ()=> this.resetFileList(this.props.state.options.selection, true));
  };

  // Method to update user added suffix
  updateSuffix = event => {
    this.setState({ suffix: event.target.value }, ()=> this.resetFileList(this.props.state.options.selection, true));
  };

  render() {

    const {
      customFunctions,
      directory,
      resetFileList,
      resetUserMods,
      scrollArea,
      props: {
        setRenameData,
        state: {
          options: { renameData, selection: selectedFileIndex }
        }
      },
      setShowToast,
      state: {
        notice,
        prefix,
        showError,
        showWarning,
        showSuccess,
        suffix
      },
      updateNotice,
      updatePrefix,
      updateSuffix
    } = this;

    const shouldScrollY = renameData.files.length > 6 || renameData.names.length > 6;

    const listControlProps = {
      renameData,
      selectedFileIndex,
      setRenameData
    };

    const missingDataText = [
      missingFileText,
      missingNameText
    ];

    return (
      <section className={ styles.rename }>
        <Header
          title='Rename files'
          description='You can reorder or remove files until the names match up the way you want, you can also add a prefix and/or suffix to the new names.'
        />

        <div className={ styles.titles }>
          <h3>Current names</h3>
          <h3>New names</h3>
        </div>

        <div
          ref={ scrollArea }
          className={ shouldScrollY ? `${styles['name-display']} ${styles.scrollY}` : styles['name-display'] }
        >
          <NameList
            { ...listControlProps }
            list={ renameData.files }
            type='files'
          />

          <NameList
            { ...listControlProps }
            list={ renameData.names }
            type='names'
          />
        </div>

        <div className={ styles['controls-container'] }>
          <Controls
            { ...listControlProps }
            resetFileList={ resetFileList }
            resetUserMods={ resetUserMods }
          />
        </div>

        <div className={ styles['modifications-container']}>
          <TextField
            label='Prefix'
            onChange={ updatePrefix }
            placeholder='Add prefix to new names'
            value={ prefix }
          />

          <TextField
            label='Suffix'
            onChange={ updateSuffix }
            placeholder='Add suffix to new names'
            value={ suffix }
          />
        </div>

        <Toast
          messageBarType={
            showError.show ? 'error' :
            showWarning.show ? 'warning' :
            'success'
          }
          onDismiss={
            showError.show ? ()=> setShowToast('error', false) :
            showSuccess.show ? ()=> setShowToast('success', false) :
            showWarning.show ? null : null
          }
          show={
            showError.show ||
            showWarning.show ||
            showSuccess.show
          }
          type={
            showError.show ? 'error' :
            showWarning.show ? 'warning' :
            'success'
          }
        >
          {
            showError.show ? showError.message :
            showWarning.show ? showWarning.message :
            showSuccess.show ? showSuccess.message :
            undefined
          }
        </Toast>

        <Footer customFunctions={ customFunctions } />

        <Notice
          cancelFunc={ ()=> updateNotice(true) }
          messageText='Would you like to rename the files? This cannot be undone.'
          title='Rename files'
          okayFunc={ ()=> renameFiles(directory, renameData, missingDataText, (response) => {
              updateNotice(true,
                ()=> setShowToast(response.status, true, response.message,
                ()=> setRenameData({ ...renameData, files: response.files }, 0)
              ))
            })
          }
          hideDialog={ notice.hide }
          setHideDialog={ updateNotice }
        />
      </section>
    );
  };

};

Rename.propTypes = {
  setRenameData: PropTypes.func,
  state: PropTypes.object
};

export default connect(mapStateToProps, mapDispatchToProps)(Rename);