Преглед изворни кода

Merge pull request #7251 from arafubeatbox/feat/110272-112702-show-questionnaire-ui

Feat/110272 112702 show questionnaire UI
Haku Mizuki пре 3 година
родитељ
комит
542df9d623

+ 12 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -798,5 +798,17 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature"
+  },
+  "questionnaire": {
+    "give_us_feedback": "Give us feedback for improvements",
+    "thank_you_for_answering": "Thank you for answering",
+    "additional_feedback": "Send us additional feedback from here.",
+    "dont_show_again": "Don`t show again",
+    "skip": "Skip",
+    "agree": "Agree",
+    "disagree": "Disagree",
+    "answer": "Answer",
+    "no_answer": "No answer",
+    "settings": "Questionnaire settings"
   }
 }

+ 12 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -797,5 +797,17 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する"
+  },
+  "questionnaire": {
+    "give_us_feedback": "GROWI の改善のために、アンケートにご協力ください",
+    "thank_you_for_answering": "ご回答ありがとうございます",
+    "additional_feedback": "その他ご意見ご要望はこちらからお願い致します。",
+    "dont_show_again": "今後このアンケートを表示しない",
+    "skip": "スキップ",
+    "agree": "そう思う",
+    "disagree": "そう思わない",
+    "answer": "回答する",
+    "no_answer": "無回答",
+    "settings": "アンケート設定"
   }
 }

+ 43 - 0
packages/app/src/components/Questionnaire/Question.tsx

@@ -0,0 +1,43 @@
+import { IQuestionHasId } from '~/interfaces/questionnaire/question';
+
+type QuestionProps = {
+  question: IQuestionHasId,
+}
+
+const Question = ({ question }: QuestionProps): JSX.Element => {
+  return <div className="row mt-4">
+    <div className="col-6 d-flex align-items-center">
+      <span>
+        {question.text}
+      </span>
+    </div>
+    <div className="col-1 d-flex align-items-center p-0">
+      <div className="btn-group btn-group-toggle flex-fill grw-questionnaire-btn-group" data-toggle="buttons">
+        <label className="btn btn-outline-primary active">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-noAnswer`}/> -
+        </label>
+      </div>
+    </div>
+    <div className="col-5 d-flex align-items-center">
+      <div className="btn-group btn-group-toggle flex-fill grw-questionnaire-btn-group" data-toggle="buttons">
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option1`}/> 1
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option2`}/> 2
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option3`}/> 3
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option4`}/> 4
+        </label>
+        <label className="btn btn-outline-primary">
+          <input type="radio" name={`question-${question._id}`} id={`${question._id}-option5`}/> 5
+        </label>
+      </div>
+    </div>
+  </div>;
+};
+
+export default Question;

+ 78 - 0
packages/app/src/components/Questionnaire/QuestionnaireModal.tsx

@@ -0,0 +1,78 @@
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess } from '~/client/util/toastr';
+import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnaire-order';
+import { useCurrentUser } from '~/stores/context';
+import { useQuestionnaireModal } from '~/stores/modal';
+
+
+import Question from './Question';
+
+type QuestionnaireModalProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId
+}
+
+const QuestionnaireModal = ({ questionnaireOrder }: QuestionnaireModalProps): JSX.Element => {
+  const { data: currentUser } = useCurrentUser();
+
+  const { data: questionnaireModalData, close: closeQuestionnaireModal } = useQuestionnaireModal();
+  const isOpened = questionnaireModalData?.openedQuestionnaireId === questionnaireOrder._id;
+
+  const { t } = useTranslation();
+
+  const answerBtnClickHandler = () => {
+    toastSuccess(
+      <>
+        <div className="font-weight-bold">{t('questionnaire.thank_you_for_answering')}</div>
+        <div className="pt-2">{t('questionnaire.additional_feedback')}</div>
+      </>,
+      {
+        autoClose: 3000,
+        closeButton: true,
+      },
+    );
+    closeQuestionnaireModal();
+  };
+
+  return (<Modal
+    size="lg"
+    isOpen={isOpened}
+    toggle={() => closeQuestionnaireModal()}
+  >
+    <ModalHeader
+      tag="h4"
+      toggle={() => closeQuestionnaireModal()}
+      className="bg-primary text-light">
+      <span>{t('questionnaire.give_us_feedback')}</span>
+    </ModalHeader>
+    <ModalBody className="my-4">
+      <div className="container">
+        <h3 className="grw-modal-head">{questionnaireOrder.title}</h3>
+        <div className="row mt-4">
+          <div className="col-6"></div>
+          <div className="col-1 p-0 font-weight-bold text-center align-items-center">{t('questionnaire.no_answer')}</div>
+          <div className="col-5 d-flex justify-content-between align-items-center">
+            <span className="font-weight-bold">{t('questionnaire.disagree')}</span>
+            <span className="font-weight-bold">{t('questionnaire.agree')}</span>
+          </div>
+        </div>
+        {questionnaireOrder.questions?.map((question) => {
+          return <Question question={question} key={question._id.toString()}/>;
+        })}
+      </div>
+    </ModalBody>
+    <ModalFooter>
+      {currentUser?.admin
+        && <a href="" className="mr-auto d-flex align-items-center"><i className="material-icons mr-1">settings</i>{t('questionnaire.settings')}</a>}
+      <>
+        <button type="button" className="btn btn-outline-secondary mr-3">{t('questionnaire.dont_show_again')}</button>
+        <button type="button" className="btn btn-primary" onClick={answerBtnClickHandler}>{t('questionnaire.answer')}</button>
+      </>
+    </ModalFooter>
+  </Modal>);
+};
+
+export default QuestionnaireModal;

+ 6 - 0
packages/app/src/components/Questionnaire/QuestionnaireModalManager.module.scss

@@ -0,0 +1,6 @@
+.grw-questionnaire-toasts :global {
+  position: fixed;
+  right: 20px;
+  bottom: 20px;
+  width: 230px;
+}

+ 23 - 0
packages/app/src/components/Questionnaire/QuestionnaireModalManager.tsx

@@ -0,0 +1,23 @@
+import { useSWRxQuestionnaireOrders } from '~/stores/questionnaire';
+
+import QuestionnaireModal from './QuestionnaireModal';
+import QuestionnaireToast from './QuestionnaireToast';
+
+import styles from './QuestionnaireModalManager.module.scss';
+
+const QuestionnaireModalManager = ():JSX.Element => {
+  const { data: questionnaireOrders } = useSWRxQuestionnaireOrders();
+
+  return <>
+    {questionnaireOrders?.map((questionnaireOrder) => {
+      return <QuestionnaireModal questionnaireOrder={questionnaireOrder} key={questionnaireOrder._id} />;
+    })}
+    <div className={styles['grw-questionnaire-toasts']}>
+      {questionnaireOrders?.map((questionnaireOrder) => {
+        return <QuestionnaireToast questionnaireOrder={questionnaireOrder} key={questionnaireOrder._id}/>;
+      })}
+    </div>
+  </>;
+};
+
+export default QuestionnaireModalManager;

+ 38 - 0
packages/app/src/components/Questionnaire/QuestionnaireToast.tsx

@@ -0,0 +1,38 @@
+import { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnaire-order';
+import { useQuestionnaireModal } from '~/stores/modal';
+
+
+type QuestionnaireToastProps = {
+  questionnaireOrder: IQuestionnaireOrderHasId,
+}
+
+const QuestionnaireToast = ({ questionnaireOrder }: QuestionnaireToastProps): JSX.Element => {
+  const { open: openQuestionnaireModal } = useQuestionnaireModal();
+  const [isOpen, setIsOpen] = useState(true);
+
+  const { t } = useTranslation();
+
+  const answerBtnClickHandler = () => {
+    setIsOpen(false);
+    openQuestionnaireModal(questionnaireOrder._id);
+  };
+
+  return <div className={`toast ${isOpen ? 'show' : 'hide'}`}>
+    <div className="toast-header bg-info">
+      <strong className="mr-auto text-light">{questionnaireOrder.title}</strong>
+      <button type="button" className="ml-2 mb-1 close" onClick={() => setIsOpen(false)}>
+        <span aria-hidden="true" className="text-light">&times;</span>
+      </button>
+    </div>
+    <div className="toast-body bg-light d-flex justify-content-end">
+      <button type="button" className="btn btn-secondary mr-3" onClick={answerBtnClickHandler}>{t('questionnaire.answer')}</button>
+      <button type="button" className="btn btn-secondary" onClick={() => setIsOpen(false)}>{t('questionnaire.skip')}</button>
+    </div>
+  </div>;
+};
+
+export default QuestionnaireToast;

+ 4 - 0
packages/app/src/interfaces/questionnaire/condition.ts

@@ -1,3 +1,5 @@
+import { HasObjectId } from '@growi/core';
+
 import { GrowiServiceType } from './growi-info';
 import { UserType } from './user-info';
 
@@ -14,3 +16,5 @@ export interface ICondition {
   user: UserCondition
   growi: GrowiCondition
 }
+
+export type IConditionHasId = ICondition & HasObjectId;

+ 4 - 0
packages/app/src/interfaces/questionnaire/question.ts

@@ -1,3 +1,5 @@
+import { HasObjectId } from '@growi/core';
+
 export const QuestionType = { points: 'points', text: 'text' } as const;
 
 type QuestionType = typeof QuestionType[keyof typeof QuestionType];
@@ -6,3 +8,5 @@ export interface IQuestion {
   type: QuestionType
   text: string
 }
+
+export type IQuestionHasId = IQuestion & HasObjectId;

+ 13 - 2
packages/app/src/interfaces/questionnaire/questionnaire-order.ts

@@ -1,9 +1,20 @@
-import { ICondition } from './condition';
-import { IQuestion } from './question';
+import { HasObjectId } from '@growi/core';
+
+import { ICondition, IConditionHasId } from './condition';
+import { IQuestion, IQuestionHasId } from './question';
 
 export interface IQuestionnaireOrder {
+  title: string,
   showFrom: Date
   showUntil: Date
   questions: IQuestion[]
   condition: ICondition
 }
+
+export type IQuestionnaireOrderHasId = {
+  title: string,
+  showFrom: Date
+  showUntil: Date
+  questions: IQuestionHasId[]
+  condition: IConditionHasId
+} & HasObjectId;

+ 2 - 0
packages/app/src/pages/[[...path]].page.tsx

@@ -25,6 +25,7 @@ import { MainPane } from '~/components/Layout/MainPane';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
 import { PageContentFooter } from '~/components/PageContentFooter';
+import QuestionnaireModalManager from '~/components/Questionnaire/QuestionnaireModalManager';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -372,6 +373,7 @@ Page.getLayout = function getLayout(page) {
       <DescendantsPageListModal />
       <DrawioModal />
       <HandsontableModal />
+      <QuestionnaireModalManager />
     </>
   );
 };

+ 1 - 0
packages/app/src/server/models/questionnaire/questionnaire-order.ts

@@ -11,6 +11,7 @@ export interface QuestionnaireOrderDocument extends IQuestionnaireOrder, Documen
 export type QuestionnaireOrderModel = Model<QuestionnaireOrderDocument>
 
 const questionnaireOrderSchema = new Schema<QuestionnaireOrderDocument>({
+  title: { type: String, required: true },
   showFrom: { type: Date, required: true },
   showUntil: {
     type: Date,

+ 2 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -108,6 +108,8 @@ module.exports = (crowi, app) => {
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
+  router.use('/questionnaire-orders', require('./questionnaire-orders')());
+
 
   return [router, routerForAdmin, routerForAuth];
 };

+ 33 - 0
packages/app/src/server/routes/apiv3/questionnaire-orders.ts

@@ -0,0 +1,33 @@
+import express, { Request } from 'express';
+
+import QuestionnaireOrder from '~/server/models/questionnaire/questionnaire-order';
+import loggerFactory from '~/utils/logger';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:questionnaire');
+
+const router = express.Router();
+
+module.exports = () => {
+
+  router.get('/', async(req: Request, res: ApiV3Response) => {
+    const currentDate = new Date();
+    try {
+      const questionnaireOrders = await QuestionnaireOrder.find({
+        showUntil: {
+          $gte: currentDate,
+        },
+      });
+
+      return res.apiv3({ questionnaireOrders });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+
+};

+ 23 - 0
packages/app/src/stores/modal.tsx

@@ -580,3 +580,26 @@ export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Err
     },
   });
 };
+
+/*
+* QuestionnaireModals
+*/
+type QuestionnaireModalStatuses = {
+  openedQuestionnaireId: string | null,
+}
+
+type QuestionnaireModalStatusUtils = {
+  open(string): Promise<QuestionnaireModalStatuses | undefined>
+  close(): Promise<QuestionnaireModalStatuses | undefined>
+}
+
+export const useQuestionnaireModal = (status?: QuestionnaireModalStatuses): SWRResponse<QuestionnaireModalStatuses, Error> & QuestionnaireModalStatusUtils => {
+  const initialData: QuestionnaireModalStatuses = { openedQuestionnaireId: null };
+  const swrResponse = useStaticSWR<QuestionnaireModalStatuses, Error>('questionnaireModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (questionnaireOrderId: string) => swrResponse.mutate({ openedQuestionnaireId: questionnaireOrderId }),
+    close: () => swrResponse.mutate({ openedQuestionnaireId: null }),
+  };
+};

+ 13 - 0
packages/app/src/stores/questionnaire.tsx

@@ -0,0 +1,13 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IQuestionnaireOrderHasId } from '~/interfaces/questionnaire/questionnaire-order';
+
+export const useSWRxQuestionnaireOrders = (): SWRResponse<IQuestionnaireOrderHasId[], Error> => {
+  return useSWR(
+    '/questionnaire-orders',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.questionnaireOrders;
+    }),
+  );
+};

+ 2 - 2
packages/app/src/styles/style-app.scss

@@ -18,8 +18,8 @@
 
 // icons
 
-// DO NOT CHANGE THER OERDER OF font-awesome AND simple-line-icons.
-// font-familiy used in simple-line-icons has to be prioritized than the one used in font-awesome.
+// DO NOT CHANGE THE ORDER OF font-awesome AND simple-line-icons.
+// font-family used in simple-line-icons has to be prioritized than the one used in font-awesome.
 @import '~font-awesome';
 @import '~simple-line-icons';
 @import '~material-icons/iconfont/filled';

+ 14 - 0
packages/preset-themes/src/styles/theme/_apply-colors.scss

@@ -687,3 +687,17 @@ Emoji picker modal
 .emoji-picker-modal {
   background-color: transparent !important;
 }
+
+/*
+  Questionnaire modal
+*/
+.grw-questionnaire-btn-group {
+  .btn-outline-primary {
+    @include button-outline-variant(
+      lighten($primary, 30%),
+      $color-hover: color-contrast($primary),
+      $active-background: $primary,
+      $active-border: lighten($primary, 30%),
+    );
+  }
+}