Browse Source

Merge pull request #1285 from weseek/imprv/import-exported-data

Imprv/import exported data
Yuki Takei 6 years ago
parent
commit
979f14fc67

+ 3 - 1
CHANGES.md

@@ -4,7 +4,9 @@
 
 * Feature: Upload to GCS (Google Cloud Storage)
 * Feature: Statistics API
-* Improvement: Export progress bar
+* Improvement: Optimize exporting
+* Improvement: Show progress bar when exporting
+* Improvement: Validate collection combinations when importing
 * Improvement: Reactify admin pages
 * Fix: Use HTTP PlantUML URL in default
     * Introduced by 3.5.12

+ 7 - 3
resource/locales/en-US/translation.json

@@ -753,12 +753,17 @@
     "import_form_growi": "Import from GROWI",
     "growi_settings": {
       "overwrite_documents": "Imported documents will overwrite existing documents",
-      "zip_file": "Zip File",
+      "zip_file": "Exported Zip File",
       "uploaded_data": "Uploaded Data",
       "extracted_file": "Extracted File",
       "collection": "Collection",
       "upload": "Upload",
-      "discard": "Discard Uploaded Data"
+      "discard": "Discard Uploaded Data",
+      "errors": {
+        "at_least_one": "Select one or more collections.",
+        "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+        "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      }
     },
     "esa_settings": {
       "team_name": "Team name",
@@ -783,7 +788,6 @@
     "rebuild_description_3":"This may take a while."
   },
   "export_management": {
-    "beta_warning": "This function is Beta.",
     "exporting_data_list": "Exporting Data List",
     "exported_data_list": "Exported Data List",
     "export_collections": "Export Collections",

+ 7 - 3
resource/locales/ja/translation.json

@@ -738,12 +738,17 @@
     "import_form_growi": "GROWIからインポート",
     "growi_settings": {
       "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
-      "zip_file": "Zip ファイル",
+      "zip_file": "エクスポートされた Zip ファイル",
       "uploaded_data": "アップロードされたデータ",
       "extracted_file": "展開されたファイル",
       "collection": "コレクション",
       "upload": "アップロード",
-      "discard": "アップロードしたデータを破棄する"
+      "discard": "アップロードしたデータを破棄する",
+      "errors": {
+        "at_least_one": "コレクションが選択されていません",
+        "page_and_revision": "'Pages' と 'Revisions' はセットでインポートする必要があります",
+        "depends": "'{{condition}}' をインポートする場合は、'{{target}}' を一緒に選択する必要があります"
+      }
     },
     "esa_settings": {
       "team_name": "チーム名",
@@ -768,7 +773,6 @@
     "rebuild_description_3":""
   },
   "export_management": {
-    "beta_warning": "この機能はベータ版です",
     "exporting_data_list": "エクスポート中のデータ",
     "exported_data_list": "エクスポートデータリスト",
     "export_collections": "コレクションのエクスポート",

+ 248 - 65
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
@@ -7,51 +7,186 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+
 class GrowiImportForm extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.initialState = {
-      collections: new Set(),
+      collectionNameToFileNameMap: {},
+      selectedCollections: new Set(),
       schema: {
         pages: {},
         revisions: {},
         // ...
       },
+
+      canImport: false,
+      errorsForPageGroups: [],
+      errorsForUserGroups: [],
+      errorsForConfigGroups: [],
+      errorsForOtherGroups: [],
     };
 
+    this.props.fileStats.forEach((fileStat) => {
+      const { fileName, collectionName } = fileStat;
+      this.initialState.collectionNameToFileNameMap[collectionName] = fileName;
+    });
+
     this.state = this.initialState;
 
     this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.validate = this.validate.bind(this);
     this.import = this.import.bind(this);
-    this.validateForm = this.validateForm.bind(this);
   }
 
-  toggleCheckbox(e) {
+  get allCollectionNames() {
+    return Object.keys(this.state.collectionNameToFileNameMap);
+  }
+
+  async toggleCheckbox(e) {
     const { target } = e;
     const { name, checked } = target;
 
-    this.setState((prevState) => {
-      const collections = new Set(prevState.collections);
+    await this.setState((prevState) => {
+      const selectedCollections = new Set(prevState.selectedCollections);
       if (checked) {
-        collections.add(name);
+        selectedCollections.add(name);
       }
       else {
-        collections.delete(name);
+        selectedCollections.delete(name);
       }
-      return { collections };
+      return { selectedCollections };
+    });
+
+    this.validate();
+  }
+
+  async checkAll() {
+    await this.setState({ selectedCollections: new Set(this.allCollectionNames) });
+    this.validate();
+  }
+
+  async uncheckAll() {
+    await this.setState({ selectedCollections: new Set() });
+    this.validate();
+  }
+
+  async validate() {
+    // init errors
+    await this.setState({
+      errorsForPageGroups: [],
+      errorsForUserGroups: [],
+      errorsForConfigGroups: [],
+      errorsForOtherGroups: [],
     });
+
+    await this.validateCollectionSize();
+    await this.validatePagesCollectionPairs();
+    await this.validateExternalAccounts();
+    await this.validateUserGroups();
+    await this.validateUserGroupRelations();
+
+    const errors = [
+      ...this.state.errorsForPageGroups,
+      ...this.state.errorsForUserGroups,
+      ...this.state.errorsForConfigGroups,
+      ...this.state.errorsForOtherGroups,
+    ];
+    const canImport = errors.length === 0;
+
+    this.setState({ canImport });
+  }
+
+  async validateCollectionSize(validationErrors) {
+    const { t } = this.props;
+    const { errorsForOtherGroups, selectedCollections } = this.state;
+
+    if (selectedCollections.size === 0) {
+      errorsForOtherGroups.push(t('importer_management.growi_settings.errors.at_least_one'));
+    }
+
+    this.setState({ errorsForOtherGroups });
+  }
+
+  async validatePagesCollectionPairs() {
+    const { t } = this.props;
+    const { errorsForPageGroups, selectedCollections } = this.state;
+
+    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter((collectionName) => {
+      return selectedCollections.has(collectionName);
+    }).length;
+
+    // MUST be included both or neither when importing
+    if (pageRelatedCollectionsLength !== 0 && pageRelatedCollectionsLength !== 2) {
+      errorsForPageGroups.push(t('importer_management.growi_settings.errors.page_and_revision'));
+    }
+
+    this.setState({ errorsForPageGroups });
+  }
+
+  async validateExternalAccounts() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'externalaccounts' is selected
+    if (selectedCollections.has('externalaccounts')) {
+      if (!selectedCollections.has('users')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Externalaccounts' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
+  }
+
+  async validateUserGroups() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'usergroups' is selected
+    if (selectedCollections.has('usergroups')) {
+      if (!selectedCollections.has('users')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Usergroups' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
   }
 
-  async import(e) {
-    e.preventDefault();
+  async validateUserGroupRelations() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'usergroups' if 'usergrouprelations' is selected
+    if (selectedCollections.has('usergrouprelations')) {
+      if (!selectedCollections.has('usergroups')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Usergroups', condition: 'Usergrouprelations' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
+  }
 
+  async import() {
     try {
       // TODO: use appContainer.apiv3.post
       const { results } = await this.props.appContainer.apiPost('/v3/import', {
         fileName: this.props.fileName,
-        collections: Array.from(this.state.collections),
+        collections: Array.from(this.state.selectedCollections),
         schema: this.state.schema,
       });
 
@@ -93,69 +228,117 @@ class GrowiImportForm extends React.Component {
     }
   }
 
-  validateForm() {
-    return this.state.collections.size > 0;
+  renderWarnForGroups(errors, key) {
+    if (errors.length === 0) {
+      return null;
+    }
+
+    return (
+      <div key={key} className="alert alert-warning">
+        <ul>
+          { errors.map((error, index) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`${key}-${index}`}>{error}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }
+
+  renderGroups(groupList, groupName, errors, { wellContent, color } = {}) {
+    const collectionNames = groupList.filter((collectionName) => {
+      return this.allCollectionNames.includes(collectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return null;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        { wellContent != null && (
+          <div className="well well-sm small">
+            <ul>
+              <li>{wellContent}</li>
+            </ul>
+          </div>
+        ) }
+        { this.renderCheckboxes(collectionNames, color) }
+        { this.renderWarnForGroups(errors, `warnFor${groupName}`) }
+      </div>
+    );
+  }
+
+  renderOthers() {
+    const collectionNames = this.allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return this.renderGroups(collectionNames, 'Other', this.state.errorsForOtherGroups);
+  }
+
+  renderCheckboxes(collectionNames, color) {
+    const checkboxColor = color ? `checkbox-${color}` : 'checkbox-info';
+
+    return (
+      <div className={`row checkbox ${checkboxColor}`}>
+        {collectionNames.map((collectionName) => {
+          return (
+            <div className="col-xs-6 my-1" key={collectionName}>
+              <input
+                type="checkbox"
+                id={collectionName}
+                name={collectionName}
+                className="form-check-input"
+                value={collectionName}
+                checked={this.state.selectedCollections.has(collectionName)}
+                onChange={this.toggleCheckbox}
+              />
+              <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                {collectionName}
+              </label>
+            </div>
+          );
+        })}
+      </div>
+    );
   }
 
+
   render() {
     const { t } = this.props;
+    const { errorsForPageGroups, errorsForUserGroups, errorsForConfigGroups } = this.state;
 
     return (
-      <form className="row" onSubmit={this.import}>
-        <div className="col-xs-12">
-          <table className="table table-bordered table-mapping">
-            <caption>{t('importer_management.growi_settings.uploaded_data')}</caption>
-            <thead>
-              <tr>
-                <th></th>
-                <th>{t('importer_management.growi_settings.extracted_file')}</th>
-                <th>{t('importer_management.growi_settings.collection')}</th>
-              </tr>
-            </thead>
-            <tbody>
-              {this.props.fileStats.map((fileStat) => {
-                  const { fileName, collectionName } = fileStat;
-                  const checked = this.state.collections.has(collectionName);
-                  return (
-                    <Fragment key={collectionName}>
-                      <tr>
-                        <td>
-                          <input
-                            type="checkbox"
-                            id={collectionName}
-                            name={collectionName}
-                            className="form-check-input"
-                            value={collectionName}
-                            checked={checked}
-                            onChange={this.toggleCheckbox}
-                          />
-                        </td>
-                        <td>{fileName}</td>
-                        <td className="text-capitalize">{collectionName}</td>
-                      </tr>
-                      {checked && (
-                        <tr>
-                          <td className="text-muted" colSpan="3">
-                            TBD: define how {collectionName} are imported
-                            {/* TODO: create a component for each collection to modify this.state.schema */}
-                          </td>
-                        </tr>
-                      )}
-                    </Fragment>
-                  );
-                })}
-            </tbody>
-          </table>
-        </div>
-        <div className="col-xs-12 text-center">
-          <button type="submit" className="btn btn-primary mx-1" disabled={!this.validateForm()}>
-            { t('importer_management.import') }
-          </button>
+      <>
+        <form className="form-inline">
+          <div className="form-group">
+            <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+              <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+            </button>
+          </div>
+          <div className="form-group">
+            <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+              <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+            </button>
+          </div>
+        </form>
+
+        { this.renderGroups(GROUPS_PAGE, 'Page', errorsForPageGroups, { wellContent: t('importer_management.growi_settings.overwrite_documents') }) }
+        { this.renderGroups(GROUPS_USER, 'User', errorsForUserGroups) }
+        { this.renderGroups(GROUPS_CONFIG, 'Config', errorsForConfigGroups) }
+        { this.renderOthers() }
+
+        <div className="mt-5 text-center">
           <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
             { t('importer_management.growi_settings.discard') }
           </button>
+          <button type="button" className="btn btn-primary mx-1" onClick={this.import} disabled={!this.state.canImport}>
+            { t('importer_management.import') }
+          </button>
         </div>
-      </form>
+      </>
     );
   }
 

+ 6 - 11
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import GrowiZipUploadForm from './GrowiZipUploadForm';
-import GrowiZipImportForm from './GrowiZipImportForm';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+
 class GrowiZipImportSection extends React.Component {
 
   constructor(props) {
@@ -72,27 +73,21 @@ class GrowiZipImportSection extends React.Component {
 
     return (
       <Fragment>
-        <legend>{t('importer_management.import_form_growi')}</legend>
+        <h2>{t('importer_management.import_form_growi')}</h2>
 
         <div className="alert alert-warning">
           <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
         </div>
 
-        <div className="well well-sm small">
-          <ul>
-            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
-          </ul>
-        </div>
-
         {this.state.fileName ? (
-          <Fragment>
+          <div className="px-4">
             <GrowiZipImportForm
               fileName={this.state.fileName}
               fileStats={this.state.fileStats}
               onDiscard={this.discardData}
               onPostImport={this.resetState}
             />
-          </Fragment>
+          </div>
         ) : (
           <GrowiZipUploadForm
             onUpload={this.handleUpload}

+ 1 - 1
src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx

@@ -51,7 +51,7 @@ class GrowiZipUploadForm extends React.Component {
     return (
       <form className="form-horizontal" onSubmit={this.uploadZipFile}>
         <fieldset>
-          <div className="form-group d-flex align-items-center">
+          <div className="form-group">
             <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
             <div className="col-xs-6">
               <input

+ 1 - 0
src/client/js/components/Page/RevisionBody.jsx

@@ -53,6 +53,7 @@ export default class RevisionBody extends React.Component {
           }
         }}
         className={`wiki ${additionalClassName}`}
+        // eslint-disable-next-line react/no-danger
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}
       />
     );

+ 1 - 0
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -34,6 +34,7 @@ export default class RevisionDiff extends React.Component {
     }
 
     const diffView = { __html: diffViewHTML };
+    // eslint-disable-next-line react/no-danger
     return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
   }
 

+ 0 - 1
src/server/routes/admin.js

@@ -1057,7 +1057,6 @@ module.exports = function(crowi, app) {
     const { validationResult } = require('express-validator');
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
-      console.log('validator', errors);
       return res.json(ApiResponse.error('Qiita form is blank'));
     }