Explorar el Código

Merge pull request #7068 from weseek/feat/display-plugin-cards-dynamically

feat: Display plugin cards dynamically
Ryoji Shimizu hace 3 años
padre
commit
7fdf0ec19d

+ 5 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -1024,6 +1024,10 @@
     "remove_user_success": "Succeeded to removing {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",
     "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "switch_disable_link_sharing_success": "Succeeded to update share link setting",
     "switch_disable_link_sharing_success": "Succeeded to update share link setting",
-    "failed_to_reset_password":"Failed to reset password"
+    "failed_to_reset_password":"Failed to reset password",
+    "install_plugin_success": "Succeeded to install {{pluginName}}",
+    "activate_plugin_success": "Succeeded to activating {{pluginName}}",
+    "deactivate_plugin_success": "Succeeded to deactivate {{pluginName}}",
+    "remove_plugin_success": "Succeeded to removing {{pluginName}}"
   }
   }
 }
 }

+ 5 - 1
packages/app/public/static/locales/ja_JP/admin.json

@@ -1032,6 +1032,10 @@
     "remove_user_success": "{{username}}を削除しました",
     "remove_user_success": "{{username}}を削除しました",
     "remove_external_user_success": "{{accountId}}を削除しました",
     "remove_external_user_success": "{{accountId}}を削除しました",
     "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
     "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
-    "failed_to_reset_password":"パスワードのリセットに失敗しました"
+    "failed_to_reset_password":"パスワードのリセットに失敗しました",
+    "install_plugin_success": "{{pluginName}}のインストールに成功しました",
+    "activate_plugin_success": "{{pluginName}}を有効化しました",
+    "deactivate_plugin_success": "{{pluginName}}を無効化しました",
+    "remove_plugin_success": "{{pluginName}}を削除しました"
   }
   }
 }
 }

+ 5 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -1032,6 +1032,10 @@
     "remove_user_success": "Succeeded to removing {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",
     "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
-    "failed_to_reset_password":"Failed to reset password"
+    "failed_to_reset_password":"Failed to reset password",
+    "install_plugin_success": "Succeeded to install {{pluginName}}",
+    "activate_plugin_success": "Succeeded to activating {{pluginName}}",
+    "deactivate_plugin_success": "Succeeded to deactivate {{pluginName}}",
+    "remove_plugin_success": "Succeeded to removing {{pluginName}}"
   }
   }
 }
 }

+ 0 - 13
packages/app/src/components/Admin/PluginsExtension/Loading.js

@@ -1,13 +0,0 @@
-import {
-  Spinner,
-} from 'reactstrap';
-
-const Loading = () => {
-  return (
-    <Spinner className='d-flex justify-content-center aligh-items-center'>
-      Loading...
-    </Spinner>
-  );
-};
-
-export default Loading;

+ 92 - 49
packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx

@@ -1,26 +1,103 @@
-// import { faCircleArrowDown, faCircleCheck } from '@fortawesome/free-solid-svg-icons';
-// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import React, { useState } from 'react';
 
 
+import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-import styles from './PluginCard.module.scss';
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
 
 
+import styles from './PluginCard.module.scss';
 
 
 type Props = {
 type Props = {
+  id: string,
   name: string,
   name: string,
   url: string,
   url: string,
-  description: string,
+  isEnalbed: boolean,
+  mutate: () => void,
+  desc?: string,
 }
 }
 
 
 export const PluginCard = (props: Props): JSX.Element => {
 export const PluginCard = (props: Props): JSX.Element => {
+
   const {
   const {
-    name, url, description,
+    id, name, url, isEnalbed, desc, mutate,
   } = props;
   } = props;
-  // const [isEnabled, setIsEnabled] = useState(true);
 
 
-  // const checkboxHandler = useCallback(() => {
-  //   setIsEnabled(false);
-  // }, []);
+  const { t } = useTranslation('admin');
+
+  const PluginCardButton = (): JSX.Element => {
+    const [isEnabled, setState] = useState<boolean>(isEnalbed);
+
+    const onChangeHandler = async() => {
+      try {
+        if (isEnabled) {
+          const reqUrl = `/plugins/${id}/deactivate`;
+          const res = await apiv3Put(reqUrl);
+          setState(!isEnabled);
+          const pluginName = res.data.pluginName;
+          toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
+        }
+        else {
+          const reqUrl = `/plugins/${id}/activate`;
+          const res = await apiv3Put(reqUrl);
+          setState(!isEnabled);
+          const pluginName = res.data.pluginName;
+          toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
+        }
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+
+    return (
+      <div className={`${styles.plugin_card}`}>
+        <div className="switch">
+          <label className="switch__label">
+            <input
+              type="checkbox"
+              className="switch__input"
+              onChange={() => onChangeHandler()}
+              checked={isEnabled}
+            />
+            <span className="switch__content"></span>
+            <span className="switch__circle"></span>
+          </label>
+        </div>
+      </div>
+    );
+  };
+
+  const PluginDeleteButton = (): JSX.Element => {
+
+    const onClickPluginDeleteBtnHandler = async() => {
+      const reqUrl = `/plugins/${id}/remove`;
+
+      try {
+        const res = await apiv3Delete(reqUrl);
+        const pluginName = res.data.pluginName;
+        toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
+      }
+      catch (err) {
+        toastError(err);
+      }
+      finally {
+        mutate();
+      }
+    };
+
+    return (
+      <div className="">
+        <button
+          type="submit"
+          className="btn btn-primary"
+          onClick={() => onClickPluginDeleteBtnHandler()}
+        >
+          Delete
+        </button>
+      </div>
+    );
+  };
 
 
   return (
   return (
     <div className="card shadow border-0" key={name}>
     <div className="card shadow border-0" key={name}>
@@ -30,54 +107,20 @@ export const PluginCard = (props: Props): JSX.Element => {
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
               <Link href={`${url}`}>{name}</Link>
               <Link href={`${url}`}>{name}</Link>
             </h2>
             </h2>
-            <p className="card-text text-muted">{description}</p>
+            <p className="card-text text-muted">{desc}</p>
           </div>
           </div>
           <div className='col-3'>
           <div className='col-3'>
-            <div className={`${styles.plugin_card}`}>
-              <div className="switch">
-                <label className="switch__label">
-                  <input type="checkbox" className="switch__input" checked/>
-                  <span className="switch__content"></span>
-                  <span className="switch__circle"></span>
-                </label>
-              </div>
+            <div>
+              <PluginCardButton />
+            </div>
+            <div className="mt-4">
+              <PluginDeleteButton />
             </div>
             </div>
-            {/* <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
-              <input
-                type="checkbox"
-                className="custom-control-input border-0"
-                checked={isEnabled}
-                onChange={checkboxHandler}
-              />
-              <label className="custom-control-label align-center"></label>
-            </div> */}
-            {/* <Image className="mx-auto" alt="GitHub avator image" src={owner.avatar_url} width={250} height={250} /> */}
-          </div>
-        </div>
-        <div className="row">
-          <div className="col-12 d-flex flex-wrap gap-2">
-            {/* {topics?.map((topic: string) => {
-              return (
-                <span key={`${name}-${topic}`} className="badge rounded-1 mp-bg-light-blue text-dark fw-normal">
-                  {topic}
-                </span>
-              );
-            })} */}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
       <div className="card-footer px-5 border-top-0 mp-bg-light-blue">
       <div className="card-footer px-5 border-top-0 mp-bg-light-blue">
         <p className="d-flex justify-content-between align-self-center mb-0">
         <p className="d-flex justify-content-between align-self-center mb-0">
-          <span>
-            {/* {owner.login === 'weseek' ? <FontAwesomeIcon icon={faCircleCheck} className="me-1 text-primary" /> : <></>}
-
-            <a href={owner.html_url} target="_blank" rel="noreferrer">
-              {owner.login}
-            </a> */}
-          </span>
-          {/* <span>
-            <FontAwesomeIcon icon={faCircleArrowDown} className="me-1" /> {stargazersCount}
-          </span> */}
         </p>
         </p>
       </div>
       </div>
     </div>
     </div>

+ 14 - 33
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -1,11 +1,14 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useTranslation } from 'next-i18next';
 
 
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxPlugins } from '~/stores/plugin';
 
 
 export const PluginInstallerForm = (): JSX.Element => {
 export const PluginInstallerForm = (): JSX.Element => {
-  // const { t } = useTranslation('admin');
+  const { mutate } = useSWRxPlugins();
+  const { t } = useTranslation('admin');
 
 
   const submitHandler = useCallback(async(e) => {
   const submitHandler = useCallback(async(e) => {
     e.preventDefault();
     e.preventDefault();
@@ -25,13 +28,17 @@ export const PluginInstallerForm = (): JSX.Element => {
     };
     };
 
 
     try {
     try {
-      await apiv3Post('/plugins', { pluginInstallerForm });
-      toastSuccess('Plugin Install Successed!');
+      const res = await apiv3Post('/plugins', { pluginInstallerForm });
+      const pluginName = res.data.pluginName;
+      toastSuccess(t('toaster.install_plugin_success', { pluginName }));
     }
     }
     catch (e) {
     catch (e) {
       toastError(e);
       toastError(e);
     }
     }
-  }, []);
+    finally {
+      mutate();
+    }
+  }, [mutate, t]);
 
 
   return (
   return (
     <form role="form" onSubmit={submitHandler}>
     <form role="form" onSubmit={submitHandler}>
@@ -41,39 +48,13 @@ export const PluginInstallerForm = (): JSX.Element => {
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            // defaultValue={adminAppContainer.state.title || ''}
             name="pluginInstallerForm[url]"
             name="pluginInstallerForm[url]"
-            placeholder="https://github.com/weseek/growi-plugin-lsx"
+            placeholder="https://github.com/growi/plugins"
             required
             required
           />
           />
           <p className="form-text text-muted">You can install plugins by inputting the GitHub URL.</p>
           <p className="form-text text-muted">You can install plugins by inputting the GitHub URL.</p>
-          {/* <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p> */}
         </div>
         </div>
       </div>
       </div>
-      {/* <div className='form-group row'>
-        <label className="text-left text-md-right col-md-3 col-form-label">branch</label>
-        <div className="col-md-6">
-          <input
-            className="form-control"
-            type="text"
-            name="pluginInstallerForm[ghBranch]"
-            placeholder="main"
-          />
-          <p className="form-text text-muted">branch name</p>
-        </div>
-      </div>
-      <div className='form-group row'>
-        <label className="text-left text-md-right col-md-3 col-form-label">tag</label>
-        <div className="col-md-6">
-          <input
-            className="form-control"
-            type="text"
-            name="pluginInstallerForm[ghTag]"
-            placeholder="tags"
-          />
-          <p className="form-text text-muted">tag name</p>
-        </div>
-      </div> */}
 
 
       <div className="row my-3">
       <div className="row my-3">
         <div className="mx-auto">
         <div className="mx-auto">

+ 43 - 32
packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx

@@ -1,25 +1,25 @@
 import React from 'react';
 import React from 'react';
 
 
-import type { SearchResultItem } from '~/interfaces/github-api';
-import { useInstalledPlugins } from '~/stores/useInstalledPlugins';
+import { Spinner } from 'reactstrap';
+
+import { useSWRxPlugins } from '~/stores/plugin';
 
 
-import Loading from './Loading';
 import { PluginCard } from './PluginCard';
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
 import { PluginInstallerForm } from './PluginInstallerForm';
 
 
-
-// TODO: i18n
+const Loading = (): JSX.Element => {
+  return (
+    <Spinner className='d-flex justify-content-center aligh-items-center'>
+      Loading...
+    </Spinner>
+  );
+};
 
 
 export const PluginsExtensionPageContents = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
-  // const { data, error } = useInstalledPlugins();
-
-  // if (data == null) {
-  //   return <Loading />;
-  // }
+  const { data, mutate } = useSWRxPlugins();
 
 
   return (
   return (
     <div>
     <div>
-
       <div className="row mb-5">
       <div className="row mb-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
           <h2 className="admin-setting-header">Plugin Installer</h2>
           <h2 className="admin-setting-header">Plugin Installer</h2>
@@ -29,27 +29,38 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
 
 
       <div className="row mb-5">
       <div className="row mb-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">Plugins</h2>
-          <div className="d-grid gap-5">
-            <PluginCard
-              name={'growi-plugin-templates-for-office'}
-              url={'https://github.com/weseek/growi-plugin-templates-for-office'}
-              description={'GROWI markdown templates for office.'}
-            />
-            {/* <PluginCard
-              name={'growi-plugin-theme-welcome-to-fumiya-room'}
-              url={'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room'}
-              description={'Welcome to fumiya\'s room! This is very very "latest" design...'}
-            /> */}
-            <PluginCard
-              name={'growi-plugin-copy-code-to-clipboard'}
-              url={'https://github.com/weseek/growi-plugin-copy-code-to-clipboard'}
-              description={'Add copy button on code blocks.'}
-            />
-            {/* {data?.items.map((item: SearchResultItem) => {
-              return <PluginCard key={item.name} {...item} />;
-            })} */}
-          </div>
+          <h2 className="admin-setting-header">Plugins
+            <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
+              <i className="icon icon-reload"></i>
+            </button>
+          </h2>
+          {data?.plugins == null
+            ? <Loading />
+            : (
+              <div className="d-grid gap-5">
+                { data.plugins.length === 0 && (
+                  <div>Plugin is not installed</div>
+                )}
+                { data.plugins.map((plugin) => {
+                  const pluginId = plugin._id;
+                  const pluginName = plugin.meta.name;
+                  const pluginUrl = plugin.origin.url;
+                  const pluginIsEnabled = plugin.isEnabled;
+                  const pluginDiscription = plugin.meta.desc;
+                  return (
+                    <PluginCard
+                      key={pluginId}
+                      id={pluginId}
+                      name={pluginName}
+                      url={pluginUrl}
+                      isEnalbed={pluginIsEnabled}
+                      desc={pluginDiscription}
+                      mutate={mutate}
+                    />
+                  );
+                })}
+              </div>
+            )}
         </div>
         </div>
       </div>
       </div>
 
 

+ 3 - 1
packages/app/src/interfaces/plugin.ts

@@ -1,4 +1,4 @@
-import { GrowiThemeMetadata } from '@growi/core';
+import { GrowiThemeMetadata, HasObjectId } from '@growi/core';
 
 
 export const GrowiPluginResourceType = {
 export const GrowiPluginResourceType = {
   Template: 'template',
   Template: 'template',
@@ -31,3 +31,5 @@ export type GrowiPluginMeta = {
 export type GrowiThemePluginMeta = GrowiPluginMeta & {
 export type GrowiThemePluginMeta = GrowiPluginMeta & {
   themes: GrowiThemeMetadata[]
   themes: GrowiThemeMetadata[]
 }
 }
+
+export type GrowiPluginHasId = GrowiPlugin & HasObjectId;

+ 24 - 1
packages/app/src/server/models/growi-plugin.ts

@@ -1,6 +1,6 @@
 import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
 import {
-  Schema, Model, Document,
+  Schema, Model, Document, Types,
 } from 'mongoose';
 } from 'mongoose';
 
 
 import {
 import {
@@ -14,6 +14,8 @@ export interface GrowiPluginDocument extends GrowiPlugin, Document {
 export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
 export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
   findEnabledPlugins(): Promise<GrowiPlugin[]>
   findEnabledPlugins(): Promise<GrowiPlugin[]>
   findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
   findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+  activatePlugin(id: Types.ObjectId): Promise<string>
+  deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
 }
 
 
 const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
 const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
@@ -58,6 +60,7 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
   return this.find({ isEnabled: true });
   return this.find({ isEnabled: true });
 };
 };
+
 growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
 growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
   return this.find({
   return this.find({
     isEnabled: true,
     isEnabled: true,
@@ -65,4 +68,24 @@ growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(t
   });
   });
 };
 };
 
 
+growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: true });
+  if (growiPlugin == null) {
+    const message = 'No plugin found for this ID.';
+    throw new Error(message);
+  }
+  const pluginName = growiPlugin.meta.name;
+  return pluginName;
+};
+
+growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: false });
+  if (growiPlugin == null) {
+    const message = 'No plugin found for this ID.';
+    throw new Error(message);
+  }
+  const pluginName = growiPlugin.meta.name;
+  return pluginName;
+};
+
 export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);
 export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 94 - 7
packages/app/src/server/routes/apiv3/plugins.ts

@@ -1,26 +1,113 @@
-import express, { Request } from 'express';
+import express, { Request, Router } from 'express';
+import { body, query } from 'express-validator';
+import mongoose from 'mongoose';
 
 
 import Crowi from '../../crowi';
 import Crowi from '../../crowi';
+import type { GrowiPluginModel } from '../../models/growi-plugin';
 
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 import { ApiV3Response } from './interfaces/apiv3-response';
 
 
-type PluginInstallerFormRequest = Request & { form: any };
+const ObjectID = mongoose.Types.ObjectId;
+
+/*
+ * Validators
+ */
+const validator = {
+  pluginIdisRequired: [
+    query('id').isMongoId().withMessage('pluginId is required'),
+  ],
+  pluginFormValueisRequired: [
+    body('pluginInstallerForm').isString().withMessage('pluginFormValue is required'),
+  ],
+};
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
 
 
-module.exports = (crowi: Crowi) => {
   const router = express.Router();
   const router = express.Router();
   const { pluginService } = crowi;
   const { pluginService } = crowi;
 
 
-  router.post('/', async(req: PluginInstallerFormRequest, res: ApiV3Response) => {
+  router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err('\'pluginService\' is not set up', 500);
+    }
+
+    try {
+      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+      const data = await GrowiPluginModel.find({});
+      return res.apiv3({ plugins: data });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err('\'pluginService\' is not set up', 500);
+    }
+
+    const { pluginInstallerForm: formValue } = req.body;
+
+    try {
+      const pluginName = await pluginService.install(formValue);
+      return res.apiv3({ pluginName });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err('\'pluginService\' is not set up', 500);
+    }
+    const { id } = req.params;
+    const pluginId = new ObjectID(id);
+
+    try {
+      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+      const pluginName = await GrowiPluginModel.activatePlugin(pluginId);
+      return res.apiv3({ pluginName });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
     if (pluginService == null) {
     if (pluginService == null) {
       return res.apiv3Err('\'pluginService\' is not set up', 500);
       return res.apiv3Err('\'pluginService\' is not set up', 500);
     }
     }
 
 
+    const { id } = req.params;
+    const pluginId = new ObjectID(id);
+
+    try {
+      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
+      const pluginName = await GrowiPluginModel.deactivatePlugin(pluginId);
+      return res.apiv3({ pluginName });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err('\'pluginService\' is not set up', 500);
+    }
+
+    const { id } = req.params;
+    const pluginId = new ObjectID(id);
+
     try {
     try {
-      await pluginService.install(req.body.pluginInstallerForm);
-      return res.apiv3({});
+      const pluginName = await pluginService.deletePlugin(pluginId);
+      return res.apiv3({ pluginName });
     }
     }
     catch (err) {
     catch (err) {
-      return res.apiv3Err(err, 400);
+      return res.apiv3Err(err);
     }
     }
   });
   });
 
 

+ 45 - 16
packages/app/src/server/service/plugin.ts

@@ -34,7 +34,7 @@ function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
 }
 }
 
 
 export interface IPluginService {
 export interface IPluginService {
-  install(origin: GrowiPluginOrigin): Promise<void>
+  install(origin: GrowiPluginOrigin): Promise<string>
   retrieveThemeHref(theme: string): Promise<string | undefined>
   retrieveThemeHref(theme: string): Promise<string | undefined>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   downloadNotExistPluginRepositories(): Promise<void>
   downloadNotExistPluginRepositories(): Promise<void>
@@ -62,7 +62,7 @@ export class PluginService implements IPluginService {
           const ghBranch = 'main';
           const ghBranch = 'main';
           const match = ghPathname.match(githubReposIdPattern);
           const match = ghPathname.match(githubReposIdPattern);
           if (ghUrl.hostname !== 'github.com' || match == null) {
           if (ghUrl.hostname !== 'github.com' || match == null) {
-            throw new Error('The GitHub Repository URL is invalid.');
+            throw new Error('GitHub repository URL is invalid.');
           }
           }
 
 
           const ghOrganizationName = match[1];
           const ghOrganizationName = match[1];
@@ -79,7 +79,7 @@ export class PluginService implements IPluginService {
     }
     }
   }
   }
 
 
-  async install(origin: GrowiPluginOrigin): Promise<void> {
+  async install(origin: GrowiPluginOrigin): Promise<string> {
     try {
     try {
     // download
     // download
       const ghUrl = new URL(origin.url);
       const ghUrl = new URL(origin.url);
@@ -89,7 +89,7 @@ export class PluginService implements IPluginService {
 
 
       const match = ghPathname.match(githubReposIdPattern);
       const match = ghPathname.match(githubReposIdPattern);
       if (ghUrl.hostname !== 'github.com' || match == null) {
       if (ghUrl.hostname !== 'github.com' || match == null) {
-        throw new Error('The GitHub Repository URL is invalid.');
+        throw new Error('GitHub repository URL is invalid.');
       }
       }
 
 
       const ghOrganizationName = match[1];
       const ghOrganizationName = match[1];
@@ -105,13 +105,13 @@ export class PluginService implements IPluginService {
       // save plugin metadata
       // save plugin metadata
       const plugins = await PluginService.detectPlugins(origin, installedPath);
       const plugins = await PluginService.detectPlugins(origin, installedPath);
       await this.savePluginMetaData(plugins);
       await this.savePluginMetaData(plugins);
+
+      return plugins[0].meta.name;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       throw err;
       throw err;
     }
     }
-
-    return;
   }
   }
 
 
   private async deleteOldPluginDocument(path: string): Promise<void> {
   private async deleteOldPluginDocument(path: string): Promise<void> {
@@ -144,8 +144,8 @@ export class PluginService implements IPluginService {
             else {
             else {
               rejects(res.status);
               rejects(res.status);
             }
             }
-          }).catch((e) => {
-            logger.error(e);
+          }).catch((err) => {
+            logger.error(err);
             // eslint-disable-next-line prefer-promise-reject-errors
             // eslint-disable-next-line prefer-promise-reject-errors
             rejects('Filed to download file.');
             rejects('Filed to download file.');
           });
           });
@@ -180,14 +180,9 @@ export class PluginService implements IPluginService {
       }
       }
     };
     };
 
 
-    try {
-      await downloadFile(requestUrl, zipFilePath);
-      await unzip(zipFilePath, unzippedPath);
-      await renamePath(`${unzippedPath}/${ghReposName}-${ghBranch}`, `${unzippedPath}/${ghReposName}`);
-    }
-    catch (err) {
-      throw err;
-    }
+    await downloadFile(requestUrl, zipFilePath);
+    await unzip(zipFilePath, unzippedPath);
+    await renamePath(`${unzippedPath}/${ghReposName}-${ghBranch}`, `${unzippedPath}/${ghReposName}`);
 
 
     return;
     return;
   }
   }
@@ -255,6 +250,40 @@ export class PluginService implements IPluginService {
     return [];
     return [];
   }
   }
 
 
+  /**
+   * Delete plugin
+   */
+  async deletePlugin(pluginId: mongoose.Types.ObjectId): Promise<string> {
+    const deleteFolder = (path: fs.PathLike): Promise<void> => {
+      return fs.promises.rm(path, { recursive: true });
+    };
+
+    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
+    const growiPlugins = await GrowiPlugin.findById(pluginId);
+
+    if (growiPlugins == null) {
+      throw new Error('No plugin found for this ID.');
+    }
+
+    try {
+      const growiPluginsPath = path.join(pluginStoringPath, growiPlugins.installedPath);
+      await deleteFolder(growiPluginsPath);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Filed to delete plugin repository.');
+    }
+
+    try {
+      await GrowiPlugin.deleteOne({ _id: pluginId });
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error('Filed to delete plugin from GrowiPlugin documents.');
+    }
+
+    return growiPlugins.meta.name;
+  }
 
 
   async retrieveThemeHref(theme: string): Promise<string | undefined> {
   async retrieveThemeHref(theme: string): Promise<string | undefined> {
 
 

+ 26 - 0
packages/app/src/stores/plugin.tsx

@@ -0,0 +1,26 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { GrowiPluginHasId } from '~/interfaces/plugin';
+
+type Plugins = {
+  plugins: GrowiPluginHasId[]
+}
+
+const pluginsFetcher = () => {
+  return async() => {
+    const reqUrl = '/plugins';
+
+    try {
+      const res = await apiv3Get(reqUrl);
+      return res.data;
+    }
+    catch (err) {
+      throw new Error(err);
+    }
+  };
+};
+
+export const useSWRxPlugins = (): SWRResponse<Plugins, Error> => {
+  return useSWR('/plugins', pluginsFetcher());
+};

+ 0 - 27
packages/app/src/stores/useInstalledPlugins.ts

@@ -1,27 +0,0 @@
-import useSWR, { SWRResponse } from 'swr';
-
-import type { SearchResult, SearchResultItem } from '../interfaces/github-api';
-
-const pluginFetcher = (owner: string, repo: string) => {
-  return async() => {
-    const reqUrl = `/api/fetch_repository?owner=${owner}&repo=${repo}`;
-    const data = await fetch(reqUrl).then(res => res.json());
-    return data.searchResultItem;
-  };
-};
-
-export const useInstalledPlugin = (owner: string, repo: string): SWRResponse<SearchResultItem | null, Error> => {
-  return useSWR(`${owner}/{repo}`, pluginFetcher(owner, repo));
-};
-
-const pluginsFetcher = () => {
-  return async() => {
-    const reqUrl = '/api/fetch_repositories';
-    const data = await fetch(reqUrl).then(res => res.json());
-    return data.searchResult;
-  };
-};
-
-export const useInstalledPlugins = (): SWRResponse<SearchResult | null, Error> => {
-  return useSWR('/api/fetch_repositories', pluginsFetcher());
-};