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

Merge branch 'feat/growi-bot' into 5899-access-token-to-db

Steven Fukase 4 лет назад
Родитель
Сommit
fff8b502bc
37 измененных файлов с 494 добавлено и 59 удалено
  1. 4 1
      CHANGES.md
  2. 1 1
      package.json
  3. 10 0
      packages/slack/src/utils/check-communicable.ts
  4. 66 1
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  5. 1 1
      packages/slackbot-proxy/src/entities/installation.ts
  6. 1 1
      packages/slackbot-proxy/src/entities/order.ts
  7. 3 3
      packages/slackbot-proxy/src/services/RegisterService.ts
  8. 0 0
      public/images/customize-settings/default-dark.svg
  9. 0 0
      public/images/customize-settings/default-light.svg
  10. 0 0
      public/images/customize-settings/fluid-dark.svg
  11. 0 0
      public/images/customize-settings/fluid-light.svg
  12. 5 0
      resource/locales/en_US/admin/admin.json
  13. 5 0
      resource/locales/ja_JP/admin/admin.json
  14. 5 0
      resource/locales/zh_CN/admin/admin.json
  15. 10 4
      src/client/js/components/Admin/Customize/Customize.jsx
  16. 93 0
      src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx
  17. 1 1
      src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx
  18. 2 2
      src/client/js/components/Admin/SlackIntegration/BotTypeCard.jsx
  19. 1 1
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  20. 65 3
      src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  21. 22 19
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  22. 1 1
      src/client/js/components/PageContentFooter.jsx
  23. 6 0
      src/client/styles/scss/_admin.scss
  24. 12 0
      src/client/styles/scss/_layout.scss
  25. 1 1
      src/client/styles/scss/_mixins.scss
  26. 14 5
      src/client/styles/scss/_on-edit.scss
  27. 9 1
      src/client/styles/scss/theme/_apply-colors.scss
  28. 1 3
      src/server/models/config.js
  29. 81 1
      src/server/routes/apiv3/customize-setting.js
  30. 61 0
      src/server/routes/apiv3/slack-integration-settings.js
  31. 3 2
      src/server/views/layout-growi/not_found.html
  32. 1 1
      src/server/views/layout-growi/page.html
  33. 2 2
      src/server/views/layout-growi/page_list.html
  34. 1 1
      src/server/views/layout-growi/shared_page.html
  35. 1 1
      src/server/views/layout-growi/user_page.html
  36. 4 1
      src/server/views/layout/layout.html
  37. 1 1
      src/server/views/me/drafts.html

+ 4 - 1
CHANGES.md

@@ -1,6 +1,9 @@
 # CHANGES
 
-## v4.2.17-RC
+## v4.2.18-RC
+* Feature: Cobvertible page contents width.
+
+## v4.2.17
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Hide Sidebar at shared pages

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.17-RC",
+  "version": "4.2.18-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 10 - 0
packages/slack/src/utils/check-communicable.ts

@@ -89,3 +89,13 @@ export const getConnectionStatuses = async(tokens: string[]): Promise<{[key: str
   // convert to object
   return Object.fromEntries(await map);
 };
+
+/**
+ * @param token bot OAuth token
+ * @returns
+ */
+export const relationTestToSlack = async(token:string): Promise<void> => {
+  const client = generateWebClient(token);
+  // TODO GW-6002 fire chat.postMessage
+  await testSlackApiServer(client);
+};

+ 66 - 1
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -4,11 +4,13 @@ import {
 
 import { WebAPICallResult } from '@slack/web-api';
 
-import { verifyGrowiToSlackRequest, getConnectionStatuses } from '@growi/slack';
+import { verifyGrowiToSlackRequest, getConnectionStatuses, relationTestToSlack } from '@growi/slack';
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { InstallationRepository } from '~/repositories/installation';
 import { RelationRepository } from '~/repositories/relation';
+import { OrderRepository } from '~/repositories/order';
+
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 
@@ -28,6 +30,9 @@ export class GrowiToSlackCtrl {
   @Inject()
   relationRepository: RelationRepository;
 
+  @Inject()
+  orderRepository: OrderRepository;
+
   @Get('/connection-status')
   @UseBefore(verifyGrowiToSlackRequest)
   async getConnectionStatuses(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
@@ -52,4 +57,64 @@ export class GrowiToSlackCtrl {
     return res.send({ connectionStatuses });
   }
 
+  @Get('/relation-test')
+  @UseBefore(verifyGrowiToSlackRequest)
+  async postRelation(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    const { tokenGtoPs } = req;
+
+    if (tokenGtoPs.length !== 1) {
+      return res.status(400).send({ message: 'installation is invalid' });
+    }
+
+    const tokenGtoP = tokenGtoPs[0];
+
+    // retrieve relation with Installation
+    const relation = await this.relationRepository.createQueryBuilder('relation')
+      .where('tokenGtoP = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getOne();
+
+    // Returns the result of the test if it already exists
+    if (relation != null) {
+      logger.debug('relation found', relation);
+
+      const token = relation.installation.data.bot?.token;
+      if (token == null) {
+        return res.status(400).send({ message: 'installation is invalid' });
+      }
+
+      await relationTestToSlack(token);
+      return res.send({ relation });
+    }
+
+    // retrieve latest Order with Installation
+    const order = await this.orderRepository.createQueryBuilder('order')
+      .orderBy('order.createdAt', 'DESC')
+      .where('growiAccessToken = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('order.installation', 'installation')
+      .getOne();
+
+    if (order == null || order.isExpired()) {
+      return res.status(400).send({ message: 'order has expired or does not exist.' });
+    }
+
+    logger.debug('order found', order);
+
+    const token = order.installation.data.bot?.token;
+    if (token == null) {
+      return res.status(400).send({ message: 'installation is invalid' });
+    }
+
+    await relationTestToSlack(token);
+
+    logger.debug('relation test is success', order);
+
+    // Transaction is not considered because it is used infrequently,
+    const createdRelation = await this.relationRepository.save({
+      installation: order.installation, tokenGtoP: order.growiAccessToken, tokenPtoG: order.proxyAccessToken, growiUri: order.growiUrl,
+    });
+
+    return res.send({ relation: createdRelation });
+  }
+
 }

+ 1 - 1
packages/slackbot-proxy/src/entities/installation.ts

@@ -2,7 +2,7 @@ import {
   Required,
 } from '@tsed/schema';
 import {
-  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, OneToMany,
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn,
 } from 'typeorm';
 
 import { Installation as SlackInstallation } from '@slack/oauth';

+ 1 - 1
packages/slackbot-proxy/src/entities/order.ts

@@ -16,7 +16,7 @@ export class Order {
   readonly updatedAt: Date;
 
   @ManyToOne(() => Installation)
-  readonly installation: number;
+  readonly installation: Installation;
 
   @Column({ nullable: true, default: false })
   isCompleted?: boolean;

+ 3 - 3
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -66,16 +66,16 @@ export class RegisterService implements GrowiCommandProcessor {
     const inputGrowiAccessToken = inputValues.growiAccessToken.contents_input.value;
     const inputProxyAccessToken = inputValues.proxyToken.contents_input.value;
 
-    const order = await orderRepository.findOne({ installation: installation?.id, growiUrl: inputGrowiUrl });
+    const order = await orderRepository.findOne({ installation, growiUrl: inputGrowiUrl });
     if (order != null) {
       orderRepository.update(
-        { installation: installation?.id, growiUrl: inputGrowiUrl },
+        { installation, growiUrl: inputGrowiUrl },
         { growiAccessToken: inputGrowiAccessToken, proxyAccessToken: inputProxyAccessToken },
       );
     }
     else {
       orderRepository.save({
-        installation: installation?.id, growiUrl: inputGrowiUrl, growiAccessToken: inputGrowiAccessToken, proxyAccessToken: inputProxyAccessToken,
+        installation, growiUrl: inputGrowiUrl, growiAccessToken: inputGrowiAccessToken, proxyAccessToken: inputProxyAccessToken,
       });
     }
   }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/default-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/default-light.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/fluid-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/images/customize-settings/fluid-light.svg


+ 5 - 0
resource/locales/en_US/admin/admin.json

@@ -115,6 +115,11 @@
     }
   },
   "customize_setting": {
+    "layout": "Layout",
+    "layout_options": {
+      "default": "Default content width",
+      "expanded": "Content width 100%"
+    },
     "theme": "Theme",
     "theme_desc": {
       "light_and_dark": "Light and dark modes",

+ 5 - 0
resource/locales/ja_JP/admin/admin.json

@@ -115,6 +115,11 @@
     }
   },
   "customize_setting": {
+    "layout": "レイアウト",
+    "layout_options": {
+      "default": "デフォルトのコンテンツ幅",
+      "expanded": "コンテンツ幅 100%"
+    },
     "theme": "テーマ",
     "theme_desc": {
       "light_and_dark": "Light/Dark モード選択あり",

+ 5 - 0
resource/locales/zh_CN/admin/admin.json

@@ -114,6 +114,11 @@
 		}
 	},
 	"customize_setting": {
+    "layout": "布局",
+    "layout_options": {
+      "default": "默认内容宽度 ",
+      "expanded": "内容宽度100% "
+    },
 		"theme": "主体",
 		"behavior": "行为",
 		"behavior_desc": {

+ 10 - 4
src/client/js/components/Admin/Customize/Customize.jsx

@@ -4,13 +4,15 @@ import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AppContainer from '../../../services/AppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
 import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import CustomizeLayoutSetting from './CustomizeThemeSetting';
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeThemeSetting from './CustomizeThemeSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeCssSetting from './CustomizeCssSetting';
@@ -22,7 +24,7 @@ const logger = loggerFactory('growi:services:AdminCustomizePage');
 
 let retrieveErrors = null;
 function Customize(props) {
-  const { adminCustomizeContainer } = props;
+  const { appContainer, adminCustomizeContainer } = props;
 
   if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentTheme) {
     throw (async() => {
@@ -46,7 +48,10 @@ function Customize(props) {
   return (
     <Fragment>
       <div className="mb-5">
-        <CustomizeLayoutSetting />
+        <CustomizeLayoutSetting appContainer={appContainer} />
+      </div>
+      <div className="mb-5">
+        <CustomizeThemeSetting />
       </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />
@@ -70,9 +75,10 @@ function Customize(props) {
   );
 }
 
-const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AdminCustomizeContainer]);
+const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AppContainer, AdminCustomizeContainer]);
 
 Customize.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 

+ 93 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -0,0 +1,93 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import AppContainer from '../../../services/AppContainer';
+
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { isDarkMode as isDarkModeByUtil } from '../../../util/color-scheme';
+
+const isDarkMode = isDarkModeByUtil();
+const colorText = isDarkMode ? 'dark' : 'light';
+
+const CustomizeLayoutSetting = (props) => {
+  const { t, appContainer } = props;
+
+  const [isContainerFluid, setIsContainerFluid] = useState(false);
+  const [retrieveError, setRetrieveError] = useState();
+
+  const retrieveData = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/customize-setting/layout');
+      setIsContainerFluid(res.data.isContainerFluid);
+    }
+    catch (err) {
+      setRetrieveError(err);
+      toastError(err);
+    }
+  }, [appContainer]);
+
+  useEffect(() => {
+    retrieveData();
+  }, [retrieveData]);
+
+  const onClickSubmit = async() => {
+    try {
+      await appContainer.apiv3Put('/customize-setting/layout', { isContainerFluid });
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
+      retrieveData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.layout')}</h2>
+
+          <div className="d-flex justify-content-around mt-5">
+            <div id="layoutOptions" className="card-deck">
+              <div
+                className={`card customize-layout-card ${!isContainerFluid ? 'border-active' : ''}`}
+                onClick={() => setIsContainerFluid(false)}
+                role="button"
+              >
+                <img src={`/images/customize-settings/default-${colorText}.svg`} />
+                <div className="card-body text-center">
+                  {t('admin:customize_setting.layout_options.default')}
+                </div>
+              </div>
+              <div
+                className={`card customize-layout-card ${isContainerFluid ? 'border-active' : ''}`}
+                onClick={() => setIsContainerFluid(true)}
+                role="button"
+              >
+                <img src={`/images/customize-settings/fluid-${colorText}.svg`} />
+                <div className="card-body  text-center">
+                  {t('admin:customize_setting.layout_options.expanded')}
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="mx-auto">
+              <button type="button" className="btn btn-primary" onClick={onClickSubmit} disabled={retrieveError != null}>{ t('Update') }</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+CustomizeLayoutSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutSetting);

+ 1 - 1
src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx

@@ -35,7 +35,7 @@ class CustomizeThemeSetting extends React.Component {
     if (process.env.NODE_ENV === 'development') {
       return (
         <div className="alert alert-warning">
-          <strong>DEBUG MESSAGE:</strong> development build では、リアルタイムプレビューが無効になります
+          <strong>DEBUG MESSAGE:</strong> Live preview for theme is disabled in development mode.
         </div>
       );
     }

+ 2 - 2
src/client/js/components/Admin/SlackIntegration/BotTypeCard.jsx

@@ -35,7 +35,7 @@ const BotTypeCard = (props) => {
   return (
     <div
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
-      onClick={() => props.handleBotTypeSelect(botDetails[props.botType].botType)}
+      onClick={() => props.onBotTypeSelectHandler(botDetails[props.botType].botType)}
       role="button"
       key={props.botType}
     >
@@ -87,7 +87,7 @@ const BotTypeCard = (props) => {
 BotTypeCard.propTypes = {
   isActive: PropTypes.bool.isRequired,
   botType: PropTypes.string.isRequired,
-  handleBotTypeSelect: PropTypes.func.isRequired,
+  onBotTypeSelectHandler: PropTypes.func.isRequired,
 };
 
 export default BotTypeCard;

+ 1 - 1
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -126,7 +126,7 @@ const CustomBotWithProxySettings = (props) => {
           />
         </div>
         <div className="col-md-2 mt-3 text-center text-md-left">
-          <button type="button" className="btn btn-primary" onClick={updateProxyUri} disabled={false}>{ t('Update') }</button>
+          <button type="button" className="btn btn-primary" onClick={updateProxyUri}>{ t('Update') }</button>
         </div>
       </div>
 

+ 65 - 3
src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,10 +1,50 @@
-import React from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 import { useTranslation } from 'react-i18next';
+import AppContainer from '../../../services/AppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
 import CustomBotWithProxyIntegrationCard from './CustomBotWithProxyIntegrationCard';
 import WithProxyAccordions from './WithProxyAccordions';
 
-const OfficialBotSettings = () => {
+const logger = loggerFactory('growi:SlackBotSettings');
+
+const OfficialBotSettings = (props) => {
+  const { appContainer } = props;
   const { t } = useTranslation();
+  const [proxyUri, setProxyUri] = useState(null);
+
+  const retrieveProxyUri = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3.get('/slack-integration-settings');
+      const { proxyUri } = res.data.settings;
+      setProxyUri(proxyUri);
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }, [appContainer.apiv3]);
+
+  useEffect(() => {
+    retrieveProxyUri();
+  }, [retrieveProxyUri]);
+
+  const updateProxyUri = async() => {
+    try {
+      await appContainer.apiv3.put('/slack-integration-settings/proxy-uri', {
+        proxyUri,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('Proxy URL') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
 
   return (
     <>
@@ -27,6 +67,22 @@ const OfficialBotSettings = () => {
         isSlackScopeSet
       />
 
+      <div className="form-group row my-4">
+        <label className="text-left text-md-right col-md-3 col-form-label mt-3">Proxy URL</label>
+        <div className="col-md-6 mt-3">
+          <input
+            className="form-control"
+            type="text"
+            name="settingForm[proxyUrl]"
+            defaultValue={proxyUri}
+            onChange={(e) => { setProxyUri(e.target.value) }}
+          />
+        </div>
+        <div className="col-md-2 mt-3 text-center text-md-left">
+          <button type="button" className="btn btn-primary" onClick={updateProxyUri}>{ t('Update') }</button>
+        </div>
+      </div>
+
       <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_settings')}</h2>
 
       <div className="my-5 mx-3">
@@ -37,4 +93,10 @@ const OfficialBotSettings = () => {
   );
 };
 
-export default OfficialBotSettings;
+const OfficialBotSettingsWrapper = withUnstatedContainers(OfficialBotSettings, [AppContainer]);
+
+OfficialBotSettings.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default OfficialBotSettingsWrapper;

+ 22 - 19
src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -78,25 +78,10 @@ const SlackIntegration = (props) => {
     fetchSlackIntegrationData();
   }, [fetchSlackIntegrationData]);
 
-  const handleBotTypeSelect = (clickedBotType) => {
-    if (clickedBotType === currentBotType) {
-      return;
-    }
-    if (currentBotType === null) {
-      setCurrentBotType(clickedBotType);
-      return;
-    }
-    setSelectedBotType(clickedBotType);
-  };
-
-  const cancelBotChangeHandler = () => {
-    setSelectedBotType(null);
-  };
-
-  const changeCurrentBotSettingsHandler = async() => {
+  const changeCurrentBotSettings = async(botType) => {
     try {
       const res = await appContainer.apiv3.put('/slack-integration-settings/bot-type', {
-        currentBotType: selectedBotType,
+        currentBotType: botType,
       });
       setCurrentBotType(res.data.slackBotTypeParam.slackBotType);
       setSelectedBotType(null);
@@ -105,13 +90,31 @@ const SlackIntegration = (props) => {
       setSlackBotToken(null);
       setIsSendTestMessage(false);
       setSlackWSNameInWithoutProxy(null);
-      toastSuccess(t('admin:slack_integration.bot_reset_successful'));
     }
     catch (err) {
       toastError(err);
     }
   };
 
+  const botTypeSelectHandler = async(botType) => {
+    if (botType === currentBotType) {
+      return;
+    }
+    if (currentBotType == null) {
+      return changeCurrentBotSettings(botType);
+    }
+    setSelectedBotType(botType);
+  };
+
+  const changeCurrentBotSettingsHandler = async() => {
+    changeCurrentBotSettings(selectedBotType);
+    toastSuccess(t('admin:slack_integration.bot_reset_successful'));
+  };
+
+  const cancelBotChangeHandler = () => {
+    setSelectedBotType(null);
+  };
+
   let settingsComponent = null;
 
   switch (currentBotType) {
@@ -188,7 +191,7 @@ const SlackIntegration = (props) => {
                 <BotTypeCard
                   botType={botType}
                   isActive={currentBotType === botType}
-                  handleBotTypeSelect={handleBotTypeSelect}
+                  onBotTypeSelectHandler={botTypeSelectHandler}
                 />
               </div>
             );

+ 1 - 1
src/client/js/components/PageContentFooter.jsx

@@ -15,7 +15,7 @@ const PageContentFooter = (props) => {
 
   return (
     <div className="page-content-footer py-4 d-edit-none d-print-none">
-      <div className="container-lg">
+      <div className="grw-container-convertible">
         <div className="page-meta">
           <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
           <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />

+ 6 - 0
src/client/styles/scss/_admin.scss

@@ -188,6 +188,12 @@ $slack-work-space-name-card-border: #efc1f6;
   //   }
   // }
 
+  #layoutOptions {
+    .customize-layout-card {
+      border: 4px solid $border-color;
+    }
+  }
+
   // theme selector
   #themeOptions {
     // layout

+ 12 - 0
src/client/styles/scss/_layout.scss

@@ -2,6 +2,18 @@ body {
   overflow-y: scroll !important;
 }
 
+body:not(.growi-layout-fluid) .grw-container-convertible {
+  @extend .container-lg;
+}
+
+body.not-found-page .grw-container-convertible {
+  @extend .container-lg;
+}
+
+body.growi-layout-fluid .grw-container-convertible {
+  @extend .container-fluid;
+}
+
 .grw-logo {
   svg {
     width: $grw-logo-width;

+ 1 - 1
src/client/styles/scss/_mixins.scss

@@ -27,7 +27,7 @@
     height: calc(100vh - #{$editor-margin-top});
     margin-top: 0px !important;
 
-    .container-lg {
+    .grw-container-convertible {
       max-width: unset;
       padding: 0;
       margin: 0;

+ 14 - 5
src/client/styles/scss/_on-edit.scss

@@ -243,11 +243,6 @@ body.on-edit {
       overflow-y: scroll;
     }
 
-    .wiki {
-      max-width: 980px;
-      margin: 0 auto;
-    }
-
     .grw-editor-configuration-dropdown {
       .icon-container {
         width: 20px;
@@ -304,6 +299,20 @@ body.on-edit {
   }
 }
 
+body.on-edit {
+  &:not(.growi-layout-fluid) .page-editor-preview-body {
+    .wiki {
+      max-width: 980px;
+      margin: 0 auto;
+    }
+  }
+  &.growi-layout-fluid .page-editor-preview-body {
+    .wiki {
+      margin: 0 auto;
+    }
+  }
+}
+
 // overwrite .CodeMirror pre
 .CodeMirror pre.CodeMirror-line {
   font-family: $font-family-monospace;

+ 9 - 1
src/client/styles/scss/theme/_apply-colors.scss

@@ -559,9 +559,17 @@ mark.rbt-highlight-text {
 }
 
 /*
- * GROWI admin page #themeOptions
+ * GROWI admin page #layoutOptions #themeOptions
  */
 .admin-page {
+  #layoutOptions {
+    .customize-layout-card {
+      &.border-active {
+        border-color: $color-theme-color-box;
+      }
+    }
+  }
+
   #themeOptions {
     .theme-option-container.active {
       .theme-option-name {

+ 1 - 3
src/server/models/config.js

@@ -22,7 +22,6 @@ module.exports = function(crowi) {
     // overwrite
     config['app:installed'] = true;
     config['app:fileUpload'] = true;
-    config['customize:layout'] = 'growi';
     config['customize:isSavedStatesOfTabChanges'] = false;
 
     return config;
@@ -107,7 +106,7 @@ module.exports = function(crowi) {
       'customize:highlightJsStyle' : 'github',
       'customize:highlightJsStyleBorder' : false,
       'customize:theme' : 'default',
-      'customize:layout' : 'growi',
+      'customize:isContainerFluid' : false,
       'customize:isEnabledTimeline' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
@@ -194,7 +193,6 @@ module.exports = function(crowi) {
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
-      layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),

+ 81 - 1
src/server/routes/apiv3/customize-setting.js

@@ -21,6 +21,12 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *
  *  components:
  *    schemas:
+ *      CustomizeLayout:
+ *        description: CustomizeLayout
+ *        type: object
+ *        properties:
+ *          isContainerFluid:
+ *            type: boolean
  *      CustomizeTheme:
  *        description: CustomizeTheme
  *        type: object
@@ -87,6 +93,9 @@ module.exports = (crowi) => {
   const { customizeService } = crowi;
 
   const validator = {
+    layout: [
+      body('isContainerFluid').isBoolean(),
+    ],
     themeAssetPath: [
       query('themeName').isString(),
     ],
@@ -147,7 +156,6 @@ module.exports = (crowi) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const customizeParams = {
-      layoutType: await crowi.configManager.getConfig('crowi', 'customize:layout'),
       themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
@@ -169,6 +177,78 @@ module.exports = (crowi) => {
     return res.apiv3({ customizeParams });
   });
 
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layout:
+   *      get:
+   *        tags: [CustomizeSetting]
+   *        operationId: getLayoutCustomizeSetting
+   *        summary: /customize-setting/layout
+   *        description: Get layout
+   *        responses:
+   *          200:
+   *            description: Succeeded to get layout
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayout'
+   */
+  router.get('/layout', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    try {
+      const isContainerFluid = await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+      return res.apiv3({ isContainerFluid });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting layout';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layout:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        operationId: updateLayoutCustomizeSetting
+   *        summary: /customize-setting/layout
+   *        description: Update layout
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeLayout'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update layout
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayout'
+   */
+  router.put('/layout', loginRequiredStrictly, adminRequired, csrf, validator.layout, apiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:isContainerFluid': req.body.isContainerFluid,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating layout';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 61 - 0
src/server/routes/apiv3/slack-integration-settings.js

@@ -67,6 +67,9 @@ module.exports = (crowi) => {
         .isString()
         .isLength({ min: 1 }),
     ],
+    RelationTest: [
+      body('slackappintegrationsId').isMongoId(),
+    ],
   };
 
   async function resetAllBotSettings() {
@@ -99,6 +102,18 @@ module.exports = (crowi) => {
     return result.data;
   }
 
+  async function postRelationTest(token) {
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:serverUri');
+
+    const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
+      headers: {
+        'x-growi-gtop-tokens': token,
+      },
+    });
+
+    return result.data;
+  }
+
   /**
    * @swagger
    *
@@ -417,5 +432,51 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/with-proxy/relation-test:
+   *      post:
+   *        tags: [botType]
+   *        operationId: postRelationTest
+   *        summary: /slack-integration/bot-type
+   *        description: Delete botType setting.
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  slackappintegrationsId:
+   *                    type: string
+   *        responses:
+   *           200:
+   *             description: Succeeded to delete botType setting.
+   */
+  router.post('/with-proxy/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.RelationTest, apiV3FormValidator, async(req, res) => {
+    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    if (currentBotType === 'customBotWithoutProxy') {
+      const msg = 'Not Proxy Type';
+      return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
+    }
+
+    const { slackappintegrationsId } = req.body;
+
+    try {
+      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackappintegrationsId });
+      if (slackAppIntegration == null) {
+        const msg = 'Could not find SlackAppIntegration by id';
+        return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
+      }
+      const response = await postRelationTest(slackAppIntegration.tokenGtoP);
+
+      return res.apiv3({ response });
+    }
+    catch (error) {
+      const msg = 'Error occured in updating Custom bot setting';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
   return router;
 };

+ 3 - 2
src/server/views/layout-growi/not_found.html

@@ -1,15 +1,16 @@
 {% extends 'base/layout.html' %}
 
+{% block html_base_css %}not-found-page{% endblock %}
 
 {% block content_main_before %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
   </div>
 {% endblock %}
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/not_found_content.html' %}
   </div>
 {% endblock %}

+ 1 - 1
src/server/views/layout-growi/page.html

@@ -6,7 +6,7 @@
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
 
     {% include '../widget/page_content.html' %}
 

+ 2 - 2
src/server/views/layout-growi/page_list.html

@@ -6,7 +6,7 @@
 
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
     {% include '../widget/page_content.html' %}
   </div>
 {% endblock %}
@@ -14,7 +14,7 @@
 
 {% block content_main_after %}
   {% if isTrashPage() %}
-    <div class="container-lg">
+    <div class="grw-container-convertible">
       <div id="trash-page-list"></div>
     </div>
   {% endif %}

+ 1 - 1
src/server/views/layout-growi/shared_page.html

@@ -21,7 +21,7 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="container-lg">
+  <div class="grw-container-convertible">
 
     <div
       id="is-shared-page"

+ 1 - 1
src/server/views/layout-growi/user_page.html

@@ -1,7 +1,7 @@
 {% extends 'page.html' %}
 
 {% block content_main %}
-  <div class="container-lg user-page">
+  <div class="grw-container-convertible user-page">
 
     {% include '../widget/page_content.html' %}
 

+ 4 - 1
src/server/views/layout/layout.html

@@ -60,8 +60,11 @@
 {% endblock %}
 
 {% block html_body %}
+{% if getConfig('crowi', 'customize:isContainerFluid') %}
+  {% set additionalBodyClass = 'growi-layout-fluid' %}
+{% endif %}
 <body
-  class="{% block html_base_css %}{% endblock %} growi"
+  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClass }}"
   data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}

+ 1 - 1
src/server/views/me/drafts.html

@@ -14,7 +14,7 @@
 {% endblock %}
 
 <div id="main" class="main">
-  <div id="content-main" class="content-main container-lg">
+  <div id="content-main" class="content-main grw-container-convertible">
     <div id="my-drafts"></div>
   </div>
 </div>

Некоторые файлы не были показаны из-за большого количества измененных файлов