1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

#34 made local comment form uploads

This commit is contained in:
Fedor Katurov 2021-02-27 17:51:12 +07:00
parent f45e34f330
commit 051b199d5d
14 changed files with 422 additions and 189 deletions

View file

@ -24,7 +24,7 @@
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-popper": "^2.2.3", "react-popper": "^2.2.3",
"react-redux": "^6.0.1", "react-redux": "^7.2.2",
"react-router": "^5.1.2", "react-router": "^5.1.2",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.4", "react-scripts": "3.4.4",

View file

@ -5,7 +5,6 @@ import { CommentContent } from '~/components/comment/CommentContent';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { nodeEditComment, nodeLockComment } from '~/redux/node/actions'; import { nodeEditComment, nodeLockComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer'; import { INodeState } from '~/redux/node/reducer';
import { CommentForm } from '../CommentForm';
import { CommendDeleted } from '../../node/CommendDeleted'; import { CommendDeleted } from '../../node/CommendDeleted';
import * as MODAL_ACTIONS from '~/redux/modal/actions'; import * as MODAL_ACTIONS from '~/redux/modal/actions';
@ -50,17 +49,12 @@ const Comment: FC<IProps> = memo(
return <CommendDeleted id={comment.id} onDelete={onDelete} key={comment.id} />; return <CommendDeleted id={comment.id} onDelete={onDelete} key={comment.id} />;
} }
if (Object.prototype.hasOwnProperty.call(comment_data, comment.id)) {
return <CommentForm id={comment.id} key={comment.id} />;
}
return ( return (
<CommentContent <CommentContent
comment={comment} comment={comment}
key={comment.id} key={comment.id}
can_edit={!!can_edit} can_edit={!!can_edit}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit}
modalShowPhotoswipe={modalShowPhotoswipe} modalShowPhotoswipe={modalShowPhotoswipe}
/> />
); );

View file

@ -1,112 +1,116 @@
import React, { FC, useMemo, memo, createElement, useCallback, Fragment } from 'react'; import React, { createElement, FC, Fragment, memo, useCallback, useMemo, useState } from 'react';
import { IComment, IFile } from '~/redux/types'; import { IComment, IFile } from '~/redux/types';
import { path } from 'ramda'; import { append, assocPath, path } from 'ramda';
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom'; import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { assocPath } from 'ramda';
import { append } from 'ramda';
import reduce from 'ramda/es/reduce'; import reduce from 'ramda/es/reduce';
import { AudioPlayer } from '~/components/media/AudioPlayer'; import { AudioPlayer } from '~/components/media/AudioPlayer';
import classnames from 'classnames'; import classnames from 'classnames';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions'; import { nodeLockComment } from '~/redux/node/actions';
import { CommentMenu } from '../CommentMenu'; import { CommentMenu } from '../CommentMenu';
import * as MODAL_ACTIONS from '~/redux/modal/actions'; import * as MODAL_ACTIONS from '~/redux/modal/actions';
import { LocalCommentForm } from '~/components/comment/LocalCommentForm';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectNode } from '~/redux/node/selectors';
interface IProps { interface IProps {
comment: IComment; comment: IComment;
can_edit: boolean; can_edit: boolean;
onDelete: typeof nodeLockComment; onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe;
} }
const CommentContent: FC<IProps> = memo( const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, modalShowPhotoswipe }) => {
({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => { const [isEditing, setIsEditing] = useState(false);
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>( const { current } = useShallowSelect(selectNode);
() =>
reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
const onLockClick = useCallback(() => { const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
onDelete(comment.id, !comment.deleted_at); const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
}, [comment, onDelete]);
const onEditClick = useCallback(() => { const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
onEdit(comment.id); () =>
}, [comment, onEdit]); reduce(
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
{},
comment.files
),
[comment]
);
const menu = useMemo( const onLockClick = useCallback(() => {
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={onEditClick} />, onDelete(comment.id, !comment.deleted_at);
[can_edit, comment, onEditClick, onLockClick] }, [comment, onDelete]);
);
const blocks = useMemo( const menu = useMemo(
() => () => can_edit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />,
!!comment.text.trim() [can_edit, startEditing, onLockClick]
? formatCommentText(path(['user', 'username'], comment), comment.text) );
: [],
[comment.text]
);
return ( const blocks = useMemo(
<div className={styles.wrap}> () =>
{comment.text && ( !!comment.text.trim()
<Group className={classnames(styles.block, styles.block_text)}> ? formatCommentText(path(['user', 'username'], comment), comment.text)
{menu} : [],
[comment]
);
<Group className={styles.renderers}> if (isEditing) {
{blocks.map( return <LocalCommentForm nodeId={current.id} comment={comment} onCancelEdit={stopEditing} />;
(block, key) => }
COMMENT_BLOCK_RENDERERS[block.type] &&
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
)}
</Group>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> return (
<div className={styles.wrap}>
{comment.text && (
<Group className={classnames(styles.block, styles.block_text)}>
{menu}
<Group className={styles.renderers}>
{blocks.map(
(block, key) =>
COMMENT_BLOCK_RENDERERS[block.type] &&
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
)}
</Group> </Group>
)}
{groupped.image && groupped.image.length > 0 && ( <div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
<div className={classnames(styles.block, styles.block_image)}> </Group>
{menu} )}
<div className={styles.images}> {groupped.image && groupped.image.length > 0 && (
{groupped.image.map((file, index) => ( <div className={classnames(styles.block, styles.block_image)}>
<div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}> {menu}
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
</div>
))}
</div>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.images}>
</div> {groupped.image.map((file, index) => (
)} <div key={file.id} onClick={() => modalShowPhotoswipe(groupped.image, index)}>
<img src={getURL(file, PRESETS['600'])} alt={file.name} />
{groupped.audio && groupped.audio.length > 0 && (
<Fragment>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
{menu}
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div> </div>
))} ))}
</Fragment> </div>
)}
</div> <div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
); </div>
} )}
);
{groupped.audio && groupped.audio.length > 0 && (
<Fragment>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
{menu}
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
</div>
))}
</Fragment>
)}
</div>
);
});
export { CommentContent }; export { CommentContent };

View file

@ -1,12 +1,4 @@
import React, { import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo, useState } from 'react';
FC,
KeyboardEventHandler,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Textarea } from '~/components/input/Textarea'; import { Textarea } from '~/components/input/Textarea';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler'; import { Filler } from '~/components/containers/Filler';
@ -30,7 +22,6 @@ import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons'; import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone'; import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone';
import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons'; import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
import { LocalCommentForm } from '~/components/comment/LocalCommentForm';
const mapStateToProps = (state: IState) => ({ const mapStateToProps = (state: IState) => ({
node: selectNode(state), node: selectNode(state),
@ -237,8 +228,6 @@ const CommentFormUnconnected: FC<IProps> = memo(
</Group> </Group>
</form> </form>
</CommentFormDropzone> </CommentFormDropzone>
<LocalCommentForm />
</> </>
); );
} }

View file

@ -1,30 +1,64 @@
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react';
import { CommentFormValues, useCommentFormFormik } from '~/utils/hooks/useCommentFormFormik'; import { useCommentFormFormik } from '~/utils/hooks/useCommentFormFormik';
import { FormikProvider } from 'formik'; import { FormikProvider } from 'formik';
import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea'; import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/fileUploader';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
import { LocalCommentFormAttaches } from '~/components/comment/LocalCommentFormAttaches';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { IComment, INode } from '~/redux/types';
import { EMPTY_COMMENT } from '~/redux/node/constants';
interface IProps {} interface IProps {
comment?: IComment;
nodeId: INode['id'];
isBefore?: boolean;
onCancelEdit?: () => void;
}
const initialValues: CommentFormValues = { const LocalCommentForm: FC<IProps> = ({ comment, nodeId, isBefore, onCancelEdit }) => {
text: '',
images: [],
songs: [],
};
const LocalCommentForm: FC<IProps> = () => {
const [textarea, setTextarea] = useState<HTMLTextAreaElement>(); const [textarea, setTextarea] = useState<HTMLTextAreaElement>();
const { formik } = useCommentFormFormik(initialValues); const uploader = useFileUploader(UPLOAD_SUBJECTS.COMMENT, UPLOAD_TARGETS.COMMENTS);
const formik = useCommentFormFormik(
comment || EMPTY_COMMENT,
nodeId,
uploader,
onCancelEdit,
isBefore
);
const isLoading = formik.isSubmitting || uploader.isUploading;
const isEditing = !!comment?.id;
return ( return (
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<FormikProvider value={formik}> <FormikProvider value={formik}>
<LocalCommentFormTextarea setRef={setTextarea} /> <FileUploaderProvider value={uploader}>
{formik.isSubmitting && <div>LOADING</div>} <LocalCommentFormTextarea setRef={setTextarea} />
{!!formik.status && <div>error: {formik.status}</div>}
<Button size="small" disabled={formik.isSubmitting}> <CommentFormAttachButtons onUpload={uploader.uploadFiles} />
SEND <CommentFormFormatButtons element={textarea} handler={formik.handleChange('text')} />
</Button> <LocalCommentFormAttaches />
{isLoading && <LoaderCircle size={20} />}
{isEditing && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
</Button>
)}
<Button
size="small"
color="gray"
iconRight={!isEditing ? 'enter' : 'check'}
disabled={isLoading}
>
{!isEditing ? 'Сказать' : 'Сохранить'}
</Button>
</FileUploaderProvider>
</FormikProvider> </FormikProvider>
</form> </form>
); );

View file

@ -0,0 +1,116 @@
import React, { FC, useCallback, useMemo } from 'react';
import styles from '~/components/comment/CommentForm/styles.module.scss';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import { IFile } from '~/redux/types';
import { SortEnd } from 'react-sortable-hoc';
import { moveArrItem } from '~/utils/fn';
import { useDropZone } from '~/utils/hooks';
import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants';
import { useFileUploaderContext } from '~/utils/hooks/fileUploader';
const LocalCommentFormAttaches: FC = () => {
const { files, pending, setFiles, uploadFiles } = useFileUploaderContext();
const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
files,
]);
const pendingImages = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.IMAGE), [
pending,
]);
const audios = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [
files,
]);
const pendingAudios = useMemo(() => pending.filter(item => item.type === UPLOAD_TYPES.AUDIO), [
pending,
]);
const onDrop = useDropZone(uploadFiles, COMMENT_FILE_TYPES);
const hasImageAttaches = images.length > 0 || pendingImages.length > 0;
const hasAudioAttaches = audios.length > 0 || pendingAudios.length > 0;
const hasAttaches = hasImageAttaches || hasAudioAttaches;
const onImageMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setFiles([
...audios,
...(moveArrItem(
oldIndex,
newIndex,
images.filter(file => !!file)
) as IFile[]),
]);
},
[images, audios, setFiles]
);
const onAudioMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setFiles([
...images,
...(moveArrItem(
oldIndex,
newIndex,
audios.filter(file => !!file)
) as IFile[]),
]);
},
[images, audios, setFiles]
);
const onFileDelete = useCallback(
(fileId: IFile['id']) => {
setFiles(files.filter(file => file.id !== fileId));
},
[setFiles, files]
);
const onAudioTitleChange = useCallback(
(fileId: IFile['id'], title: IFile['metadata']['title']) => {
setFiles(
files.map(file =>
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
)
);
},
[files, setFiles]
);
return (
hasAttaches && (
<div className={styles.attaches} onDropCapture={onDrop}>
{hasImageAttaches && (
<SortableImageGrid
onDelete={onFileDelete}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={pendingImages}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{hasAudioAttaches && (
<SortableAudioGrid
items={audios}
onDelete={onFileDelete}
onTitleChange={onAudioTitleChange}
onSortEnd={onAudioMove}
axis="y"
locked={pendingAudios}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)
);
};
export { LocalCommentFormAttaches };

View file

@ -6,7 +6,7 @@ import React, {
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
useState, useState
} from 'react'; } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import autosize from 'autosize'; import autosize from 'autosize';
@ -59,14 +59,21 @@ const Textarea = memo<IProps>(
const onFocus = useCallback(() => setFocused(true), [setFocused]); const onFocus = useCallback(() => setFocused(true), [setFocused]);
const onBlur = useCallback(() => setFocused(false), [setFocused]); const onBlur = useCallback(() => setFocused(false), [setFocused]);
useEffect(() => {
const target = ref?.current;
if (!target) return;
autosize(target);
setRef(target);
return () => autosize.destroy(target);
}, [ref, setRef]);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
autosize(ref.current); autosize.update(ref.current);
setRef(ref.current); }, [value]);
return () => autosize.destroy(ref.current);
}, [ref.current]);
return ( return (
<div <div
@ -89,7 +96,7 @@ const Textarea = memo<IProps>(
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={{ style={{
maxHeight: maxRows * 20, // maxHeight: maxRows * 20,
minHeight: minRows * 20, minHeight: minRows * 20,
}} }}
{...props} {...props}

View file

@ -1,32 +1,25 @@
import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo } from 'react'; import React, { FC } from 'react';
import { Textarea } from '~/components/input/Textarea';
import { CommentWrapper } from '~/components/containers/CommentWrapper'; import { CommentWrapper } from '~/components/containers/CommentWrapper';
import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import { assocPath } from 'ramda';
import { InputHandler, IFileWithUUID, IFile } from '~/redux/types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions'; import { selectAuthUser } from '~/redux/auth/selectors';
import { selectNode } from '~/redux/node/selectors';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { selectUser, selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '../../comment/CommentForm'; import { CommentForm } from '../../comment/CommentForm';
import { LocalCommentForm } from '~/components/comment/LocalCommentForm';
import { INode } from '~/redux/types';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
user: selectAuthUser(state), user: selectAuthUser(state),
}); });
type IProps = ReturnType<typeof mapStateToProps> & { type IProps = ReturnType<typeof mapStateToProps> & {
is_before?: boolean; isBefore?: boolean;
nodeId: INode['id'];
}; };
const NodeCommentFormUnconnected: FC<IProps> = ({ user, is_before }) => { const NodeCommentFormUnconnected: FC<IProps> = ({ user, isBefore, nodeId }) => {
return ( return (
<CommentWrapper user={user}> <CommentWrapper user={user}>
<CommentForm id={0} is_before={is_before} /> <CommentForm id={0} is_before={isBefore} />
<LocalCommentForm isBefore={isBefore} nodeId={nodeId} />
</CommentWrapper> </CommentWrapper>
); );
}; };

View file

@ -45,7 +45,7 @@ type IProps = ReturnType<typeof mapStateToProps> &
const id = 696; const id = 696;
const BorisLayoutUnconnected: FC<IProps> = ({ const BorisLayoutUnconnected: FC<IProps> = ({
node: { is_loading, is_loading_comments, comments = [], comment_data, comment_count }, node: { is_loading, is_loading_comments, comments = [], comment_data, comment_count, id },
user, user,
user: { is_user, last_seen_boris }, user: { is_user, last_seen_boris },
nodeLoadNode, nodeLoadNode,
@ -92,7 +92,7 @@ const BorisLayoutUnconnected: FC<IProps> = ({
<div className={styles.container}> <div className={styles.container}>
<Card className={styles.content}> <Card className={styles.content}>
<Group className={styles.grid}> <Group className={styles.grid}>
{is_user && <NodeCommentForm is_before />} {is_user && <NodeCommentForm isBefore nodeId={id} />}
{is_loading_comments ? ( {is_loading_comments ? (
<NodeNoComments is_loading count={7} /> <NodeNoComments is_loading count={7} />

View file

@ -12,7 +12,7 @@ import { NodeNoComments } from '~/components/node/NodeNoComments';
import { NodeRelated } from '~/components/node/NodeRelated'; import { NodeRelated } from '~/components/node/NodeRelated';
import { NodeComments } from '~/components/node/NodeComments'; import { NodeComments } from '~/components/node/NodeComments';
import { NodeTags } from '~/components/node/NodeTags'; import { NodeTags } from '~/components/node/NodeTags';
import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES, } from '~/redux/node/constants'; import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES } from '~/redux/node/constants';
import { selectUser } from '~/redux/auth/selectors'; import { selectUser } from '~/redux/auth/selectors';
import { pick } from 'ramda'; import { pick } from 'ramda';
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
@ -30,6 +30,7 @@ import { selectModal } from '~/redux/modal/selectors';
import { SidebarRouter } from '~/containers/main/SidebarRouter'; import { SidebarRouter } from '~/containers/main/SidebarRouter';
import { ITag } from '~/redux/types'; import { ITag } from '~/redux/types';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
const mapStateToProps = (state: IState) => ({ const mapStateToProps = (state: IState) => ({
node: selectNode(state), node: selectNode(state),
@ -60,15 +61,6 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
match: { match: {
params: { id }, params: { id },
}, },
node: {
is_loading,
is_loading_comments,
comments = [],
current: node,
related,
comment_data,
comment_count,
},
modal: { is_shown: is_modal_shown }, modal: { is_shown: is_modal_shown },
user, user,
user: { is_user }, user: { is_user },
@ -86,7 +78,15 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
}) => { }) => {
const [layout, setLayout] = useState({}); const [layout, setLayout] = useState({});
const history = useHistory(); const history = useHistory();
const {
is_loading,
is_loading_comments,
comments = [],
current: node,
related,
comment_data,
comment_count,
} = useShallowSelect(selectNode);
const updateLayout = useCallback(() => setLayout({}), []); const updateLayout = useCallback(() => setLayout({}), []);
useEffect(() => { useEffect(() => {
@ -193,7 +193,7 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
/> />
)} )}
{is_user && !is_loading && <NodeCommentForm />} {is_user && !is_loading && <NodeCommentForm nodeId={node.id} />}
</Group> </Group>
<div className={styles.panel}> <div className={styles.panel}>

View file

@ -230,6 +230,7 @@
padding: 12px 0; padding: 12px 0;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
max-height: 60vh;
} }
.status, .status,

View file

@ -0,0 +1,77 @@
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IFile, IFileWithUUID } from '~/redux/types';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
import { getFileType } from '~/utils/uploader';
import uuid from 'uuid4';
import { useDispatch } from 'react-redux';
import { uploadUploadFiles } from '~/redux/uploads/actions';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectUploads } from '~/redux/uploads/selectors';
export const useFileUploader = (
subject: typeof UPLOAD_SUBJECTS[keyof typeof UPLOAD_SUBJECTS],
target: typeof UPLOAD_TARGETS[keyof typeof UPLOAD_TARGETS]
) => {
const dispatch = useDispatch();
const { files: uploadedFiles, statuses } = useShallowSelect(selectUploads);
const [files, setFiles] = useState<IFile[]>([]);
const [pendingIDs, setPendingIDs] = useState<string[]>([]);
const uploadFiles = useCallback(
(files: File[]) => {
const items: IFileWithUUID[] = files.map(
(file: File): IFileWithUUID => ({
file,
temp_id: uuid(),
subject,
target,
type: getFileType(file),
})
);
const temps = items.map(file => file.temp_id);
setPendingIDs([...pendingIDs, ...temps]);
dispatch(uploadUploadFiles(items));
},
[pendingIDs, setPendingIDs, dispatch, subject, target]
);
useEffect(() => {
const added = pendingIDs
.map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid)
.map(el => !!el && uploadedFiles[el])
.filter(el => !!el && !files.some(file => file && file.id === el.id));
const newPending = pendingIDs.filter(
temp_id =>
statuses[temp_id] &&
(!statuses[temp_id].uuid || !added.some(file => file.id === statuses[temp_id].uuid))
);
if (added.length) {
setPendingIDs(newPending);
setFiles([...files, ...added]);
}
}, [statuses, files, pendingIDs, uploadedFiles]);
const pending = useMemo(() => pendingIDs.map(id => statuses[id]).filter(el => !!el), [
statuses,
pendingIDs,
]);
const isLoading = pending.length > 0;
return { uploadFiles, pending, files, setFiles, isUploading: isLoading };
};
export type FileUploader = ReturnType<typeof useFileUploader>;
const FileUploaderContext = createContext<FileUploader>(null);
export const FileUploaderProvider: FC<{ value: FileUploader; children }> = ({
value,
children,
}) => <FileUploaderContext.Provider value={value}>{children}</FileUploaderContext.Provider>;
export const useFileUploaderContext = () => useContext(FileUploaderContext);

View file

@ -1,64 +1,77 @@
import { IComment, IFile } from '~/redux/types'; import { IComment, INode } from '~/redux/types';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { FormikHelpers, useFormik, useFormikContext } from 'formik'; import { FormikHelpers, useFormik, useFormikContext } from 'formik';
import { array, object, string } from 'yup'; import { array, object, string } from 'yup';
import { FileUploader } from '~/utils/hooks/fileUploader';
export interface CommentFormValues {
text: string;
images: IFile[];
songs: IFile[];
}
const validationSchema = object().shape({ const validationSchema = object().shape({
text: string(), text: string(),
images: array(), files: array(),
songs: array(),
}); });
const submitComment = async ( const submitComment = (
id: IComment['id'], id: INode['id'],
values: CommentFormValues, values: IComment,
callback: (e: string) => void isBefore: boolean,
callback: (e?: string) => void
) => { ) => {
await new Promise(resolve => setTimeout(resolve, 500)); console.log('Submitting', id, values);
callback('wrong'); new Promise(resolve => setTimeout(resolve, 500)).then(() => callback());
}; };
export const useCommentFormFormik = (values: CommentFormValues) => { const onSuccess = ({ resetForm, setStatus, setSubmitting }: FormikHelpers<IComment>) => (
e: string
) => {
setSubmitting(false);
if (e) {
setStatus(e);
return;
}
if (resetForm) {
resetForm();
}
};
export const useCommentFormFormik = (
values: IComment,
nodeId: INode['id'],
uploader: FileUploader,
stopEditing?: () => void,
isBefore: boolean = false
) => {
const { current: initialValues } = useRef(values); const { current: initialValues } = useRef(values);
const onSuccess = useCallback(
({ resetForm, setStatus, setSubmitting }: FormikHelpers<CommentFormValues>) => (e: string) => {
setSubmitting(false);
if (e) {
setStatus(e);
return;
}
if (resetForm) {
resetForm();
}
},
[]
);
const onSubmit = useCallback( const onSubmit = useCallback(
(values: CommentFormValues, helpers: FormikHelpers<CommentFormValues>) => { (values: IComment, helpers: FormikHelpers<IComment>) => {
helpers.setSubmitting(true); helpers.setSubmitting(true);
submitComment(0, values, onSuccess(helpers)); submitComment(
nodeId,
{
...values,
files: uploader.files,
},
isBefore,
onSuccess(helpers)
);
}, },
[values, onSuccess] [isBefore, nodeId, uploader.files]
); );
const formik = useFormik({ const onReset = useCallback(() => {
uploader.setFiles([]);
if (stopEditing) stopEditing();
}, [uploader, stopEditing]);
return useFormik({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit, onSubmit,
initialStatus: '', initialStatus: '',
onReset,
}); });
return { formik };
}; };
export const useCommentFormContext = () => useFormikContext<CommentFormValues>(); export const useCommentFormContext = () => useFormikContext<IComment>();

View file

@ -0,0 +1,5 @@
import { shallowEqual, useSelector } from 'react-redux';
import { IState } from '~/redux/store';
export const useShallowSelect = <T extends (state: IState) => any>(selector: T): ReturnType<T> =>
useSelector(selector, shallowEqual);