Просмотр исходного кода

Merge pull request #1206 from weseek/feat/export-n-import-revision-5

 Feat/export n import revision 5
Sou Mizobuchi 6 лет назад
Родитель
Сommit
38361c75d4

+ 10 - 0
resource/locales/en-US/translation.json

@@ -738,6 +738,16 @@
 
   "importer_management": {
     "import_from": "Import from %s",
+    "import_form_growi": "Import from GROWI",
+    "growi_settings": {
+      "overwrite_documents": "Imported documents will overwrite existing documents",
+      "zip_file": "Zip File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard Uploaded Data"
+    },
     "esa_settings": {
       "team_name": "Team name",
       "access_token": "Access token",

+ 10 - 0
resource/locales/ja/translation.json

@@ -721,6 +721,16 @@
 
   "importer_management": {
     "import_from": "%s からインポート",
+    "import_form_growi": "GROWIからインポート",
+    "growi_settings": {
+      "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
+      "zip_file": "Zip ファイル",
+      "uploaded_data": "アップロードされたデータ",
+      "extracted_file": "展開されたファイル",
+      "collection": "コレクション",
+      "upload": "アップロード",
+      "discard": "アップロードしたデータを破棄する"
+    },
     "esa_settings": {
       "team_name": "チーム名",
       "access_token": "アクセストークン",

+ 2 - 2
src/client/js/app.jsx

@@ -39,7 +39,7 @@ import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import ExportPage from './components/Admin/Export/ExportPage';
-import GrowiImportForm from './components/Admin/Import/GrowiImportForm';
+import GrowiZipImportSection from './components/Admin/Import/GrowiZipImportSection';
 import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 import AppContainer from './services/AppContainer';
@@ -209,7 +209,7 @@ if (growiImportElem != null) {
   ReactDOM.render(
     <Provider inject={[]}>
       <I18nextProvider i18n={i18n}>
-        <GrowiImportForm />
+        <GrowiZipImportSection />
       </I18nextProvider>
     </Provider>,
     growiImportElem,

+ 0 - 188
src/client/js/components/Admin/Import/GrowiImportForm.jsx

@@ -1,188 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-class GrowiImportForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.initialState = {
-      meta: {},
-      zipFileName: '',
-      collections: new Set(),
-      fileStats: [],
-      schema: {
-        pages: {},
-        revisions: {},
-      },
-    };
-
-    this.state = this.initialState;
-
-    this.inputRef = React.createRef();
-
-    this.changeFileName = this.changeFileName.bind(this);
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.uploadZipFile = this.uploadZipFile.bind(this);
-    this.import = this.import.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  changeFileName(e) {
-    // to rerender onChange
-    // eslint-disable-next-line react/no-unused-state
-    this.setState({ name: e.target.files[0].name });
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const collections = new Set(prevState.collections);
-      if (checked) {
-        collections.add(name);
-      }
-      else {
-        collections.delete(name);
-      }
-      return { collections };
-    });
-  }
-
-  async uploadZipFile(e) {
-    e.preventDefault();
-
-    const formData = new FormData();
-    formData.append('_csrf', this.props.appContainer.csrfToken);
-    formData.append('file', this.inputRef.current.files[0]);
-
-    // TODO use appContainer.apiv3.post
-    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
-    this.setState({ meta: data.meta, zipFileName: data.fileName, fileStats: data.fileStats });
-    // TODO toastSuccess, toastError
-  }
-
-  async import(e) {
-    e.preventDefault();
-
-    // TODO use appContainer.apiv3.post
-    await this.props.appContainer.apiPost('/v3/import', {
-      fileName: this.state.zipFileName,
-      collections: Array.from(this.state.collections),
-      schema: this.state.schema,
-    });
-    // TODO toastSuccess, toastError
-    this.setState(this.initialState);
-  }
-
-  validateForm() {
-    return (
-      this.inputRef.current // null check
-      && this.inputRef.current.files[0] // null check
-      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <form className="form-horizontal" onSubmit={this.uploadZipFile}>
-          <fieldset>
-            <legend>Import</legend>
-            <div className="well well-sm small">
-              <ul>
-                <li>Imported pages will overwrite existing pages</li>
-              </ul>
-            </div>
-            <div className="form-group d-flex align-items-center">
-              <label htmlFor="file" className="col-xs-3 control-label">Zip File</label>
-              <div className="col-xs-6">
-                <input
-                  type="file"
-                  name="file"
-                  className="form-control-file"
-                  ref={this.inputRef}
-                  onChange={this.changeFileName}
-                />
-              </div>
-            </div>
-            <div className="form-group">
-              <div className="col-xs-offset-3 col-xs-6">
-                <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
-                  Upload
-                </button>
-              </div>
-            </div>
-          </fieldset>
-        </form>
-
-        {/* TODO: move to another component 1 */}
-        {this.state.fileStats.length > 0 && (
-          <Fragment>
-            {/* TODO: move to another component 2 */}
-            <div>{this.state.zipFileName}</div>
-            <div>{JSON.stringify(this.state.meta)}</div>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th></th>
-                  <th>File</th>
-                  <th>Collection</th>
-                </tr>
-              </thead>
-              <tbody>
-                {this.state.fileStats.map((file) => {
-                  const { fileName, collectionName } = file;
-                  return (
-                    <tr key={fileName}>
-                      <td>
-                        <input
-                          type="checkbox"
-                          id={collectionName}
-                          name={collectionName}
-                          className="form-check-input"
-                          value={collectionName}
-                          checked={this.state.collections.has(collectionName)}
-                          onChange={this.toggleCheckbox}
-                        />
-                      </td>
-                      <td>{fileName}</td>
-                      <td>{collectionName}</td>
-                    </tr>
-                  );
-                })}
-              </tbody>
-            </table>
-            {/* TODO: move to another component 3 */}
-            <button type="submit" className="btn btn-primary" onClick={this.import}>
-              { t('importer_management.import') }
-            </button>
-          </Fragment>
-        )}
-      </Fragment>
-    );
-  }
-
-}
-
-GrowiImportForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiImportFormWrapper = (props) => {
-  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
-};
-
-export default withTranslation()(GrowiImportFormWrapper);

+ 143 - 0
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -0,0 +1,143 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      collections: new Set(),
+      schema: {
+        pages: {},
+        revisions: {},
+        // ...
+      },
+    };
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.import = this.import.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+      return { collections };
+    });
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    // TODO use appContainer.apiv3.post
+    await this.props.appContainer.apiPost('/v3/import', {
+      fileName: this.props.fileName,
+      collections: Array.from(this.state.collections),
+      schema: this.state.schema,
+    });
+    // TODO toastSuccess, toastError
+    this.setState(this.initialState);
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    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>
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+        </div>
+      </form>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  fileName: PropTypes.string,
+  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 82 - 0
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx

@@ -0,0 +1,82 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipImportSection extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      fileName: '',
+      fileStats: [],
+    };
+
+    this.state = this.initialState;
+
+    this.handleUpload = this.handleUpload.bind(this);
+    this.discardData = this.discardData.bind(this);
+  }
+
+  handleUpload({ meta, fileName, fileStats }) {
+    this.setState({
+      fileName,
+      fileStats,
+    });
+  }
+
+  async discardData() {
+    await this.props.appContainer.apiRequest('delete', `/v3/import/${this.state.fileName}`, {});
+    this.setState(this.initialState);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <legend>{t('importer_management.import_form_growi')}</legend>
+        <div className="well well-sm small">
+          <ul>
+            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
+          </ul>
+        </div>
+
+        {this.state.fileName ? (
+          <Fragment>
+            <GrowiZipImportForm
+              fileName={this.state.fileName}
+              fileStats={this.state.fileStats}
+              onDiscard={this.discardData}
+            />
+          </Fragment>
+        ) : (
+          <GrowiZipUploadForm
+            onUpload={this.handleUpload}
+          />
+        )}
+      </Fragment>
+    );
+  }
+
+}
+
+GrowiZipImportSection.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipImportSectionWrapper = (props) => {
+  return createSubscribedElement(GrowiZipImportSection, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipImportSectionWrapper);

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

@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipUploadForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.inputRef = React.createRef();
+
+    this.changeFileName = this.changeFileName.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeFileName(e) {
+    // to trigger rerender at onChange event
+    // eslint-disable-next-line react/no-unused-state
+    this.setState({ dummy: e.target.files[0].name });
+  }
+
+  async uploadZipFile(e) {
+    e.preventDefault();
+
+    const formData = new FormData();
+    formData.append('_csrf', this.props.appContainer.csrfToken);
+    formData.append('file', this.inputRef.current.files[0]);
+
+    // TODO use appContainer.apiv3.post
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.props.onUpload(data);
+    // TODO toastSuccess, toastError
+  }
+
+  validateForm() {
+    return (
+      this.inputRef.current // null check
+      && this.inputRef.current.files[0] // null check
+      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.uploadZipFile}>
+        <fieldset>
+          <div className="form-group d-flex align-items-center">
+            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
+            <div className="col-xs-6">
+              <input
+                type="file"
+                name="file"
+                className="form-control-file"
+                ref={this.inputRef}
+                onChange={this.changeFileName}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-xs-offset-3 col-xs-6">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                {t('importer_management.growi_settings.upload')}
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+GrowiZipUploadForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  onUpload: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipUploadFormWrapper = (props) => {
+  return createSubscribedElement(GrowiZipUploadForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipUploadFormWrapper);

+ 69 - 0
src/server/routes/apiv3/import.js

@@ -134,6 +134,35 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  /import/upload:
+   *    post:
+   *      tags: [Import]
+   *      description: upload a zip file
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: file is uploaded
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  properties:
+   *                    meta:
+   *                      type: object
+   *                      description: meta data of the uploaded file
+   *                    fileName:
+   *                      type: string
+   *                      description: base name of the uploaded file
+   *                    fileStats:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                        description: property of each extracted file
+   */
   router.post('/upload', uploads.single('file'), async(req, res) => {
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
@@ -157,5 +186,45 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  /import/upload:
+   *    post:
+   *      tags: [Import]
+   *      description: delete a zip file
+   *      produces:
+   *        - application/json
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: file name of zip file
+   *          schema:
+   *            type: string
+   *      responses:
+   *        200:
+   *          description: file is deleted
+   *          content:
+   *            application/json:
+   */
+  router.delete('/:fileName', async(req, res) => {
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = importService.getFile(fileName);
+      importService.deleteZipFile(zipFile);
+
+      // TODO: use res.apiv3
+      return res.send({
+        ok: true,
+      });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
   return router;
 };

+ 11 - 1
src/server/service/import.js

@@ -137,7 +137,7 @@ class ImportService {
     await streamToPromise(readStream);
 
     // clean up tmp directory
-    fs.unlinkSync(jsonFile);
+    this.deleteZipFile(jsonFile);
   }
 
   /**
@@ -284,6 +284,16 @@ class ImportService {
     return jsonFile;
   }
 
+  /**
+   * remove zip file from imports dir
+   *
+   * @memberOf ImportService
+   * @param {string} zipFile absolute path to zip file
+   */
+  deleteZipFile(zipFile) {
+    fs.unlinkSync(zipFile);
+  }
+
   /**
    * validate using meta.json
    * to pass validation, all the criteria must be met