From 9498c7b7a0a52258f60923cbe415648f41486ba4 Mon Sep 17 00:00:00 2001
From: Fedor Katurov <gotham48@gmail.com>
Date: Sat, 18 Apr 2020 18:07:39 +0700
Subject: [PATCH] search load more

---
 .../flow/FlowSearchResults/index.tsx          | 42 ++++++++++++-
 .../flow/FlowSearchResults/styles.scss        | 60 ++++++++++++++++++-
 src/components/flow/FlowStamp/index.tsx       |  8 ++-
 src/components/flow/FlowStamp/styles.scss     |  6 ++
 src/constants/api.ts                          |  4 +-
 src/containers/flow/FlowLayout/index.tsx      | 22 ++++---
 src/redux/flow/actions.ts                     |  4 ++
 src/redux/flow/api.ts                         |  8 ++-
 src/redux/flow/constants.ts                   |  1 +
 src/redux/flow/reducer.ts                     |  8 ++-
 src/redux/flow/sagas.ts                       | 59 ++++++++++++++++--
 src/styles/variables.scss                     |  3 +-
 12 files changed, 199 insertions(+), 26 deletions(-)

diff --git a/src/components/flow/FlowSearchResults/index.tsx b/src/components/flow/FlowSearchResults/index.tsx
index 7bd3da35..81dba800 100644
--- a/src/components/flow/FlowSearchResults/index.tsx
+++ b/src/components/flow/FlowSearchResults/index.tsx
@@ -1,13 +1,30 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback, MouseEvent } from 'react';
 import styles from './styles.scss';
 import { IFlowState } from '~/redux/flow/reducer';
 import { LoaderCircle } from '~/components/input/LoaderCircle';
+import { URLS, PRESETS } from '~/constants/urls';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import { getURL, getPrettyDate } from '~/utils/dom';
 
 interface IProps {
   search: IFlowState['search'];
+  onLoadMore: () => void;
 }
 
-const FlowSearchResults: FC<IProps> = ({ search }) => {
+const FlowSearchResults: FC<IProps> = ({ search, onLoadMore }) => {
+  const onScroll = useCallback(
+    event => {
+      const el = event.target;
+      const bottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+
+      if (bottom > 100) return;
+
+      onLoadMore();
+    },
+    [onLoadMore]
+  );
+
   if (search.is_loading) {
     return (
       <div className={styles.loading}>
@@ -15,7 +32,26 @@ const FlowSearchResults: FC<IProps> = ({ search }) => {
       </div>
     );
   }
-  return <div className={styles.wrap}>SEARCH</div>;
+
+  return (
+    <div className={styles.wrap} onScroll={onScroll}>
+      {search.results.map(node => (
+        <Link key={node.id} className={styles.item} to={URLS.NODE_URL(node.id)}>
+          <div
+            className={classNames(styles.thumb)}
+            style={{
+              backgroundImage: `url("${getURL({ url: node.thumbnail }, PRESETS.avatar)}")`,
+            }}
+          />
+
+          <div className={styles.info}>
+            <div className={styles.title}>{node.title}</div>
+            <div className={styles.comment}>{getPrettyDate(node.created_at)}</div>
+          </div>
+        </Link>
+      ))}
+    </div>
+  );
 };
 
 export { FlowSearchResults };
diff --git a/src/components/flow/FlowSearchResults/styles.scss b/src/components/flow/FlowSearchResults/styles.scss
index cbfae3c8..86a8e8f8 100644
--- a/src/components/flow/FlowSearchResults/styles.scss
+++ b/src/components/flow/FlowSearchResults/styles.scss
@@ -1,6 +1,6 @@
 .wrap {
   flex: 1;
-  background: red;
+  overflow: auto;
 }
 
 .loading {
@@ -10,3 +10,61 @@
   flex: 1;
   opacity: 0.3;
 }
+
+.item {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font: $font_12_regular;
+  border-radius: $radius;
+  flex-direction: row;
+  margin-bottom: $gap;
+  color: white;
+  text-decoration: none;
+}
+
+.thumb {
+  height: 48px;
+  margin-right: $gap;
+  background: 50% 50% no-repeat;
+  background-size: cover;
+  border-radius: $radius;
+  flex: 0 0 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+
+  &.new {
+    &::after {
+      content: ' ';
+      width: 12px;
+      height: 12px;
+      border-radius: 100%;
+      background: $red;
+      box-shadow: $content_bg 0 0 0 5px;
+      position: absolute;
+      right: -2px;
+      bottom: -2px;
+    }
+  }
+}
+
+.info {
+  flex: 1;
+  min-width: 0;
+}
+
+.title {
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  font: $font_16_semibold;
+  text-transform: capitalize;
+}
+
+.comment {
+  font: $font_12_regular;
+  margin-top: 4px;
+  opacity: 0.5;
+}
diff --git a/src/components/flow/FlowStamp/index.tsx b/src/components/flow/FlowStamp/index.tsx
index 21ecddd2..bd8f11ed 100644
--- a/src/components/flow/FlowStamp/index.tsx
+++ b/src/components/flow/FlowStamp/index.tsx
@@ -13,9 +13,10 @@ interface IProps {
   updated: IFlowState['updated'];
   search: IFlowState['search'];
   flowChangeSearch: typeof FLOW_ACTIONS.flowChangeSearch;
+  onLoadMore: () => void;
 }
 
-const FlowStamp: FC<IProps> = ({ recent, updated, search, flowChangeSearch }) => {
+const FlowStamp: FC<IProps> = ({ recent, updated, search, flowChangeSearch, onLoadMore }) => {
   const onSearchChange = useCallback((text: string) => flowChangeSearch({ text }), [
     flowChangeSearch,
   ]);
@@ -35,16 +36,19 @@ const FlowStamp: FC<IProps> = ({ recent, updated, search, flowChangeSearch }) =>
           <>
             <div className={styles.label}>
               <span className={styles.label_text}>Результаты поиска</span>
+              <span className="line" />
+              <span>{!search.is_loading && search.total}</span>
             </div>
 
             <div className={styles.items}>
-              <FlowSearchResults search={search} />
+              <FlowSearchResults search={search} onLoadMore={onLoadMore} />
             </div>
           </>
         ) : (
           <>
             <div className={styles.label}>
               <span className={styles.label_text}>Что нового?</span>
+              <span className="line" />
             </div>
 
             <div className={styles.items}>
diff --git a/src/components/flow/FlowStamp/styles.scss b/src/components/flow/FlowStamp/styles.scss
index 098ade69..a0497505 100644
--- a/src/components/flow/FlowStamp/styles.scss
+++ b/src/components/flow/FlowStamp/styles.scss
@@ -12,6 +12,7 @@
   flex-direction: column;
   flex: 1;
   border-radius: $radius;
+  overflow: hidden;
 
   @include outer_shadow();
 }
@@ -21,6 +22,7 @@
   flex: 1;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
 }
 
 .label {
@@ -38,6 +40,10 @@
     color: white;
     padding-left: $gap * 1.2;
   }
+
+  & > :global(.line) {
+    margin-right: $gap;
+  }
 }
 
 .label_text {
diff --git a/src/constants/api.ts b/src/constants/api.ts
index 40923951..6464a302 100644
--- a/src/constants/api.ts
+++ b/src/constants/api.ts
@@ -30,5 +30,7 @@ export const API = {
       `/node/${id}/comment/${comment_id}/lock`,
     SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`,
   },
-  SEARCH: '/search',
+  SEARCH: {
+    NODES: '/search/nodes',
+  },
 };
diff --git a/src/containers/flow/FlowLayout/index.tsx b/src/containers/flow/FlowLayout/index.tsx
index c0051318..ed92ea74 100644
--- a/src/containers/flow/FlowLayout/index.tsx
+++ b/src/containers/flow/FlowLayout/index.tsx
@@ -21,6 +21,7 @@ const mapDispatchToProps = {
   flowSetCellView: FLOW_ACTIONS.flowSetCellView,
   flowGetMore: FLOW_ACTIONS.flowGetMore,
   flowChangeSearch: FLOW_ACTIONS.flowChangeSearch,
+  flowLoadMoreSearch: FLOW_ACTIONS.flowLoadMoreSearch,
 };
 
 type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
@@ -32,8 +33,9 @@ const FlowLayoutUnconnected: FC<IProps> = ({
   flowSetCellView,
   flowGetMore,
   flowChangeSearch,
+  flowLoadMoreSearch,
 }) => {
-  const loadMore = useCallback(() => {
+  const onLoadMore = useCallback(() => {
     const pos = window.scrollY + window.innerHeight - document.body.scrollHeight;
 
     if (is_loading || pos < -600) return;
@@ -41,11 +43,16 @@ const FlowLayoutUnconnected: FC<IProps> = ({
     flowGetMore();
   }, [flowGetMore, is_loading]);
 
-  useEffect(() => {
-    window.addEventListener('scroll', loadMore);
+  const onLoadMoreSearch = useCallback(() => {
+    if (search.is_loading_more) return;
+    flowLoadMoreSearch();
+  }, [search.is_loading_more, flowLoadMoreSearch]);
 
-    return () => window.removeEventListener('scroll', loadMore);
-  }, [loadMore]);
+  useEffect(() => {
+    window.addEventListener('scroll', onLoadMore);
+
+    return () => window.removeEventListener('scroll', onLoadMore);
+  }, [onLoadMore]);
 
   return (
     <div className={styles.grid}>
@@ -57,15 +64,16 @@ const FlowLayoutUnconnected: FC<IProps> = ({
         <FlowStamp
           recent={recent}
           updated={updated}
-          flowChangeSearch={flowChangeSearch}
           search={search}
+          flowChangeSearch={flowChangeSearch}
+          onLoadMore={onLoadMoreSearch}
         />
       </div>
 
       <FlowGrid
         nodes={nodes}
-        onSelect={nodeGotoNode}
         user={user}
+        onSelect={nodeGotoNode}
         onChangeCellView={flowSetCellView}
       />
     </div>
diff --git a/src/redux/flow/actions.ts b/src/redux/flow/actions.ts
index 5b535cf6..97764d3f 100644
--- a/src/redux/flow/actions.ts
+++ b/src/redux/flow/actions.ts
@@ -46,3 +46,7 @@ export const flowChangeSearch = (search: Partial<IFlowState['search']>) => ({
   type: FLOW_ACTIONS.CHANGE_SEARCH,
   search,
 });
+
+export const flowLoadMoreSearch = () => ({
+  type: FLOW_ACTIONS.LOAD_MORE_SEARCH,
+});
diff --git a/src/redux/flow/api.ts b/src/redux/flow/api.ts
index 5f54ca09..f23a8101 100644
--- a/src/redux/flow/api.ts
+++ b/src/redux/flow/api.ts
@@ -2,6 +2,7 @@ import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils
 import { INode, IResultWithStatus } from '../types';
 import { API } from '~/constants/api';
 import { flowSetCellView } from '~/redux/flow/actions';
+import { IFlowState } from './reducer';
 
 export const postNode = ({
   access,
@@ -30,11 +31,12 @@ export const postCellView = ({
 export const getSearchResults = ({
   access,
   text,
-}: {
+  skip = 0,
+}: IFlowState['search'] & {
   access: string;
-  text: string;
+  skip: number;
 }): Promise<IResultWithStatus<{ nodes: INode[]; total: number }>> =>
   api
-    .get(API.SEARCH, configWithToken(access, { params: { text } }))
+    .get(API.SEARCH.NODES, configWithToken(access, { params: { text, skip } }))
     .then(resultMiddleware)
     .catch(errorMiddleware);
diff --git a/src/redux/flow/constants.ts b/src/redux/flow/constants.ts
index 60f9f70b..fecb6d93 100644
--- a/src/redux/flow/constants.ts
+++ b/src/redux/flow/constants.ts
@@ -13,4 +13,5 @@ export const FLOW_ACTIONS = {
 
   SET_SEARCH: `${prefix}SET_SEARCH`,
   CHANGE_SEARCH: `${prefix}CHANGE_SEARCH`,
+  LOAD_MORE_SEARCH: `${prefix}LOAD_MORE_SEARCH`,
 };
diff --git a/src/redux/flow/reducer.ts b/src/redux/flow/reducer.ts
index 2a141123..1bf96683 100644
--- a/src/redux/flow/reducer.ts
+++ b/src/redux/flow/reducer.ts
@@ -10,8 +10,10 @@ export type IFlowState = Readonly<{
   updated: Partial<INode>[];
   search: {
     text: string;
-    is_loading: boolean;
     results: INode[];
+    total: number;
+    is_loading: boolean;
+    is_loading_more: boolean;
   };
   error: IError;
 }>;
@@ -23,8 +25,10 @@ const INITIAL_STATE: IFlowState = {
   updated: [],
   search: {
     text: '',
-    is_loading: false,
     results: [],
+    total: 0,
+    is_loading: false,
+    is_loading_more: false,
   },
   is_loading: false,
   error: null,
diff --git a/src/redux/flow/sagas.ts b/src/redux/flow/sagas.ts
index adb88110..ec96a5cc 100644
--- a/src/redux/flow/sagas.ts
+++ b/src/redux/flow/sagas.ts
@@ -1,4 +1,4 @@
-import { takeLatest, call, put, select, takeLeading, delay } from 'redux-saga/effects';
+import { takeLatest, call, put, select, takeLeading, delay, race, take } from 'redux-saga/effects';
 import { REHYDRATE } from 'redux-persist';
 import { FLOW_ACTIONS } from './constants';
 import { getNodeDiff } from '../node/api';
@@ -13,7 +13,7 @@ import {
   flowSetSearch,
 } from './actions';
 import { IResultWithStatus, INode, Unwrap } from '../types';
-import { selectFlowNodes } from './selectors';
+import { selectFlowNodes, selectFlow } from './selectors';
 import { reqWrapper } from '../auth/sagas';
 import { postCellView, getSearchResults } from './api';
 import { IFlowState } from './reducer';
@@ -124,11 +124,59 @@ function* changeSearch({ search }: ReturnType<typeof flowChangeSearch>) {
 
   yield delay(500);
 
-  const res: Unwrap<typeof getSearchResults> = yield call(reqWrapper, getSearchResults, {
-    ...search,
+  const { data, error }: Unwrap<ReturnType<typeof getSearchResults>> = yield call(
+    reqWrapper,
+    getSearchResults,
+    {
+      ...search,
+    }
+  );
+
+  if (error) {
+    yield put(flowSetSearch({ is_loading: false, results: [], total: 0 }));
+    return;
+  }
+
+  yield put(
+    flowSetSearch({
+      is_loading: false,
+      results: data.nodes,
+      total: data.total,
+    })
+  );
+}
+
+function* loadMoreSearch() {
+  yield put(
+    flowSetSearch({
+      is_loading_more: true,
+    })
+  );
+
+  const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow);
+
+  const {
+    result,
+    delay,
+  }: { result: Unwrap<ReturnType<typeof getSearchResults>>; delay: any } = yield race({
+    result: call(reqWrapper, getSearchResults, {
+      ...search,
+      skip: search.results.length,
+    }),
+    delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
   });
 
-  console.log(res);
+  if (delay || result.error) {
+    return put(flowSetSearch({ is_loading_more: false }));
+  }
+
+  yield put(
+    flowSetSearch({
+      results: [...search.results, ...result.data.nodes],
+      total: result.data.total,
+      is_loading_more: false,
+    })
+  );
 }
 
 export default function* nodeSaga() {
@@ -136,4 +184,5 @@ export default function* nodeSaga() {
   yield takeLatest(FLOW_ACTIONS.SET_CELL_VIEW, onSetCellView);
   yield takeLeading(FLOW_ACTIONS.GET_MORE, getMore);
   yield takeLatest(FLOW_ACTIONS.CHANGE_SEARCH, changeSearch);
+  yield takeLatest(FLOW_ACTIONS.LOAD_MORE_SEARCH, loadMoreSearch);
 }
diff --git a/src/styles/variables.scss b/src/styles/variables.scss
index b53b40b4..bc83165e 100644
--- a/src/styles/variables.scss
+++ b/src/styles/variables.scss
@@ -165,8 +165,7 @@ $input_shadow_filled: $input_shadow;
     padding-right: $gap;
   }
 
-  &:after {
-    content: ' ';
+  & > :global(.line) {
     flex: 1;
     height: 2px;
     background: transparentize(white, 0.95);