Browse Source

move reply edit form to thread view

Klaudiusz Dembler 4 years ago
parent
commit
801e861eb1

+ 57 - 68
pioneer/packages/joy-forum/src/EditReply.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import { Button, Message } from 'semantic-ui-react';
+import styled from 'styled-components';
 import { Form, Field, withFormik, FormikProps } from 'formik';
 import * as Yup from 'yup';
-import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { SubmittableResult } from '@polkadot/api';
@@ -14,7 +14,6 @@ import { PostId, Post, ThreadId } from '@joystream/types/forum';
 import { withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
 import Section from '@polkadot/joy-utils/Section';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
-import { UrlHasIdProps, CategoryCrumbs } from './utils';
 import { withForumCalls } from './calls';
 import { ValidationProps, withReplyValidation } from './validation';
 import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
@@ -39,11 +38,17 @@ const buildSchema = (props: ValidationProps) => {
   });
 };
 
+const FormActionsContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
 type OuterProps = ValidationProps & {
-  history?: History;
   id?: PostId;
   struct?: Post;
   threadId: ThreadId;
+  onEditSuccess?: () => void;
+  onEditCancel?: () => void;
 };
 
 type FormValues = {
@@ -56,7 +61,6 @@ const LabelledField = JoyForms.LabelledField<FormValues>();
 
 const InnerForm = (props: FormProps) => {
   const {
-    history,
     id,
     threadId,
     struct,
@@ -65,18 +69,16 @@ const InnerForm = (props: FormProps) => {
     isValid,
     isSubmitting,
     setSubmitting,
-    resetForm
+    resetForm,
+    onEditSuccess,
+    onEditCancel
   } = props;
 
   const {
     text
   } = values;
 
-  const goToThreadView = () => {
-    if (history) {
-      history.push('/forum/threads/' + threadId.toString());
-    }
-  };
+  const isNew = struct === undefined;
 
   const onSubmit = (sendTx: () => void) => {
     if (isValid) sendTx();
@@ -92,11 +94,12 @@ const InnerForm = (props: FormProps) => {
 
   const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
     setSubmitting(false);
-    goToThreadView();
+    resetForm();
+    if (!isNew && onEditSuccess) {
+      onEditSuccess();
+    }
   };
 
-  const isNew = struct === undefined;
-
   const buildTxParams = () => {
     if (!isValid) return [];
 
@@ -116,43 +119,57 @@ const InnerForm = (props: FormProps) => {
       </LabelledField>
 
       <LabelledField {...props}>
-        <TxButton
-          type='submit'
-          size='large'
-          label={isNew
-            ? 'Post a reply'
-            : 'Update a reply'
+        <FormActionsContainer>
+          <div>
+            <TxButton
+              type='submit'
+              size='large'
+              label={isNew
+                ? 'Post a reply'
+                : 'Update a reply'
+              }
+              isDisabled={!dirty || isSubmitting}
+              params={buildTxParams()}
+              tx={isNew
+                ? 'forum.addPost'
+                : 'forum.editPostText'
+              }
+              onClick={onSubmit}
+              txFailedCb={onTxFailed}
+              txSuccessCb={onTxSuccess}
+            />
+            <Button
+              type='button'
+              size='large'
+              disabled={!dirty || isSubmitting}
+              onClick={() => resetForm()}
+              content='Reset form'
+            />
+          </div>
+          {
+            !isNew && (
+              <Button
+                type='button'
+                size='large'
+                disabled={isSubmitting}
+                content='Cancel edit'
+                onClick={() => onEditCancel && onEditCancel()}
+              />
+            )
           }
-          isDisabled={!dirty || isSubmitting}
-          params={buildTxParams()}
-          tx={isNew
-            ? 'forum.addPost'
-            : 'forum.editPostText'
-          }
-          onClick={onSubmit}
-          txFailedCb={onTxFailed}
-          txSuccessCb={onTxSuccess}
-        />
-        <Button
-          type='button'
-          size='large'
-          disabled={!dirty || isSubmitting}
-          onClick={() => resetForm()}
-          content='Reset form'
-        />
+        </FormActionsContainer>
       </LabelledField>
     </Form>;
 
   const sectionTitle = isNew
     ? 'New reply'
-    : 'Edit my reply';
+    : `Edit my reply #${struct?.nr_in_thread}`;
 
-  return <>
-    <CategoryCrumbs threadId={threadId} />
+  return (
     <Section className='EditEntityBox' title={sectionTitle}>
       {form}
     </Section>
-  </>;
+  );
 };
 
 const EditForm = withFormik<OuterProps, FormValues>({
@@ -192,43 +209,15 @@ function FormOrLoading (props: OuterProps) {
   return <Message error className='JoyMainStatus' header='You are not allowed edit this reply.' />;
 }
 
-function withThreadIdFromUrl (Component: React.ComponentType<OuterProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} threadId={new ThreadId(id)} />;
-    } catch (err) {
-      return <em>Invalid thread ID: {id}</em>;
-    }
-  };
-}
-
-type HasPostIdProps = {
-  id: PostId;
-};
-
-function withIdFromUrl (Component: React.ComponentType<HasPostIdProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} id={new PostId(id)} />;
-    } catch (err) {
-      return <em>Invalid reply ID: {id}</em>;
-    }
-  };
-}
-
 export const NewReply = withMulti(
   EditForm,
   withOnlyMembers,
-  withThreadIdFromUrl,
   withReplyValidation
 );
 
 export const EditReply = withMulti(
   FormOrLoading,
   withOnlyMembers,
-  withIdFromUrl,
   withReplyValidation,
   withForumCalls<OuterProps>(
     ['postById', { paramName: 'id', propName: 'struct' }]

+ 7 - 5
pioneer/packages/joy-forum/src/ViewReply.tsx

@@ -10,7 +10,7 @@ import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
-import { TimeAgoDate } from './utils';
+import { TimeAgoDate, ReplyIdxQueryParam } from './utils';
 
 const HORIZONTAL_PADDING = '1em';
 const ReplyMarkdown = styled(ReactMarkdown)`
@@ -55,6 +55,8 @@ type ViewReplyProps = {
   thread: Thread;
   category: Category;
   selected?: boolean;
+  onEdit: () => void;
+  onQuote: () => void;
 };
 
 // eslint-disable-next-line react/display-name
@@ -62,7 +64,7 @@ export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref
   const { state: { address: myAddress } } = useMyAccount();
   const [showModerateForm, setShowModerateForm] = useState(false);
   const { pathname, search } = useLocation();
-  const { reply, thread, category, selected = false } = props;
+  const { reply, thread, category, selected = false, onEdit, onQuote } = props;
   const { id } = reply;
 
   if (reply.isEmpty) {
@@ -91,7 +93,7 @@ export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref
     return <ReplyFooterActionsRow>
       <div>
         {isMyPost &&
-          <Button as={Link} to={`/forum/replies/${id.toString()}/edit`} size="mini">
+          <Button onClick={onEdit} size="mini">
             <Icon name="pencil" />
             Edit
           </Button>
@@ -106,7 +108,7 @@ export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref
           </Button>
         </IfIAmForumSudo>
       </div>
-      <Button size="mini">
+      <Button onClick={onQuote} size="mini">
         <Icon name="quote left" />
         Quote
       </Button>
@@ -114,7 +116,7 @@ export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref
   };
 
   const replyLinkSearch = new URLSearchParams(search);
-  replyLinkSearch.set('replyIdx', reply.nr_in_thread.toString());
+  replyLinkSearch.set(ReplyIdxQueryParam, reply.nr_in_thread.toString());
 
   return (
     <ReplyContainer className="ui segment" ref={ref} selected={selected}>

+ 101 - 32
pioneer/packages/joy-forum/src/ViewThread.tsx

@@ -2,11 +2,11 @@ import React, { useState, useEffect, useRef } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
 import styled from 'styled-components';
-import { Table, Button, Label } from 'semantic-ui-react';
+import { Table, Button, Label, Icon } from 'semantic-ui-react';
 import BN from 'bn.js';
 
 import { Category, Thread, ThreadId, Post, PostId } from '@joystream/types/forum';
-import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam } from './utils';
+import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils';
 import { ViewReply } from './ViewReply';
 import { Moderate } from './Moderate';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
@@ -19,6 +19,7 @@ import { bnToStr } from '@polkadot/joy-utils/index';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
 import { formatDate } from '@polkadot/joy-utils/functions/date';
+import { NewReply, EditReply } from './EditReply';
 
 type ThreadTitleProps = {
   thread: Thread;
@@ -76,6 +77,47 @@ const ThreadInfoMemberPreview = styled(MemberPreview)`
   }
 `;
 
+const ReplyEditContainer = styled.div`
+  margin-top: 30px;
+  padding-bottom: 60px;
+`;
+
+type ThreadPreviewProps = {
+  thread: Thread;
+  repliesCount: number;
+}
+
+const ThreadPreview: React.FC<ThreadPreviewProps> = ({ thread, repliesCount }) => {
+  const title = <ThreadTitle thread={thread} />;
+
+  return (
+    <Table.Row>
+      <Table.Cell>
+        <Link to={`/forum/threads/${thread.id.toString()}`}>
+          {
+            thread.moderated
+              ? (
+                <MutedSpan>
+                  <Label color='orange'>Moderated</Label> {title}
+                </MutedSpan>
+              )
+              : title
+          }
+        </Link>
+      </Table.Cell>
+      <Table.Cell>
+        {repliesCount}
+      </Table.Cell>
+      <Table.Cell>
+        <MemberPreview accountId={thread.author_id} />
+      </Table.Cell>
+      <Table.Cell>
+        {formatDate(thread.created_at.momentDate)}
+      </Table.Cell>
+    </Table.Row>
+  );
+};
+
 type InnerViewThreadProps = {
   category: Category;
   thread: Thread;
@@ -91,12 +133,16 @@ function InnerViewThread (props: ViewThreadProps) {
   const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
   const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
   const { category, thread, preview = false } = props;
+  const replyFormRef = useRef<HTMLDivElement>(null);
+  const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam(ReplyIdxQueryParam);
+  const [rawEditedPostId, setEditedPostId] = useQueryParam(ReplyEditIdQueryParam);
   const [currentPage, setCurrentPage] = usePagination();
-  const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam('replyIdx');
 
   const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null;
   const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null;
 
+  const editedPostId = rawEditedPostId && new PostId(rawEditedPostId);
+
   if (!thread) {
     return <em>Loading thread details...</em>;
   }
@@ -114,7 +160,7 @@ function InnerViewThread (props: ViewThreadProps) {
 
   const changePageAndClearSelectedPost = (page?: number | string) => {
     setSelectedPostIdx(null);
-    setCurrentPage(page, true);
+    setCurrentPage(page, [ReplyIdxQueryParam]);
   };
 
   if (!category) {
@@ -124,33 +170,14 @@ function InnerViewThread (props: ViewThreadProps) {
   }
 
   if (preview) {
-    const title = <ThreadTitle thread={thread} />;
-    const repliesCount = totalPostsInThread - 1;
-    return (
-      <Table.Row>
-        <Table.Cell>
-          <Link to={`/forum/threads/${id.toString()}`}>{thread.moderated
-            ? <MutedSpan><Label color='orange'>Moderated</Label> {title}</MutedSpan>
-            : title
-          }</Link>
-        </Table.Cell>
-        <Table.Cell>
-          {repliesCount}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={thread.author_id} />
-        </Table.Cell>
-        <Table.Cell>
-          {formatDate(thread.created_at.momentDate)}
-        </Table.Cell>
-      </Table.Row>
-    );
+    return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
   }
 
   const { api, nextPostId } = props;
   const [loaded, setLoaded] = useState(false);
   const [posts, setPosts] = useState(new Array<Post>());
 
+  // fetch posts
   useEffect(() => {
     const loadPosts = async () => {
       if (!nextPostId || totalPostsInThread === 0) return;
@@ -226,6 +253,34 @@ function InnerViewThread (props: ViewThreadProps) {
     setDisplayedPosts(postsToDisplay);
   }, [loaded, posts, currentPage]);
 
+  const scrollToReplyForm = () => {
+    if (!replyFormRef.current) return;
+    replyFormRef.current.scrollIntoView();
+  };
+
+  const clearEditedPost = () => {
+    setEditedPostId(null);
+  };
+
+  const onThreadReplyClick = () => {
+    clearEditedPost();
+    scrollToReplyForm();
+  };
+
+  const onPostEditSuccess = async () => {
+    if (!editedPostId) {
+      // eslint-disable-next-line no-console
+      console.error('editedPostId not set!');
+      return;
+    }
+
+    const updatedPost = await api.query.forum.postById(editedPostId) as Post;
+    const updatedPosts = posts.map(post => post.id.eq(editedPostId) ? updatedPost : post);
+
+    setPosts(updatedPosts);
+    clearEditedPost();
+  };
+
   // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts });
 
   const renderPageOfPosts = () => {
@@ -236,13 +291,19 @@ function InnerViewThread (props: ViewThreadProps) {
     const pagination =
       <Pagination
         currentPage={currentPage}
-        totalItems={totalPostsInThread}
+        totalItems={posts.length}
         itemsPerPage={RepliesPerPage}
         onPageChange={changePageAndClearSelectedPost}
       />;
 
     const renderedReplies = displayedPosts.map((reply) => {
       const replyIdx = reply.nr_in_thread.toNumber();
+
+      const onReplyEditClick = () => {
+        setEditedPostId(reply.id.toString());
+        scrollToReplyForm();
+      };
+
       return (
         <ViewReply
           ref={postsRefs.current[replyIdx]}
@@ -251,6 +312,8 @@ function InnerViewThread (props: ViewThreadProps) {
           thread={thread}
           reply={reply}
           selected={selectedPostIdx === replyIdx}
+          onEdit={onReplyEditClick}
+          onQuote={() => {}}
         />
       );
     });
@@ -267,13 +330,10 @@ function InnerViewThread (props: ViewThreadProps) {
       return null;
     }
     return <span className='JoyInlineActions'>
-      <Link
-        to={`/forum/threads/${id.toString()}/reply`}
-        className='ui small button'
-      >
-        <i className='reply icon' />
+      <Button onClick={onThreadReplyClick}>
+        <Icon name="reply" />
         Reply
-      </Link>
+      </Button>
 
       {/* TODO show 'Edit' button only if I am owner */}
       {/* <Link
@@ -333,6 +393,15 @@ function InnerViewThread (props: ViewThreadProps) {
       ? renderModerationRationale()
       : renderPageOfPosts()
     }
+    <ReplyEditContainer ref={replyFormRef}>
+      {
+        editedPostId ? (
+          <EditReply id={editedPostId} key={editedPostId.toString()} onEditSuccess={onPostEditSuccess} onEditCancel={clearEditedPost} />
+        ) : (
+          <NewReply threadId={thread.id} />
+        )
+      }
+    </ReplyEditContainer>
   </div>;
 }
 

+ 0 - 3
pioneer/packages/joy-forum/src/index.tsx

@@ -45,12 +45,9 @@ class App extends React.PureComponent<Props> {
               <Route path={`${basePath}/categories/:id`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories`} component={CategoryList} />
 
-              <Route path={`${basePath}/threads/:id/reply`} component={NewReply} />
               <Route path={`${basePath}/threads/:id/edit`} component={EditThread} />
               <Route path={`${basePath}/threads/:id`} component={ViewThreadById} />
 
-              <Route path={`${basePath}/replies/:id/edit`} component={EditReply} />
-
               <Route component={CategoryList} />
             </Switch>
           </ForumContentWrapper>

+ 9 - 4
pioneer/packages/joy-forum/src/utils.tsx

@@ -12,6 +12,9 @@ import { withMulti } from '@polkadot/react-api';
 
 export const ThreadsPerPage = 10;
 export const RepliesPerPage = 10;
+export const ReplyIdxQueryParam = 'replyIdx';
+export const ReplyEditIdQueryParam = 'editReplyId';
+export const PagingQueryParam = 'page';
 
 type PaginationProps = {
   currentPage?: number;
@@ -148,7 +151,7 @@ export type UrlHasIdProps = {
 };
 
 type QueryValueType = string | null;
-type QuerySetValueType = (value?: QueryValueType | number, clear?: boolean) => void;
+type QuerySetValueType = (value?: QueryValueType | number, paramsToReset?: string[]) => void;
 type QueryReturnType = [QueryValueType, QuerySetValueType];
 
 export const useQueryParam = (queryParam: string): QueryReturnType => {
@@ -164,7 +167,7 @@ export const useQueryParam = (queryParam: string): QueryReturnType => {
     }
   }, [search, setValue, queryParam]);
 
-  const setParam: QuerySetValueType = (rawValue, clear = false) => {
+  const setParam: QuerySetValueType = (rawValue, paramsToReset = []) => {
     let parsedValue: string | null;
     if (!rawValue && rawValue !== 0) {
       parsedValue = null;
@@ -172,13 +175,15 @@ export const useQueryParam = (queryParam: string): QueryReturnType => {
       parsedValue = rawValue.toString();
     }
 
-    const params = new URLSearchParams(!clear ? search : '');
+    const params = new URLSearchParams(search);
     if (parsedValue) {
       params.set(queryParam, parsedValue);
     } else {
       params.delete(queryParam);
     }
 
+    paramsToReset.forEach(p => params.delete(p));
+
     setValue(parsedValue);
     history.push({ pathname, search: params.toString() });
   };
@@ -187,7 +192,7 @@ export const useQueryParam = (queryParam: string): QueryReturnType => {
 };
 
 export const usePagination = (): [number, QuerySetValueType] => {
-  const [rawCurrentPage, setCurrentPage] = useQueryParam('page');
+  const [rawCurrentPage, setCurrentPage] = useQueryParam(PagingQueryParam);
 
   let currentPage = 1;
   if (rawCurrentPage) {