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

Merge pull request #2142 from weseek/feat/new-pagepath-component

Feat/new pagepath component
Yuki Takei 6 лет назад
Родитель
Сommit
9d61d77f97

+ 18 - 4
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -7,11 +7,17 @@ import { isTrashPage } from '@commons/util/path-utils';
 
 
 import { createSubscribedElement } from '../UnstatedUtils';
 import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
+
+import PagePath from '../../models/PagePath';
+import LinkedPagePath from '../../models/LinkedPagePath';
+import PagePathHierarchicalLink from '../PageList/PagePathHierarchicalLink';
+
 import RevisionPath from '../Page/RevisionPath';
 import RevisionPath from '../Page/RevisionPath';
 import PageContainer from '../../services/PageContainer';
 import PageContainer from '../../services/PageContainer';
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
 import LikeButton from '../LikeButton';
 import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
 import BookmarkButton from '../BookmarkButton';
+
 import PageCreator from './PageCreator';
 import PageCreator from './PageCreator';
 import RevisionAuthor from './RevisionAuthor';
 import RevisionAuthor from './RevisionAuthor';
 
 
@@ -25,16 +31,23 @@ const GrowiSubNavigation = (props) => {
   const isPageNotFound = pageId == null;
   const isPageNotFound = pageId == null;
   const isPageInTrash = isTrashPage(path);
   const isPageInTrash = isTrashPage(path);
 
 
+  const pagePathModel = new PagePath(pageContainer.state.path, false, true);
+  const linkedPagePathFormer = new LinkedPagePath(pagePathModel.former);
+  const renderFormerLink = () => (
+    <>
+      { !pagePathModel.isRoot && <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} /> }
+    </>
+  );
+
   // Display only the RevisionPath
   // Display only the RevisionPath
   if (isPageNotFound || isPageForbidden || isPageInTrash) {
   if (isPageNotFound || isPageForbidden || isPageInTrash) {
     return (
     return (
-      <div className="d-flex align-items-center px-3 py-3 grw-subnavbar">
+      <div className="px-3 py-3 grw-subnavbar">
+        { renderFormerLink() }
         <h1 className="m-0">
         <h1 className="m-0">
           <RevisionPath
           <RevisionPath
-            behaviorType={appContainer.config.behaviorType}
             pageId={pageId}
             pageId={pageId}
             pagePath={pageContainer.state.path}
             pagePath={pageContainer.state.path}
-            isPageNotFound={isPageNotFound}
             isPageForbidden={isPageForbidden}
             isPageForbidden={isPageForbidden}
             isPageInTrash={isPageInTrash}
             isPageInTrash={isPageInTrash}
           />
           />
@@ -60,8 +73,9 @@ const GrowiSubNavigation = (props) => {
 
 
       {/* Page Path */}
       {/* Page Path */}
       <div>
       <div>
+        { renderFormerLink() }
         <h1 className="m-0">
         <h1 className="m-0">
-          <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageId} pagePath={pageContainer.state.path} />
+          <RevisionPath pageId={pageId} pagePath={pageContainer.state.path} />
         </h1>
         </h1>
         { !isPageNotFound && !isPageForbidden && (
         { !isPageNotFound && !isPageForbidden && (
           <TagLabels />
           <TagLabels />

+ 6 - 1
src/client/js/components/Page/CopyDropdown.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { withTranslation } from 'react-i18next';
+
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Tooltip,
   Tooltip,
@@ -8,7 +10,7 @@ import {
 
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
 
-export default class CopyDropdown extends React.Component {
+class CopyDropdown extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -127,7 +129,10 @@ export default class CopyDropdown extends React.Component {
 
 
 CopyDropdown.propTypes = {
 CopyDropdown.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
+
   pagePath: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   pageId: PropTypes.string,
   buttonStyle: PropTypes.object,
   buttonStyle: PropTypes.object,
 };
 };
+
+export default withTranslation()(CopyDropdown);

+ 22 - 149
src/client/js/components/Page/RevisionPath.jsx

@@ -3,178 +3,51 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import urljoin from 'url-join';
+import PagePath from '../../models/PagePath';
+import LinkedPagePath from '../../models/LinkedPagePath';
+import PagePathHierarchicalLink from '../PageList/PagePathHierarchicalLink';
 
 
 import CopyDropdown from './CopyDropdown';
 import CopyDropdown from './CopyDropdown';
 
 
-class RevisionPath extends React.Component {
+const RevisionPath = (props) => {
+  // define styles
+  const buttonStyle = {
+    marginLeft: '0.5em',
+    padding: '0 2px',
+  };
 
 
-  constructor(props) {
-    super(props);
+  const {
+    pageId, isPageInTrash, isPageForbidden,
+  } = props;
 
 
-    this.state = {
-      pages: [],
-      isListPage: false,
-      isLinkToListPage: true,
-    };
+  const pagePathModel = new PagePath(props.pagePath, false, true);
+  const linkedPagePathLatter = new LinkedPagePath(pagePathModel.latter);
 
 
-    // retrieve xss library from window
-    this.xss = window.xss;
-  }
-
-  componentWillMount() {
-    // whether list page or not
-    const isListPage = this.props.pagePath.match(/\/$/);
-    this.setState({ isListPage });
-
-    // whether set link to '/'
-    const { behaviorType } = this.props;
-    const isLinkToListPage = (behaviorType === 'crowi');
-    this.setState({ isLinkToListPage });
-
-    this.generateHierarchyData();
-  }
-
-  /**
-   * 1. split `pagePath` with '/'
-   * 2. list hierararchical page paths
-   *
-   * e.g.
-   *  when `pagePath` is '/foo/bar/baz`
-   *  return:
-   *  [
-   *    { pagePath: '/foo',         pageName: 'foo' },
-   *    { pagePath: '/foo/bar',     pageName: 'bar' },
-   *    { pagePath: '/foo/bar/baz', pageName: 'baz' },
-   *  ]
-   */
-  generateHierarchyData() {
-    // generate pages obj
-    const splitted = this.props.pagePath.split(/\//);
-    splitted.shift(); // omit first element with shift()
-    if (splitted[splitted.length - 1] === '') {
-      splitted.pop(); // omit last element with unshift()
-    }
-
-    const pages = [];
-    const pagePaths = [];
-    splitted.forEach((pageName) => {
-      pagePaths.push(encodeURIComponent(pageName));
-      pages.push({
-        pagePath: urljoin('/', ...pagePaths),
-        pageName: this.xss.process(pageName),
-      });
-    });
-
-    this.setState({ pages });
-  }
-
-  showToolTip() {
-    const buttonId = '#copyPagePathDropdown';
-    $(buttonId).tooltip('show');
-    setTimeout(() => {
-      $(buttonId).tooltip('hide');
-    }, 1000);
-  }
-
-  generateLinkElementToListPage(pagePath, isLinkToListPage, isLastElement) {
-    /* eslint-disable no-else-return */
-    if (isLinkToListPage) {
-      return <a href={`${pagePath}/`} className={(isLastElement && !this.state.isListPage) ? 'last-path' : ''}>/</a>;
-    }
-    else if (!isLastElement) {
-      return <span>/</span>;
-    }
-    else {
-      return <span></span>;
-    }
-    /* eslint-enable no-else-return */
-  }
-
-  render() {
-    // define styles
-    const separatorStyle = {
-      marginLeft: '0.2em',
-      marginRight: '0.2em',
-    };
-    const buttonStyle = {
-      marginLeft: '0.5em',
-      padding: '0 2px',
-    };
-
-    const { isPageInTrash, isPageForbidden } = this.props;
-    const pageLength = this.state.pages.length;
-
-    const rootElement = isPageInTrash
-      ? (
-        <>
-          <span className="path-segment">
-            <a href="/trash"><i className="icon-trash"></i></a>
-          </span>
-          <span className="separator" style={separatorStyle}><a href="/">/</a></span>
-        </>
-      )
-      : (
-        <>
-          <span className="path-segment">
-            <a href="/">
-              <i className="icon-home"></i>
-              <span className="separator" style={separatorStyle}>/</span>
-            </a>
-          </span>
-        </>
-      );
-
-    const afterElements = [];
-    this.state.pages.forEach((page, index) => {
-      const isLastElement = (index === pageLength - 1);
-
-      // add elements for page
-      afterElements.push(
-        <span key={page.pagePath} className="path-segment">
-          <a href={page.pagePath}>{page.pageName}</a>
-        </span>,
-      );
-
-      // add elements for '/'
-      afterElements.push(
-        <span key={`${page.pagePath}/`} className="separator" style={separatorStyle}>
-          {this.generateLinkElementToListPage(page.pagePath, this.state.isLinkToListPage, isLastElement)}
-        </span>,
-      );
-    });
-
-    return (
+  return (
+    <>
       <span className="d-flex align-items-center flex-wrap">
       <span className="d-flex align-items-center flex-wrap">
-
-        {rootElement}
-        {afterElements}
-
-        <CopyDropdown t={this.props.t} pagePath={this.props.pagePath} pageId={this.props.pageId} buttonStyle={buttonStyle}></CopyDropdown>
-
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={pagePathModel.isRoot ? undefined : pagePathModel.former} />
+        <CopyDropdown pagePath={props.pagePath} pageId={pageId} buttonStyle={buttonStyle} />
         { !isPageInTrash && !isPageForbidden && (
         { !isPageInTrash && !isPageForbidden && (
           <a href="#edit" className="d-block d-edit-none text-muted btn btn-secondary bg-transparent btn-edit border-0" style={buttonStyle}>
           <a href="#edit" className="d-block d-edit-none text-muted btn btn-secondary bg-transparent btn-edit border-0" style={buttonStyle}>
             <i className="icon-note" />
             <i className="icon-note" />
           </a>
           </a>
         ) }
         ) }
       </span>
       </span>
-    );
-  }
-
-}
+    </>
+  );
+};
 
 
 RevisionPath.propTypes = {
 RevisionPath.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  behaviorType: PropTypes.string.isRequired,
+
   pagePath: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   pageId: PropTypes.string,
-  isPageNotFound: PropTypes.bool,
   isPageForbidden: PropTypes.bool,
   isPageForbidden: PropTypes.bool,
   isPageInTrash: PropTypes.bool,
   isPageInTrash: PropTypes.bool,
 };
 };
 
 
 RevisionPath.defaultProps = {
 RevisionPath.defaultProps = {
-  isPageNotFound: false,
   isPageForbidden: false,
   isPageForbidden: false,
   isPageInTrash: false,
   isPageInTrash: false,
 };
 };

+ 5 - 7
src/client/js/components/PageList/Page.jsx

@@ -3,24 +3,24 @@ import PropTypes from 'prop-types';
 
 
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 import PageListMeta from './PageListMeta';
 import PageListMeta from './PageListMeta';
-import PagePath from './PagePath';
+import PagePathLabel from './PagePathLabel';
 
 
 export default class Page extends React.Component {
 export default class Page extends React.Component {
 
 
   render() {
   render() {
     const {
     const {
-      page, noLink, excludePathString,
+      page, noLink,
     } = this.props;
     } = this.props;
 
 
-    let pagePath = <PagePath page={page} excludePathString={excludePathString} />;
+    let pagePathElem = <PagePathLabel page={page} />;
     if (!noLink != null) {
     if (!noLink != null) {
-      pagePath = <a className="text-break" href={page.pagePath}>{pagePath}</a>;
+      pagePathElem = <a className="text-break" href={page.path}>{pagePathElem}</a>;
     }
     }
 
 
     return (
     return (
       <>
       <>
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
-        {pagePath}
+        {pagePathElem}
         <PageListMeta page={page} />
         <PageListMeta page={page} />
       </>
       </>
     );
     );
@@ -30,11 +30,9 @@ export default class Page extends React.Component {
 
 
 Page.propTypes = {
 Page.propTypes = {
   page: PropTypes.object.isRequired,
   page: PropTypes.object.isRequired,
-  excludePathString: PropTypes.string,
   noLink: PropTypes.bool,
   noLink: PropTypes.bool,
 };
 };
 
 
 Page.defaultProps = {
 Page.defaultProps = {
-  excludePathString: '',
   noLink: false,
   noLink: false,
 };
 };

+ 13 - 48
src/client/js/components/PageList/PagePath.jsx

@@ -1,59 +1,24 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import escapeStringRegexp from 'escape-string-regexp';
+import PagePathLabel from './PagePathLabel';
 
 
-export default class PagePath extends React.Component {
-
-  getShortPath(path) {
-    const name = path.replace(/(\/)$/, '');
-
-    // /.../hoge/YYYY/MM/DD 形式のページ
-    if (name.match(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/)) {
-      return name.replace(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/, '$1');
-    }
-
-    // /.../hoge/YYYY/MM 形式のページ
-    if (name.match(/.+\/([^/]+\/\d{4}\/\d{2})$/)) {
-      return name.replace(/.+\/([^/]+\/\d{4}\/\d{2})$/, '$1');
-    }
-
-    // /.../hoge/YYYY 形式のページ
-    if (name.match(/.+\/([^/]+\/\d{4})$/)) {
-      return name.replace(/.+\/([^/]+\/\d{4})$/, '$1');
-    }
-
-    // ページの末尾を拾う
-    return name.replace(/.+\/(.+)?$/, '$1');
-  }
-
-  render() {
-    const page = this.props.page;
-    const isShortPathOnly = this.props.isShortPathOnly;
-    const pagePath = decodeURIComponent(page.path);
-    const shortPath = this.getShortPath(pagePath);
-
-    const shortPathEscaped = escapeStringRegexp(shortPath);
-    const pathPrefix = pagePath.replace(new RegExp(`${shortPathEscaped}(/)?$`), '');
-
-    let classNames = ['page-path'];
-    classNames = classNames.concat(this.props.additionalClassNames);
-
-    if (isShortPathOnly) {
-      return <span className={classNames.join(' ')}>{shortPath}</span>;
-    }
-
-    return <span className={classNames.join(' ')}>{pathPrefix}<strong>{shortPath}</strong></span>;
-  }
-
-}
+/**
+ * !!DEPRECATED!!
+ *
+ * maintained for backward compatibility for growi-lsx-plugin(<= 3.1.1)
+ */
+const PagePath = props => (
+  <PagePathLabel isLatterOnly={props.isShortPathOnly} {...props} />
+);
 
 
 PagePath.propTypes = {
 PagePath.propTypes = {
-  page: PropTypes.object.isRequired,
   isShortPathOnly: PropTypes.bool,
   isShortPathOnly: PropTypes.bool,
-  additionalClassNames: PropTypes.array,
+  ...PagePathLabel.propTypes,
 };
 };
 
 
 PagePath.defaultProps = {
 PagePath.defaultProps = {
-  additionalClassNames: [],
+  ...PagePathLabel.defaultProps,
 };
 };
+
+export default PagePath;

+ 66 - 0
src/client/js/components/PageList/PagePathHierarchicalLink.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import urljoin from 'url-join';
+
+import LinkedPagePath from '../../models/LinkedPagePath';
+
+
+const PagePathHierarchicalLink = (props) => {
+  const { linkedPagePath, basePath } = props;
+
+  // render root element
+  if (linkedPagePath.isRoot) {
+    if (basePath != null) {
+      return null;
+    }
+
+    return props.isPageInTrash
+      ? (
+        <>
+          <span className="path-segment">
+            <a href="/trash"><i className="icon-trash"></i></a>
+          </span>
+          <span className="separator"><a href="/">/</a></span>
+        </>
+      )
+      : (
+        <>
+          <span className="path-segment">
+            <a href="/">
+              <i className="icon-home"></i>
+              <span className="separator">/</span>
+            </a>
+          </span>
+        </>
+      );
+  }
+
+  const isParentExists = linkedPagePath.parent != null;
+  const isParentRoot = isParentExists && linkedPagePath.parent.isRoot;
+  const isSeparatorRequired = isParentExists && !isParentRoot;
+
+  const href = encodeURI(urljoin(basePath || '', linkedPagePath.href));
+
+  return (
+    <>
+      { isParentExists && (
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePath.parent} basePath={basePath} />
+      ) }
+      { isSeparatorRequired && (
+        <span className="separator">/</span>
+      ) }
+
+      <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
+    </>
+  );
+};
+
+PagePathHierarchicalLink.propTypes = {
+  linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
+  basePath: PropTypes.string,
+
+  isPageInTrash: PropTypes.bool, // TODO: omit
+};
+
+export default PagePathHierarchicalLink;

+ 34 - 0
src/client/js/components/PageList/PagePathLabel.jsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import PagePath from '../../models/PagePath';
+
+const PagePathLabel = (props) => {
+
+  const pagePath = new PagePath(props.page.path, false, true);
+
+  let classNames = ['page-path'];
+  classNames = classNames.concat(props.additionalClassNames);
+
+  if (props.isLatterOnly) {
+    return <span className={classNames.join(' ')}>{pagePath.latter}</span>;
+  }
+
+  const textElem = (pagePath.former == null && pagePath.latter == null)
+    ? <><strong>/</strong></>
+    : <>{pagePath.former}/<strong>{pagePath.latter}</strong></>;
+
+  return <span className={classNames.join(' ')}>{textElem}</span>;
+};
+
+PagePathLabel.propTypes = {
+  page: PropTypes.object.isRequired,
+  isLatterOnly: PropTypes.bool,
+  additionalClassNames: PropTypes.array,
+};
+
+PagePathLabel.defaultProps = {
+  additionalClassNames: [],
+};
+
+export default PagePathLabel;

+ 2 - 2
src/client/js/components/SearchTypeahead.jsx

@@ -6,7 +6,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
 import UserPicture from './User/UserPicture';
 import UserPicture from './User/UserPicture';
 import PageListMeta from './PageList/PageListMeta';
 import PageListMeta from './PageList/PageListMeta';
-import PagePath from './PageList/PagePath';
+import PagePathLabel from './PageList/PagePathLabel';
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import { createSubscribedElement } from './UnstatedUtils';
 import { createSubscribedElement } from './UnstatedUtils';
 
 
@@ -164,7 +164,7 @@ class SearchTypeahead extends React.Component {
     return (
     return (
       <span>
       <span>
         <UserPicture user={page.lastUpdateUser} size="sm" noLink />
         <UserPicture user={page.lastUpdateUser} size="sm" noLink />
-        <PagePath page={page} />
+        <PagePathLabel page={page} />
         <PageListMeta page={page} />
         <PageListMeta page={page} />
       </span>
       </span>
     );
     );

+ 30 - 0
src/client/js/models/LinkedPagePath.js

@@ -0,0 +1,30 @@
+import { pathUtils } from 'growi-commons';
+
+import PagePath from './PagePath';
+
+/**
+ * Linked Array Structured PagePath Model
+ */
+export default class LinkedPagePath {
+
+  constructor(path, skipNormalize = false) {
+
+    const pagePath = new PagePath(path, skipNormalize);
+
+    this.pathName = pagePath.latter;
+    this.isRoot = pagePath.isRoot;
+    this.parent = pagePath.isRoot
+      ? null
+      : new LinkedPagePath(pagePath.former, true);
+
+  }
+
+  get href() {
+    if (this.isRoot) {
+      return '';
+    }
+
+    return pathUtils.normalizePath(`${this.parent.href}/${this.pathName}`);
+  }
+
+}

+ 43 - 0
src/client/js/models/PagePath.js

@@ -0,0 +1,43 @@
+import { pathUtils } from 'growi-commons';
+
+// https://regex101.com/r/BahpKX/2
+const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
+// https://regex101.com/r/WVpPpY/1
+const PATTERN_DEFAULT = /^((.*)\/)?([^/]+)$/;
+
+export default class PagePath {
+
+  constructor(path, skipNormalize = false, evalDatePath = false) {
+
+    this.isRoot = false;
+    this.former = null;
+    this.latter = null;
+
+    // root
+    if (path == null || path === '' || path === '/') {
+      this.isRoot = true;
+      this.latter = '/';
+      return;
+    }
+
+    const pagePath = skipNormalize ? path : pathUtils.normalizePath(path);
+    this.latter = pagePath;
+
+    // evaluate date path
+    if (evalDatePath) {
+      const matchDate = pagePath.match(PATTERN_INCLUDE_DATE);
+      if (matchDate != null) {
+        this.former = matchDate[1];
+        this.latter = matchDate[2];
+        return;
+      }
+    }
+
+    const matchDefault = pagePath.match(PATTERN_DEFAULT);
+    if (matchDefault != null) {
+      this.former = matchDefault[2];
+      this.latter = matchDefault[3];
+    }
+  }
+
+}

+ 5 - 0
src/client/styles/scss/_subnav.scss

@@ -77,6 +77,11 @@ header.grw-header {
     line-height: 1.1em;
     line-height: 1.1em;
   }
   }
 
 
+  .separator {
+    margin-right: 0.2em;
+    margin-left: 0.2em;
+  }
+
   ul.authors {
   ul.authors {
     padding-left: 1.5em;
     padding-left: 1.5em;
     margin: 0;
     margin: 0;