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

Merge branch 'dev/4.0.x' into support/reactify-create-page

itizawa 5 лет назад
Родитель
Сommit
87b8b82048

+ 31 - 0
src/client/js/components/FormattedDistanceDate.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { format, formatDistanceStrict } from 'date-fns';
+import { UncontrolledTooltip } from 'reactstrap';
+
+const FormattedDistanceDate = (props) => {
+
+  // cast to date if string
+  const date = (typeof props.date === 'string') ? new Date(props.date) : props.date;
+
+  const baseDate = props.baseDate || new Date();
+
+  const elemId = `grw-fdd-${props.id}`;
+  const dateFormatted = format(date, 'yyyy/MM/dd HH:mm');
+
+  return (
+    <>
+      <span id={elemId}>{formatDistanceStrict(date, baseDate)}</span>
+      <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>{dateFormatted}</UncontrolledTooltip>
+    </>
+  );
+};
+
+FormattedDistanceDate.propTypes = {
+  id: PropTypes.string.isRequired,
+  date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired,
+  baseDate: PropTypes.instanceOf(Date),
+};
+
+export default FormattedDistanceDate;

+ 6 - 6
src/client/js/components/PageComment/Comment.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { format, formatDistanceStrict } from 'date-fns';
+import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
@@ -9,6 +9,8 @@ import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 
 import { createSubscribedElement } from '../UnstatedUtils';
+
+import FormattedDistanceDate from '../FormattedDistanceDate';
 import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
@@ -175,9 +177,6 @@ class Comment extends React.PureComponent {
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
-    const commentedDateId = `commentDate-${comment._id}`;
-    const commentedDate = <span id={commentedDateId}>{formatDistanceStrict(createdAt, new Date())}</span>;
-    const commentedDateFormatted = format(createdAt, 'yyyy/MM/dd HH:mm');
     const editedDateId = `editedDate-${comment._id}`;
     const editedDateFormatted = isEdited
       ? format(updatedAt, 'yyyy/MM/dd HH:mm')
@@ -206,8 +205,9 @@ class Comment extends React.PureComponent {
               </div>
               <div className="page-comment-body">{commentBody}</div>
               <div className="page-comment-meta">
-                <span><a href={`#${commentId}`}>{commentedDate}</a></span>
-                <UncontrolledTooltip placement="bottom" fade={false} target={commentedDateId}>{commentedDateFormatted}</UncontrolledTooltip>
+                <a href={`#${commentId}`}>
+                  <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+                </a>
                 { isEdited && (
                   <>
                     <span id={editedDateId}>&nbsp;(edited)</span>

+ 76 - 11
src/client/js/components/Sidebar/History.jsx

@@ -1,5 +1,5 @@
 import React from 'react';
-// import PropTypes from 'prop-types';
+import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
@@ -8,39 +8,104 @@ import {
   MenuSection,
 } from '@atlaskit/navigation-next';
 
+import loggerFactory from '@alias/logger';
+
+import DevidedPagePath from '@commons/models/devided-page-path';
+import LinkedPagePath from '@commons/models/linked-page-path';
+import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
+
 import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import { toastError } from '../../util/apiNotification';
+
+import FormattedDistanceDate from '../FormattedDistanceDate';
+import UserPicture from '../User/UserPicture';
 
+const logger = loggerFactory('growi:History');
 class History extends React.Component {
 
   static propTypes = {
+    t: PropTypes.func.isRequired, // i18next
+    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   };
 
-  state = {
-  };
+  constructor(props) {
+    super(props);
+
+    this.reloadData = this.reloadData.bind(this);
+  }
+
+  async componentDidMount() {
+    this.reloadData();
+  }
+
+  async reloadData() {
+    const { appContainer } = this.props;
 
-  renderHeaderWordmark() {
-    return <h3>History</h3>;
+    try {
+      await appContainer.retrieveRecentlyUpdated();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error, 'Error occurred in updating History');
+    }
+  }
+
+  PageItem = ({ page }) => {
+    const dPagePath = new DevidedPagePath(page.path, false, true);
+    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+    const FormerLink = () => (
+      <div className="grw-page-path-text-muted-container small">
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+      </div>
+    );
+
+    return (
+      <li className="list-group-item">
+        <div className="d-flex w-100">
+          <UserPicture user={page.lastUpdatedUser} size="md" />
+          <div className="flex-grow-1 ml-2">
+            { !dPagePath.isRoot && <FormerLink /> }
+            <h4 className="mb-1">
+              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+            </h4>
+            <div className="text-right small">
+              <FormattedDistanceDate id={page.id} date={page.updatedAt} />
+            </div>
+          </div>
+        </div>
+      </li>
+    );
   }
 
   render() {
+    const { PageItem } = this;
+    const { t } = this.props;
+    const { recentlyUpdatedPages } = this.props.appContainer.state;
+
     return (
-      <>
+      <div className="grw-sidebar-history">
         <HeaderSection>
           { () => (
-            <div className="grw-sidebar-header-container">
-              {this.renderHeaderWordmark()}
+            <div className="grw-sidebar-header-container p-3 d-flex">
+              <h3>{t('History')}</h3>
+              <button type="button" className="btn xs btn-secondary ml-auto" onClick={this.reloadData}>
+                <i className="icon icon-reload"></i>
+              </button>
             </div>
           ) }
         </HeaderSection>
         <MenuSection>
           { () => (
-            <div className="grw-sidebar-content-container">
-              <span>(TBD) History Contents</span>
+            <div className="grw-sidebar-content-container p-3">
+              <ul className="list-group list-group-flush">
+                { recentlyUpdatedPages.map(page => <PageItem key={page.id} page={page} />) }
+              </ul>
             </div>
           ) }
         </MenuSection>
-      </>
+      </div>
     );
   }
 

+ 1 - 1
src/client/js/components/User/UserPicture.jsx

@@ -101,7 +101,7 @@ export default class UserPicture extends React.Component {
 
 UserPicture.propTypes = {
   user: PropTypes.object,
-  size: PropTypes.string,
+  size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
   noLink: PropTypes.bool,
   noTooltip: PropTypes.bool,
 };

+ 8 - 0
src/client/js/services/AppContainer.js

@@ -34,7 +34,10 @@ export default class AppContainer extends Container {
       preferDarkModeByMediaQuery: false,
       preferDarkModeByUser: null,
       isDrawerOpened: false,
+
       isPageCreateModalShown: false,
+
+      recentlyUpdatedPages: [],
     };
 
     const body = document.querySelector('body');
@@ -276,6 +279,11 @@ export default class AppContainer extends Container {
     });
   }
 
+  async retrieveRecentlyUpdated() {
+    const { data } = await this.apiv3Get('/pages/recent');
+    this.setState({ recentlyUpdatedPages: data.pages });
+  }
+
   fetchUsers() {
     const interval = 1000 * 60 * 15; // 15min
     const currentTime = new Date();

+ 14 - 0
src/client/styles/scss/_page-path.scss

@@ -0,0 +1,14 @@
+.grw-page-path-hierarchical-link {
+  .separator {
+    margin-right: 0.2em;
+    margin-left: 0.2em;
+  }
+}
+
+.grw-page-path-text-muted-container .grw-page-path-hierarchical-link {
+  // overwrite link color
+  &,
+  a {
+    @extend .text-muted;
+  }
+}

+ 0 - 2
src/client/styles/scss/_sidebar.scss

@@ -78,8 +78,6 @@
 }
 
 .grw-sidebar-header-container {
-  padding: 10px;
-
   h3 {
     margin-bottom: 0;
   }

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -47,6 +47,7 @@
 @import 'notification';
 @import 'on-edit';
 @import 'page_list';
+@import 'page-path';
 @import 'page';
 @import 'search';
 @import 'shortcuts';

+ 11 - 8
src/client/styles/scss/theme/_apply-colors.scss

@@ -47,12 +47,15 @@ $input-focus-color: $color-global;
   .list-group-item {
     color: $color-list;
     background-color: $bgcolor-list;
-    &:hover {
-      background-color: $color-list-hover;
-    }
-    &.active {
-      color: $color-list-active;
-      background-color: $bgcolor-list-active;
+
+    &.list-group-item-action {
+      &:hover {
+        background-color: $color-list-hover;
+      }
+      &.active {
+        color: $color-list-active;
+        background-color: $bgcolor-list-active;
+      }
     }
   }
 }
@@ -118,12 +121,12 @@ $input-focus-color: $color-global;
     }
   }
   div[data-testid='GlobalNavigation'] {
-    div {
+    > div {
       background-color: $bgcolor-sidebar;
     }
   }
   div[data-testid='ContextualNavigation'] {
-    div {
+    > div {
       color: $color-sidebar-context;
       background-color: $bgcolor-sidebar-context;
     }

+ 15 - 9
src/lib/components/PagePathHierarchicalLink.jsx

@@ -39,32 +39,38 @@ const PagePathHierarchicalLink = (props) => {
   const isParentExists = linkedPagePath.parent != null;
   const isParentRoot = linkedPagePath.parent?.isRoot;
   const isSeparatorRequired = isParentExists && !isParentRoot;
-  const isParentInTrash = isInTrash || linkedPagePath.isInTrash;
 
   const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
 
+  // eslint-disable-next-line react/prop-types
+  const RootElm = ({ children }) => {
+    return props.isInnerElem
+      ? <>{children}</>
+      : <span className="grw-page-path-hierarchical-link">{children}</span>;
+  };
+
   return (
-    <>
+    <RootElm>
       { isParentExists && (
-        <PagePathHierarchicalLink
-          linkedPagePath={linkedPagePath.parent}
-          basePath={basePath}
-          isInTrash={isParentInTrash}
-        />
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePath.parent} basePath={basePath} isInnerElem />
       ) }
       { isSeparatorRequired && (
         <span className="separator">/</span>
       ) }
 
       <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
-    </>
+    </RootElm>
   );
 };
 
 PagePathHierarchicalLink.propTypes = {
   linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
   basePath: PropTypes.string,
-  isInTrash: PropTypes.bool,
+
+  // !!INTERNAL USE ONLY!!
+  isInnerElem: PropTypes.bool,
+
+  isInTrash: PropTypes.bool, // TODO: omit
 };
 
 export default PagePathHierarchicalLink;

+ 40 - 0
src/server/routes/apiv3/pages.js

@@ -19,6 +19,46 @@ module.exports = (crowi) => {
 
   const Page = crowi.model('Page');
 
+  /**
+   * @swagger
+   *
+   *    /pages/recent:
+   *      get:
+   *        tags: [Pages]
+   *        description: Get recently updated pages
+   *        responses:
+   *          200:
+   *            description: Return pages recently updated
+   *
+   */
+  router.get('/recent', loginRequired, async(req, res) => {
+    const limit = 20;
+    const offset = parseInt(req.query.offset) || 0;
+
+    const queryOptions = {
+      offset,
+      limit,
+      includeTrashed: false,
+      isRegExpEscapedFromPath: true,
+      sort: 'updatedAt',
+      desc: -1,
+    };
+
+    try {
+      const result = await Page.findListWithDescendants('/', req.user, queryOptions);
+      if (result.pages.length > limit) {
+        result.pages.pop();
+      }
+
+      return res.apiv3(result);
+    }
+    catch (err) {
+      res.code = 'unknown';
+      logger.error('Failed to get recent pages', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
   /**
   * @swagger
   *