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

99 use swr (#100)

* 99: made node use SWR

* 99: fixed comments for SWR node

* 99: added error toast to useNodeFormFormik.ts
This commit is contained in:
muerwre 2022-01-02 17:10:21 +07:00 committed by GitHub
parent 832386d39a
commit c2d1c2bfc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 366 additions and 413 deletions

View file

@ -8,8 +8,9 @@ import classNames from 'classnames';
import { NEW_COMMENT_CLASSNAME } from '~/constants/comment'; import { NEW_COMMENT_CLASSNAME } from '~/constants/comment';
type IProps = HTMLAttributes<HTMLDivElement> & { type IProps = HTMLAttributes<HTMLDivElement> & {
is_empty?: boolean; nodeId: number;
is_loading?: boolean; isEmpty?: boolean;
isLoading?: boolean;
group: ICommentGroup; group: ICommentGroup;
isSame?: boolean; isSame?: boolean;
canEdit?: boolean; canEdit?: boolean;
@ -20,9 +21,10 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
const Comment: FC<IProps> = memo( const Comment: FC<IProps> = memo(
({ ({
group, group,
is_empty, nodeId,
isEmpty,
isSame, isSame,
is_loading, isLoading,
className, className,
canEdit, canEdit,
onDelete, onDelete,
@ -34,8 +36,8 @@ const Comment: FC<IProps> = memo(
className={classNames(className, { className={classNames(className, {
[NEW_COMMENT_CLASSNAME]: group.hasNew, [NEW_COMMENT_CLASSNAME]: group.hasNew,
})} })}
isEmpty={is_empty} isEmpty={isEmpty}
isLoading={is_loading} isLoading={isLoading}
user={group.user} user={group.user}
isNew={group.hasNew && !isSame} isNew={group.hasNew && !isSame}
{...props} {...props}
@ -48,9 +50,10 @@ const Comment: FC<IProps> = memo(
return ( return (
<CommentContent <CommentContent
nodeId={nodeId}
comment={comment} comment={comment}
key={comment.id} key={comment.id}
can_edit={!!canEdit} canEdit={!!canEdit}
onDelete={onDelete} onDelete={onDelete}
onShowImageModal={onShowImageModal} onShowImageModal={onShowImageModal}
/> />

View file

@ -13,19 +13,18 @@ import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { CommentMenu } from '../CommentMenu'; import { CommentMenu } from '../CommentMenu';
import { CommentForm } from '~/components/comment/CommentForm'; import { CommentForm } from '~/components/comment/CommentForm';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectNode } from '~/redux/node/selectors';
interface IProps { interface IProps {
nodeId: number;
comment: IComment; comment: IComment;
can_edit: boolean; canEdit: boolean;
onDelete: (id: IComment['id'], isLocked: boolean) => void; onDelete: (id: IComment['id'], isLocked: boolean) => void;
onShowImageModal: (images: IFile[], index: number) => void; onShowImageModal: (images: IFile[], index: number) => void;
} }
const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onShowImageModal }) => { const CommentContent: FC<IProps> = memo(
({ comment, canEdit, nodeId, onDelete, onShowImageModal }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const { current } = useShallowSelect(selectNode);
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]); const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
@ -46,8 +45,8 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onShowIm
}, [comment, onDelete]); }, [comment, onDelete]);
const menu = useMemo( const menu = useMemo(
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />, () => canEdit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />,
[can_edit, startEditing, onLockClick] [canEdit, startEditing, onLockClick]
); );
const blocks = useMemo( const blocks = useMemo(
@ -59,7 +58,7 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onShowIm
); );
if (isEditing) { if (isEditing) {
return <CommentForm nodeId={current.id} comment={comment} onCancelEdit={stopEditing} />; return <CommentForm nodeId={nodeId} comment={comment} onCancelEdit={stopEditing} />;
} }
return ( return (
@ -85,7 +84,9 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onShowIm
{menu} {menu}
<div <div
className={classNames(styles.images, { [styles.multiple]: groupped.image.length > 1 })} className={classNames(styles.images, {
[styles.multiple]: groupped.image.length > 1,
})}
> >
{groupped.image.map((file, index) => ( {groupped.image.map((file, index) => (
<div key={file.id} onClick={() => onShowImageModal(groupped.image, index)}> <div key={file.id} onClick={() => onShowImageModal(groupped.image, index)}>
@ -113,6 +114,7 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onShowIm
)} )}
</div> </div>
); );
}); }
);
export { CommentContent }; export { CommentContent };

View file

@ -10,7 +10,7 @@ import styles from './styles.module.scss';
import { NodeEditorProps } from '~/redux/node/types'; import { NodeEditorProps } from '~/redux/node/types';
import { useNodeImages } from '~/utils/hooks/node/useNodeImages'; import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios'; import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
import { useFileUploaderContext } from '~/utils/hooks/useFileUploader'; import { useFileUploaderContext } from '~/utils/hooks/useFileUploader';
import { UploadDropzone } from '~/components/upload/UploadDropzone'; import { UploadDropzone } from '~/components/upload/UploadDropzone';

View file

@ -1,9 +1,8 @@
import React, { FC, createElement } from 'react'; import React, { FC, createElement } from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { INode } from '~/redux/types';
import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants'; import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants';
import { has } from 'ramda'; import { has } from 'ramda';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
const EditorActionsPanel: FC = () => { const EditorActionsPanel: FC = () => {
const { values } = useNodeFormContext(); const { values } = useNodeFormContext();

View file

@ -4,7 +4,7 @@ import { Group } from '~/components/containers/Group';
import { InputText } from '~/components/input/InputText'; import { InputText } from '~/components/input/InputText';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { Padder } from '~/components/containers/Padder'; import { Padder } from '~/components/containers/Padder';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
const EditorButtons: FC = () => { const EditorButtons: FC = () => {
const { values, handleChange, isSubmitting } = useNodeFormContext(); const { values, handleChange, isSubmitting } = useNodeFormContext();
@ -28,6 +28,7 @@ const EditorButtons: FC = () => {
iconRight="check" iconRight="check"
color={values.is_promoted ? 'primary' : 'lab'} color={values.is_promoted ? 'primary' : 'lab'}
disabled={isSubmitting} disabled={isSubmitting}
type="submit"
/> />
</Group> </Group>
</Padder> </Padder>

View file

@ -3,7 +3,7 @@ import { IEditorComponentProps } from '~/redux/node/types';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
interface IProps extends IEditorComponentProps {} interface IProps extends IEditorComponentProps {}

View file

@ -5,7 +5,7 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants';
import { IEditorComponentProps } from '~/redux/node/types'; import { IEditorComponentProps } from '~/redux/node/types';
import { useFileUploaderContext } from '~/utils/hooks/useFileUploader'; import { useFileUploaderContext } from '~/utils/hooks/useFileUploader';
import { getFileType } from '~/utils/uploader'; import { getFileType } from '~/utils/uploader';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
type IProps = IEditorComponentProps & { type IProps = IEditorComponentProps & {

View file

@ -12,7 +12,7 @@ import { Icon } from '~/components/input/Icon';
import { PRESETS } from '~/constants/urls'; import { PRESETS } from '~/constants/urls';
import { IEditorComponentProps } from '~/redux/node/types'; import { IEditorComponentProps } from '~/redux/node/types';
import { useFileUploader, useFileUploaderContext } from '~/utils/hooks/useFileUploader'; import { useFileUploader, useFileUploaderContext } from '~/utils/hooks/useFileUploader';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
import { getFileType } from '~/utils/uploader'; import { getFileType } from '~/utils/uploader';
type IProps = IEditorComponentProps & {}; type IProps = IEditorComponentProps & {};

View file

@ -4,7 +4,7 @@ import styles from './styles.module.scss';
import { Textarea } from '~/components/input/Textarea'; import { Textarea } from '~/components/input/Textarea';
import { path } from 'ramda'; import { path } from 'ramda';
import { NodeEditorProps } from '~/redux/node/types'; import { NodeEditorProps } from '~/redux/node/types';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
type IProps = NodeEditorProps & {}; type IProps = NodeEditorProps & {};

View file

@ -6,7 +6,7 @@ import { InputText } from '~/components/input/InputText';
import classnames from 'classnames'; import classnames from 'classnames';
import { getYoutubeThumb } from '~/utils/dom'; import { getYoutubeThumb } from '~/utils/dom';
import { NodeEditorProps } from '~/redux/node/types'; import { NodeEditorProps } from '~/redux/node/types';
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormContext } from '~/utils/hooks/node/useNodeFormFormik';
type IProps = NodeEditorProps & {}; type IProps = NodeEditorProps & {};

View file

@ -64,7 +64,6 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
<div className={styles.single}> <div className={styles.single}>
<ImagePreloader <ImagePreloader
file={images[0]} file={images[0]}
onLoad={updateSwiper}
onClick={() => onOpenPhotoSwipe(0)} onClick={() => onOpenPhotoSwipe(0)}
className={styles.image} className={styles.image}
/> />

View file

@ -3,6 +3,10 @@ import { EMPTY_NODE, NODE_TYPES } from '~/redux/node/constants';
import { EditorDialog } from '~/containers/dialogs/EditorDialog'; import { EditorDialog } from '~/containers/dialogs/EditorDialog';
import { useHistory, useRouteMatch } from 'react-router'; import { useHistory, useRouteMatch } from 'react-router';
import { values } from 'ramda'; import { values } from 'ramda';
import { INode } from '~/redux/types';
import { apiPostNode } from '~/redux/node/api';
import { useUpdateNode } from '~/utils/hooks/data/useUpdateNode';
import { useCreateNode } from '~/utils/hooks/data/useCreateNode';
const EditorCreateDialog: FC = () => { const EditorCreateDialog: FC = () => {
const history = useHistory(); const history = useHistory();
@ -25,11 +29,21 @@ const EditorCreateDialog: FC = () => {
const data = useRef({ ...EMPTY_NODE, type, is_promoted: !isInLab }); const data = useRef({ ...EMPTY_NODE, type, is_promoted: !isInLab });
const createNode = useCreateNode();
const onSubmit = useCallback(
async (node: INode) => {
await createNode(node);
goBack();
},
[goBack, createNode]
);
if (!type || !isExist) { if (!type || !isExist) {
return null; return null;
} }
return <EditorDialog node={data.current} onRequestClose={goBack} />; return <EditorDialog node={data.current} onRequestClose={goBack} onSubmit={onSubmit} />;
}; };
export { EditorCreateDialog }; export { EditorCreateDialog };

View file

@ -5,7 +5,7 @@ import { NODE_EDITORS } from '~/redux/node/constants';
import { BetterScrollDialog } from '../BetterScrollDialog'; import { BetterScrollDialog } from '../BetterScrollDialog';
import { CoverBackdrop } from '~/components/containers/CoverBackdrop'; import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
import { prop } from 'ramda'; import { prop } from 'ramda';
import { useNodeFormFormik } from '~/utils/hooks/useNodeFormFormik'; import { useNodeFormFormik } from '~/utils/hooks/node/useNodeFormFormik';
import { EditorButtons } from '~/components/editors/EditorButtons'; import { EditorButtons } from '~/components/editors/EditorButtons';
import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/useFileUploader'; import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/useFileUploader';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
@ -15,16 +15,18 @@ import { ModalWrapper } from '~/components/dialogs/ModalWrapper';
import { useTranslatedError } from '~/utils/hooks/useTranslatedError'; import { useTranslatedError } from '~/utils/hooks/useTranslatedError';
import { useCloseOnEscape } from '~/utils/hooks'; import { useCloseOnEscape } from '~/utils/hooks';
import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose'; import { EditorConfirmClose } from '~/components/editors/EditorConfirmClose';
import { on } from 'cluster';
interface Props extends IDialogProps { interface Props extends IDialogProps {
node: INode; node: INode;
onSubmit: (node: INode) => Promise<unknown>;
} }
const EditorDialog: FC<Props> = ({ node, onRequestClose }) => { const EditorDialog: FC<Props> = ({ node, onRequestClose, onSubmit }) => {
const [isConfirmModalShown, setConfirmModalShown] = useState(false); const [isConfirmModalShown, setConfirmModalShown] = useState(false);
const uploader = useFileUploader(UPLOAD_SUBJECTS.EDITOR, UPLOAD_TARGETS.NODES, node.files); const uploader = useFileUploader(UPLOAD_SUBJECTS.EDITOR, UPLOAD_TARGETS.NODES, node.files);
const formik = useNodeFormFormik(node, uploader, onRequestClose); const formik = useNodeFormFormik(node, uploader, onRequestClose, onSubmit);
const { values, handleSubmit, dirty, status } = formik; const { values, handleSubmit, dirty, status } = formik;
const component = useMemo(() => node.type && prop(node.type, NODE_EDITORS), [node.type]); const component = useMemo(() => node.type && prop(node.type, NODE_EDITORS), [node.type]);
@ -71,6 +73,7 @@ const EditorDialog: FC<Props> = ({ node, onRequestClose }) => {
{isConfirmModalShown && ( {isConfirmModalShown && (
<EditorConfirmClose onApprove={onRequestClose} onDecline={closeConfirmModal} /> <EditorConfirmClose onApprove={onRequestClose} onDecline={closeConfirmModal} />
)} )}
<div className={styles.editor}>{createElement(component)}</div> <div className={styles.editor}>{createElement(component)}</div>
</> </>
</BetterScrollDialog> </BetterScrollDialog>

View file

@ -5,7 +5,8 @@ import { ModalWrapper } from '~/components/dialogs/ModalWrapper';
import { LoaderCircle } from '~/components/input/LoaderCircle'; import { LoaderCircle } from '~/components/input/LoaderCircle';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { useGetNode } from '~/utils/hooks/data/useGetNode'; import { useGetNode } from '~/utils/hooks/data/useGetNode';
import { EMPTY_NODE } from '~/redux/node/constants'; import { useUpdateNode } from '~/utils/hooks/data/useUpdateNode';
import { INode } from '~/redux/types';
const EditorEditDialog: FC = () => { const EditorEditDialog: FC = () => {
const history = useHistory(); const history = useHistory();
@ -24,6 +25,15 @@ const EditorEditDialog: FC = () => {
}, [backUrl, history]); }, [backUrl, history]);
const { node, isLoading } = useGetNode(parseInt(id, 10)); const { node, isLoading } = useGetNode(parseInt(id, 10));
const updateNode = useUpdateNode(parseInt(id, 10));
const onSubmit = useCallback(
async (node: INode) => {
await updateNode(node);
goBack();
},
[updateNode, goBack]
);
if (isLoading || !node) { if (isLoading || !node) {
return ( return (
@ -35,7 +45,7 @@ const EditorEditDialog: FC = () => {
); );
} }
return <EditorDialog node={node || EMPTY_NODE} onRequestClose={goBack} />; return <EditorDialog node={node} onRequestClose={goBack} onSubmit={onSubmit} />;
}; };
export { EditorEditDialog }; export { EditorEditDialog };

View file

@ -9,6 +9,7 @@ import { useGrouppedComments } from '~/utils/hooks/node/useGrouppedComments';
import { useCommentContext } from '~/utils/context/CommentContextProvider'; import { useCommentContext } from '~/utils/context/CommentContextProvider';
import { Comment } from '~/components/comment/Comment'; import { Comment } from '~/components/comment/Comment';
import { useUserContext } from '~/utils/context/UserContextProvider'; import { useUserContext } from '~/utils/context/UserContextProvider';
import { useNodeContext } from '~/utils/context/NodeContextProvider';
interface IProps { interface IProps {
order: 'ASC' | 'DESC'; order: 'ASC' | 'DESC';
@ -16,6 +17,8 @@ interface IProps {
const NodeComments: FC<IProps> = memo(({ order }) => { const NodeComments: FC<IProps> = memo(({ order }) => {
const user = useUserContext(); const user = useUserContext();
const { node } = useNodeContext();
const { const {
comments, comments,
count, count,
@ -41,12 +44,17 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
[left, onLoadMoreComments] [left, onLoadMoreComments]
); );
if (!node?.id) {
return null;
}
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
{order === 'DESC' && more} {order === 'DESC' && more}
{groupped.map(group => ( {groupped.map(group => (
<Comment <Comment
nodeId={node.id!}
key={group.ids.join()} key={group.ids.join()}
group={group} group={group}
canEdit={canEditComment(group, user)} canEdit={canEditComment(group, user)}

View file

@ -12,6 +12,7 @@ import { CommentContextProvider } from '~/utils/context/CommentContextProvider';
import { TagsContextProvider } from '~/utils/context/TagsContextProvider'; import { TagsContextProvider } from '~/utils/context/TagsContextProvider';
import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions'; import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions';
import { NodeRelatedProvider } from '~/utils/providers/NodeRelatedProvider'; import { NodeRelatedProvider } from '~/utils/providers/NodeRelatedProvider';
import { useGetNode } from '~/utils/hooks/data/useGetNode';
type Props = RouteComponentProps<{ id: string }> & {}; type Props = RouteComponentProps<{ id: string }> & {};
@ -20,14 +21,8 @@ const NodePage: FC<Props> = ({
params: { id }, params: { id },
}, },
}) => { }) => {
const { const { node, isLoading } = useGetNode(parseInt(id, 10));
node, const { isLoadingComments, comments, commentsCount, lastSeenCurrent } = useFullNode(id);
isLoading,
isLoadingComments,
comments,
commentsCount,
lastSeenCurrent,
} = useFullNode(id);
const onShowImageModal = useImageModal(); const onShowImageModal = useImageModal();
const { onLoadMoreComments, onDelete: onDeleteComment } = useNodeComments(parseInt(id, 10)); const { onLoadMoreComments, onDelete: onDeleteComment } = useNodeComments(parseInt(id, 10));
@ -39,6 +34,11 @@ const NodePage: FC<Props> = ({
useScrollToTop([id, isLoadingComments]); useScrollToTop([id, isLoadingComments]);
if (!node) {
// TODO: do something here
return null;
}
return ( return (
<NodeContextProvider node={node} isLoading={isLoading}> <NodeContextProvider node={node} isLoading={isLoading}>
<NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}> <NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}>

View file

@ -47,16 +47,7 @@ export const nodePostLocalComment = (
nodeId, nodeId,
comment, comment,
callback, callback,
type: NODE_ACTIONS.POST_COMMENT, type: NODE_ACTIONS.POST_LOCAL_COMMENT,
});
export const nodeSubmitLocal = (
node: INode,
callback: (e?: string, errors?: Record<string, string>) => void
) => ({
node,
callback,
type: NODE_ACTIONS.SUBMIT_LOCAL,
}); });
export const nodeSetSendingComment = (is_sending_comment: boolean) => ({ export const nodeSetSendingComment = (is_sending_comment: boolean) => ({
@ -69,34 +60,6 @@ export const nodeSetComments = (comments: IComment[]) => ({
type: NODE_ACTIONS.SET_COMMENTS, type: NODE_ACTIONS.SET_COMMENTS,
}); });
export const nodeUpdateTags = (id: INode['id'], tags: string[]) => ({
type: NODE_ACTIONS.UPDATE_TAGS,
id,
tags,
});
export const nodeDeleteTag = (id: INode['id'], tagId: ITag['ID']) => ({
type: NODE_ACTIONS.DELETE_TAG,
id: id!,
tagId,
});
export const nodeSetTags = (tags: ITag[]) => ({
type: NODE_ACTIONS.SET_TAGS,
tags,
});
export const nodeCreate = (node_type: INode['type'], isLab?: boolean) => ({
type: NODE_ACTIONS.CREATE,
node_type,
isLab,
});
export const nodeEdit = (id: INode['id']) => ({
type: NODE_ACTIONS.EDIT,
id,
});
export const nodeLike = (id: INode['id']) => ({ export const nodeLike = (id: INode['id']) => ({
type: NODE_ACTIONS.LIKE, type: NODE_ACTIONS.LIKE,
id, id,
@ -113,17 +76,13 @@ export const nodeLock = (id: INode['id'], is_locked: boolean) => ({
is_locked, is_locked,
}); });
export const nodeLockComment = (id: IComment['id'], is_locked: boolean) => ({ export const nodeLockComment = (id: number, is_locked: boolean, nodeId: number) => ({
type: NODE_ACTIONS.LOCK_COMMENT, type: NODE_ACTIONS.LOCK_COMMENT,
nodeId,
id, id,
is_locked, is_locked,
}); });
export const nodeEditComment = (id: IComment['id']) => ({
type: NODE_ACTIONS.EDIT_COMMENT,
id,
});
export const nodeSetEditor = (editor: INode) => ({ export const nodeSetEditor = (editor: INode) => ({
type: NODE_ACTIONS.SET_EDITOR, type: NODE_ACTIONS.SET_EDITOR,
editor, editor,

View file

@ -42,9 +42,6 @@ export type ApiGetNodeCommentsResponse = { comments: IComment[]; comment_count:
export const apiPostNode = ({ node }: ApiPostNodeRequest) => export const apiPostNode = ({ node }: ApiPostNodeRequest) =>
api.post<ApiPostNodeResult>(API.NODE.SAVE, node).then(cleanResult); api.post<ApiPostNodeResult>(API.NODE.SAVE, node).then(cleanResult);
export const apiPostNodeLocal = ({ node }: ApiPostNodeRequest) =>
api.post<ApiPostNodeResult>(API.NODE.SAVE, node).then(cleanResult);
export const getNodeDiff = ({ export const getNodeDiff = ({
start, start,
end, end,
@ -69,7 +66,10 @@ export const getNodeDiff = ({
.then(cleanResult); .then(cleanResult);
export const apiGetNode = ({ id }: ApiGetNodeRequest, config?: AxiosRequestConfig) => export const apiGetNode = ({ id }: ApiGetNodeRequest, config?: AxiosRequestConfig) =>
api.get<ApiGetNodeResponse>(API.NODE.GET_NODE(id), config).then(cleanResult); api
.get<ApiGetNodeResponse>(API.NODE.GET_NODE(id), config)
.then(cleanResult)
.then(data => ({ node: data.node, last_seen: data.last_seen }));
export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => { export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => {
const cancelToken = axios.CancelToken.source(); const cancelToken = axios.CancelToken.source();

View file

@ -22,22 +22,18 @@ import { LabPad } from '~/components/lab/LabPad';
import { LabDescription } from '~/components/lab/LabDescription'; import { LabDescription } from '~/components/lab/LabDescription';
import { LabVideo } from '~/components/lab/LabVideo'; import { LabVideo } from '~/components/lab/LabVideo';
import { LabAudio } from '~/components/lab/LabAudioBlock'; import { LabAudio } from '~/components/lab/LabAudioBlock';
import { LabLine } from '~/components/lab/LabLine';
const prefix = 'NODE.'; const prefix = 'NODE.';
export const NODE_ACTIONS = { export const NODE_ACTIONS = {
SUBMIT_LOCAL: `${prefix}SUBMIT_LOCAL`,
LOAD_NODE: `${prefix}LOAD_NODE`, LOAD_NODE: `${prefix}LOAD_NODE`,
GOTO_NODE: `${prefix}GOTO_NODE`, GOTO_NODE: `${prefix}GOTO_NODE`,
SET: `${prefix}SET`, SET: `${prefix}SET`,
EDIT: `${prefix}EDIT`,
LIKE: `${prefix}LIKE`, LIKE: `${prefix}LIKE`,
STAR: `${prefix}STAR`, STAR: `${prefix}STAR`,
LOCK: `${prefix}LOCK`, LOCK: `${prefix}LOCK`,
LOCK_COMMENT: `${prefix}LOCK_COMMENT`, LOCK_COMMENT: `${prefix}LOCK_COMMENT`,
EDIT_COMMENT: `${prefix}EDIT_COMMENT`, EDIT_COMMENT: `${prefix}EDIT_COMMENT`,
CREATE: `${prefix}CREATE`,
LOAD_MORE_COMMENTS: `${prefix}LOAD_MORE_COMMENTS`, LOAD_MORE_COMMENTS: `${prefix}LOAD_MORE_COMMENTS`,
SET_SAVE_ERRORS: `${prefix}SET_SAVE_ERRORS`, SET_SAVE_ERRORS: `${prefix}SET_SAVE_ERRORS`,
@ -47,13 +43,10 @@ export const NODE_ACTIONS = {
SET_CURRENT: `${prefix}SET_CURRENT`, SET_CURRENT: `${prefix}SET_CURRENT`,
SET_EDITOR: `${prefix}SET_EDITOR`, SET_EDITOR: `${prefix}SET_EDITOR`,
POST_COMMENT: `${prefix}POST_LOCAL_COMMENT`, POST_LOCAL_COMMENT: `${prefix}POST_LOCAL_COMMENT`,
SET_COMMENTS: `${prefix}SET_COMMENTS`, SET_COMMENTS: `${prefix}SET_COMMENTS`,
SET_RELATED: `${prefix}SET_RELATED`, SET_RELATED: `${prefix}SET_RELATED`,
UPDATE_TAGS: `${prefix}UPDATE_TAGS`,
DELETE_TAG: `${prefix}DELETE_TAG`,
SET_TAGS: `${prefix}SET_TAGS`,
SET_COVER_IMAGE: `${prefix}SET_COVER_IMAGE`, SET_COVER_IMAGE: `${prefix}SET_COVER_IMAGE`,
}; };

View file

@ -10,7 +10,6 @@ import {
nodeSetLoadingComments, nodeSetLoadingComments,
nodeSetSaveErrors, nodeSetSaveErrors,
nodeSetSendingComment, nodeSetSendingComment,
nodeSetTags,
} from './actions'; } from './actions';
import { INodeState } from './reducer'; import { INodeState } from './reducer';
@ -41,9 +40,6 @@ const setSendingComment = (
const setComments = (state: INodeState, { comments }: ReturnType<typeof nodeSetComments>) => const setComments = (state: INodeState, { comments }: ReturnType<typeof nodeSetComments>) =>
assocPath(['comments'], comments, state); assocPath(['comments'], comments, state);
const setTags = (state: INodeState, { tags }: ReturnType<typeof nodeSetTags>) =>
assocPath(['current', 'tags'], tags, state);
const setEditor = (state: INodeState, { editor }: ReturnType<typeof nodeSetEditor>) => const setEditor = (state: INodeState, { editor }: ReturnType<typeof nodeSetEditor>) =>
assocPath(['editor'], editor, state); assocPath(['editor'], editor, state);
@ -60,7 +56,6 @@ export const NODE_HANDLERS = {
[NODE_ACTIONS.SET_CURRENT]: setCurrent, [NODE_ACTIONS.SET_CURRENT]: setCurrent,
[NODE_ACTIONS.SET_SENDING_COMMENT]: setSendingComment, [NODE_ACTIONS.SET_SENDING_COMMENT]: setSendingComment,
[NODE_ACTIONS.SET_COMMENTS]: setComments, [NODE_ACTIONS.SET_COMMENTS]: setComments,
[NODE_ACTIONS.SET_TAGS]: setTags,
[NODE_ACTIONS.SET_EDITOR]: setEditor, [NODE_ACTIONS.SET_EDITOR]: setEditor,
[NODE_ACTIONS.SET_COVER_IMAGE]: setCoverImage, [NODE_ACTIONS.SET_COVER_IMAGE]: setCoverImage,
}; };

View file

@ -1,11 +1,7 @@
import { call, put, select, takeLatest, takeLeading } from 'redux-saga/effects'; import { call, put, select, takeLatest, takeLeading } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import { COMMENTS_DISPLAY, EMPTY_NODE, NODE_ACTIONS, NODE_EDITOR_DATA } from './constants'; import { COMMENTS_DISPLAY, EMPTY_NODE, NODE_ACTIONS, NODE_EDITOR_DATA } from './constants';
import { import {
nodeCreate,
nodeDeleteTag,
nodeEdit,
nodeGotoNode, nodeGotoNode,
nodeLike, nodeLike,
nodeLoadNode, nodeLoadNode,
@ -15,21 +11,13 @@ import {
nodeSet, nodeSet,
nodeSetComments, nodeSetComments,
nodeSetCurrent, nodeSetCurrent,
nodeSetEditor,
nodeSetLoading,
nodeSetLoadingComments, nodeSetLoadingComments,
nodeSetTags,
nodeSubmitLocal,
nodeUpdateTags,
} from './actions'; } from './actions';
import { import {
apiDeleteNodeTag,
apiGetNode,
apiGetNodeComments, apiGetNodeComments,
apiLockComment, apiLockComment,
apiLockNode, apiLockNode,
apiPostComment, apiPostComment,
apiPostNode,
apiPostNodeHeroic, apiPostNodeHeroic,
apiPostNodeLike, apiPostNodeLike,
apiPostNodeTags, apiPostNodeTags,
@ -45,6 +33,8 @@ import { DIALOGS } from '~/redux/modal/constants';
import { has } from 'ramda'; import { has } from 'ramda';
import { selectLabListNodes } from '~/redux/lab/selectors'; import { selectLabListNodes } from '~/redux/lab/selectors';
import { labSetList } from '~/redux/lab/actions'; import { labSetList } from '~/redux/lab/actions';
import { apiPostNode } from '~/redux/node/api';
import { showErrorToast } from '~/utils/errors/showToast';
export function* updateNodeEverywhere(node) { export function* updateNodeEverywhere(node) {
const { const {
@ -66,42 +56,6 @@ export function* updateNodeEverywhere(node) {
); );
} }
function* onNodeSubmitLocal({ node, callback }: ReturnType<typeof nodeSubmitLocal>) {
try {
const { errors, node: result }: Unwrap<typeof apiPostNode> = yield call(apiPostNode, { node });
if (errors && Object.values(errors).length > 0) {
callback('', errors);
return;
}
if (node.is_promoted) {
const nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
const updated_flow_nodes = node.id
? nodes.map(item => (item.id === result.id ? result : item))
: [result, ...nodes];
yield put(flowSetNodes(updated_flow_nodes));
} else {
const nodes: ReturnType<typeof selectLabListNodes> = yield select(selectLabListNodes);
const updated_lab_nodes = node.id
? nodes.map(item => (item.node.id === result.id ? { ...item, node: result } : item))
: [{ node: result, comment_count: 0, last_seen: node.created_at }, ...nodes];
yield put(labSetList({ nodes: updated_lab_nodes }));
}
const { current } = yield select(selectNode);
if (node.id && current.id === result.id) {
yield put(nodeSetCurrent(result));
}
callback();
return;
} catch (error) {
callback(error.message);
}
}
function* onNodeGoto({ id, node_type }: ReturnType<typeof nodeGotoNode>) { function* onNodeGoto({ id, node_type }: ReturnType<typeof nodeGotoNode>) {
if (!id) { if (!id) {
return; return;
@ -164,22 +118,9 @@ function* nodeGetComments(id: INode['id']) {
} }
function* onNodeLoad({ id }: ReturnType<typeof nodeLoadNode>) { function* onNodeLoad({ id }: ReturnType<typeof nodeLoadNode>) {
// Get node body
try {
yield put(nodeSetLoading(true));
yield put(nodeSetLoadingComments(true));
const { node, last_seen }: Unwrap<typeof apiGetNode> = yield call(apiGetNode, { id });
yield put(nodeSet({ current: node, lastSeenCurrent: last_seen }));
yield put(nodeSetLoading(false));
} catch (error) {
yield put(push(URLS.ERRORS.NOT_FOUND));
yield put(nodeSetLoading(false));
}
// Comments // Comments
try { try {
yield put(nodeSetLoadingComments(true));
yield call(nodeGetComments, id); yield call(nodeGetComments, id);
yield put( yield put(
@ -197,9 +138,6 @@ function* onPostComment({ nodeId, comment, callback }: ReturnType<typeof nodePos
id: nodeId, id: nodeId,
}); });
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
if (current?.id === nodeId) {
const { comments }: ReturnType<typeof selectNode> = yield select(selectNode); const { comments }: ReturnType<typeof selectNode> = yield select(selectNode);
if (!comment.id) { if (!comment.id) {
@ -213,66 +151,11 @@ function* onPostComment({ nodeId, comment, callback }: ReturnType<typeof nodePos
} }
callback(); callback();
}
} catch (error) { } catch (error) {
return callback(error.message); return callback(error.message);
} }
} }
function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
try {
const { node }: Unwrap<typeof apiPostNodeTags> = yield call(apiPostNodeTags, { id, tags });
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
if (!node || !node.id || node.id !== current.id) return;
yield put(nodeSetTags(node.tags));
} catch {}
}
function* onDeleteTag({ id, tagId }: ReturnType<typeof nodeDeleteTag>) {
try {
const { tags }: Unwrap<typeof apiDeleteNodeTag> = yield call(apiDeleteNodeTag, { id, tagId });
yield put(nodeSetTags(tags));
} catch {}
}
function* onCreateSaga({ node_type: type, isLab }: ReturnType<typeof nodeCreate>) {
if (!type || !has(type, NODE_EDITOR_DIALOGS)) return;
yield put(
nodeSetEditor({
...EMPTY_NODE,
...(NODE_EDITOR_DATA[type] || {}),
type,
is_promoted: !isLab,
})
);
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[type]));
}
function* onEditSaga({ id }: ReturnType<typeof nodeEdit>) {
try {
if (!id) {
return;
}
yield put(modalShowDialog(DIALOGS.LOADING));
const { node }: Unwrap<typeof apiGetNode> = yield call(apiGetNode, { id });
if (!node.type || !has(node.type, NODE_EDITOR_DIALOGS)) return;
if (!NODE_EDITOR_DIALOGS[node?.type]) {
throw new Error('Unknown node type');
}
yield put(nodeSetEditor(node));
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
} catch (error) {
yield put(modalSetShown(false));
}
}
function* onLikeSaga({ id }: ReturnType<typeof nodeLike>) { function* onLikeSaga({ id }: ReturnType<typeof nodeLike>) {
const { current }: ReturnType<typeof selectNode> = yield select(selectNode); const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
@ -330,22 +213,12 @@ function* onLockSaga({ id, is_locked }: ReturnType<typeof nodeLock>) {
} }
} }
function* onLockCommentSaga({ id, is_locked }: ReturnType<typeof nodeLockComment>) { function* onLockCommentSaga({ nodeId, id, is_locked }: ReturnType<typeof nodeLockComment>) {
const { current, comments }: ReturnType<typeof selectNode> = yield select(selectNode); const { comments }: ReturnType<typeof selectNode> = yield select(selectNode);
try { try {
yield put(
nodeSetComments(
comments.map(comment =>
comment.id === id
? { ...comment, deleted_at: is_locked ? new Date().toISOString() : undefined }
: comment
)
)
);
const data: Unwrap<typeof apiLockComment> = yield call(apiLockComment, { const data: Unwrap<typeof apiLockComment> = yield call(apiLockComment, {
current: current.id, current: nodeId,
id, id,
is_locked, is_locked,
}); });
@ -357,26 +230,15 @@ function* onLockCommentSaga({ id, is_locked }: ReturnType<typeof nodeLockComment
) )
) )
); );
} catch { } catch (e) {
yield put( showErrorToast(e);
nodeSetComments(
comments.map(comment =>
comment.id === id ? { ...comment, deleted_at: current.deleted_at } : comment
)
)
);
} }
} }
export default function* nodeSaga() { export default function* nodeSaga() {
yield takeLatest(NODE_ACTIONS.SUBMIT_LOCAL, onNodeSubmitLocal);
yield takeLatest(NODE_ACTIONS.GOTO_NODE, onNodeGoto); yield takeLatest(NODE_ACTIONS.GOTO_NODE, onNodeGoto);
yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad); yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad);
yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment); yield takeLatest(NODE_ACTIONS.POST_LOCAL_COMMENT, onPostComment);
yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags);
yield takeLatest(NODE_ACTIONS.DELETE_TAG, onDeleteTag);
yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga);
yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga);
yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga); yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga);
yield takeLatest(NODE_ACTIONS.STAR, onStarSaga); yield takeLatest(NODE_ACTIONS.STAR, onStarSaga);
yield takeLatest(NODE_ACTIONS.LOCK, onLockSaga); yield takeLatest(NODE_ACTIONS.LOCK, onLockSaga);

View file

@ -0,0 +1,9 @@
export const showErrorToast = (error: unknown) => {
if (!(error instanceof Error)) {
console.warn('catched strange exception', error);
return;
}
// TODO: show toast or something
console.warn(error.message);
};

View file

@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { INode } from '~/redux/types';
import { apiPostNode } from '~/redux/node/api';
import { selectFlowNodes } from '~/redux/flow/selectors';
import { flowSetNodes } from '~/redux/flow/actions';
import { selectLabListNodes } from '~/redux/lab/selectors';
import { labSetList } from '~/redux/lab/actions';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { useDispatch } from 'react-redux';
export const useCreateNode = () => {
const dispatch = useDispatch();
const flowNodes = useShallowSelect(selectFlowNodes);
const labNodes = useShallowSelect(selectLabListNodes);
return useCallback(
async (node: INode) => {
const result = await apiPostNode({ node });
// TODO: use another store here someday
if (node.is_promoted) {
const updatedNodes = [result.node, ...flowNodes];
dispatch(flowSetNodes(updatedNodes));
} else {
const updatedNodes = [
{ node: result.node, comment_count: 0, last_seen: node.created_at },
...labNodes,
];
dispatch(labSetList({ nodes: updatedNodes }));
}
},
[flowNodes, labNodes, dispatch]
);
};

View file

@ -1,19 +1,29 @@
import { INode } from '~/redux/types';
import useSWR from 'swr'; import useSWR from 'swr';
import { AxiosResponse } from 'axios';
import { ApiGetNodeResponse } from '~/redux/node/types'; import { ApiGetNodeResponse } from '~/redux/node/types';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import { api } from '~/utils/api'; import { useOnNodeSeen } from '~/utils/hooks/node/useOnNodeSeen';
import { apiGetNode } from '~/redux/node/api';
import { useCallback } from 'react';
import { INode } from '~/redux/types';
export const useGetNode = (id?: INode['id']) => { export const useGetNode = (id: number) => {
const { data, isValidating: isLoading } = useSWR<AxiosResponse<ApiGetNodeResponse>>( const { data, isValidating, mutate } = useSWR<ApiGetNodeResponse>(API.NODE.GET_NODE(id), () =>
API.NODE.GET_NODE(id || ''), apiGetNode({ id })
api.get
); );
if (!id) { const update = useCallback(
return { node: undefined, isLoading: false }; async (node?: Partial<INode>) => {
if (!data?.node) {
await mutate();
return;
} }
return { node: data?.data.node, isLoading }; await mutate({ node: { ...data.node, ...node } }, true);
},
[data, mutate]
);
useOnNodeSeen(data?.node);
return { node: data?.node, isLoading: isValidating && !data, update };
}; };

View file

@ -0,0 +1,43 @@
import { useGetNode } from '~/utils/hooks/data/useGetNode';
import { useCallback } from 'react';
import { INode } from '~/redux/types';
import { apiPostNode } from '~/redux/node/api';
import { selectFlowNodes } from '~/redux/flow/selectors';
import { flowSetNodes } from '~/redux/flow/actions';
import { selectLabListNodes } from '~/redux/lab/selectors';
import { labSetList } from '~/redux/lab/actions';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { useDispatch } from 'react-redux';
export const useUpdateNode = (id: number) => {
const dispatch = useDispatch();
const { update } = useGetNode(id);
const flowNodes = useShallowSelect(selectFlowNodes);
const labNodes = useShallowSelect(selectLabListNodes);
return useCallback(
async (node: INode) => {
const result = await apiPostNode({ node });
if (!update) {
return;
}
await update(result.node);
// TODO: use another store here someday
if (node.is_promoted) {
const updatedNodes = flowNodes.map(item =>
item.id === result.node.id ? result.node : item
);
dispatch(flowSetNodes(updatedNodes));
} else {
const updatedNodes = labNodes.map(item =>
item.node.id === result.node.id ? { ...item, node: result.node } : item
);
dispatch(labSetList({ nodes: updatedNodes }));
}
},
[update, flowNodes, dispatch, labNodes]
);
};

View file

@ -14,7 +14,7 @@ export const useFullNode = (id: string) => {
} = useShallowSelect(selectNode); } = useShallowSelect(selectNode);
useLoadNode(id); useLoadNode(id);
useOnNodeSeen(node); // useOnNodeSeen(node);
return { node, comments, commentsCount, lastSeenCurrent, isLoading, isLoadingComments }; return { node, comments, commentsCount, lastSeenCurrent, isLoading, isLoadingComments };
}; };

View file

@ -1,7 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { nodeGotoNode, nodeSetCurrent } from '~/redux/node/actions'; import { nodeGotoNode } from '~/redux/node/actions';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { EMPTY_NODE } from '~/redux/node/constants';
// useLoadNode loads node on id change // useLoadNode loads node on id change
export const useLoadNode = (id: any) => { export const useLoadNode = (id: any) => {
@ -9,9 +8,5 @@ export const useLoadNode = (id: any) => {
useEffect(() => { useEffect(() => {
dispatch(nodeGotoNode(parseInt(id, 10), undefined)); dispatch(nodeGotoNode(parseInt(id, 10), undefined));
return () => {
dispatch(nodeSetCurrent(EMPTY_NODE));
};
}, [dispatch, id]); }, [dispatch, id]);
}; };

View file

@ -1,12 +1,21 @@
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { nodeEdit, nodeLike, nodeLock, nodeStar } from '~/redux/node/actions'; import { nodeLike, nodeLock, nodeStar } from '~/redux/node/actions';
import { modalShowDialog } from '~/redux/modal/actions';
import { NODE_EDITOR_DIALOGS } from '~/constants/dialogs';
export const useNodeActions = (node: INode) => { export const useNodeActions = (node: INode) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const onEdit = useCallback(() => dispatch(nodeEdit(node.id)), [dispatch, node]); const onEdit = useCallback(() => {
if (!node.type) {
return;
}
dispatch(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
}, [dispatch, node]);
const onLike = useCallback(() => dispatch(nodeLike(node.id)), [dispatch, node]); const onLike = useCallback(() => dispatch(nodeLike(node.id)), [dispatch, node]);
const onStar = useCallback(() => dispatch(nodeStar(node.id)), [dispatch, node]); const onStar = useCallback(() => dispatch(nodeStar(node.id)), [dispatch, node]);
const onLock = useCallback(() => dispatch(nodeLock(node.id, !node.deleted_at)), [dispatch, node]); const onLock = useCallback(() => dispatch(nodeLock(node.id, !node.deleted_at)), [dispatch, node]);

View file

@ -1,16 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { nodeLoadMoreComments, nodeLockComment } from '~/redux/node/actions'; import { nodeLoadMoreComments, nodeLockComment } from '~/redux/node/actions';
import { IComment, INode } from '~/redux/types'; import { IComment } from '~/redux/types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
export const useNodeComments = (id: INode['id']) => { export const useNodeComments = (nodeId: number) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const onLoadMoreComments = useCallback(() => dispatch(nodeLoadMoreComments()), [dispatch]); const onLoadMoreComments = useCallback(() => dispatch(nodeLoadMoreComments()), [dispatch]);
const onDelete = useCallback( const onDelete = useCallback(
(id: IComment['id'], locked: boolean) => dispatch(nodeLockComment(id, locked)), (id: IComment['id'], locked: boolean) => dispatch(nodeLockComment(id, locked, nodeId)),
[dispatch] [dispatch, nodeId]
); );
return { onLoadMoreComments, onDelete }; return { onLoadMoreComments, onDelete };

View file

@ -1,15 +1,14 @@
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
import { FileUploader } from '~/utils/hooks/useFileUploader'; import { FileUploader } from '~/utils/hooks/useFileUploader';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { FormikHelpers, useFormik, useFormikContext } from 'formik'; import { FormikConfig, FormikHelpers, useFormik, useFormikContext } from 'formik';
import { object } from 'yup'; import { object } from 'yup';
import { useDispatch } from 'react-redux';
import { nodeSubmitLocal } from '~/redux/node/actions';
import { keys } from 'ramda'; import { keys } from 'ramda';
import { showErrorToast } from '~/utils/errors/showToast';
const validationSchema = object().shape({}); const validationSchema = object().shape({});
const onSuccess = ({ resetForm, setStatus, setSubmitting, setErrors }: FormikHelpers<INode>) => ( const afterSubmit = ({ resetForm, setStatus, setSubmitting, setErrors }: FormikHelpers<INode>) => (
e?: string, e?: string,
errors?: Record<string, string> errors?: Record<string, string>
) => { ) => {
@ -17,6 +16,7 @@ const onSuccess = ({ resetForm, setStatus, setSubmitting, setErrors }: FormikHel
if (e) { if (e) {
setStatus(e); setStatus(e);
showErrorToast(e);
return; return;
} }
@ -33,17 +33,9 @@ const onSuccess = ({ resetForm, setStatus, setSubmitting, setErrors }: FormikHel
export const useNodeFormFormik = ( export const useNodeFormFormik = (
values: INode, values: INode,
uploader: FileUploader, uploader: FileUploader,
stopEditing: () => void stopEditing: () => void,
sendSaveRequest: (node: INode) => Promise<unknown>
) => { ) => {
const dispatch = useDispatch();
const onSubmit = useCallback(
(values: INode, helpers: FormikHelpers<INode>) => {
helpers.setSubmitting(true);
dispatch(nodeSubmitLocal(values, onSuccess(helpers)));
},
[dispatch]
);
const { current: initialValues } = useRef(values); const { current: initialValues } = useRef(values);
const onReset = useCallback(() => { const onReset = useCallback(() => {
@ -52,7 +44,19 @@ export const useNodeFormFormik = (
if (stopEditing) stopEditing(); if (stopEditing) stopEditing();
}, [uploader, stopEditing]); }, [uploader, stopEditing]);
const formik = useFormik<INode>({ const onSubmit = useCallback<FormikConfig<INode>['onSubmit']>(
async (values, helpers) => {
try {
await sendSaveRequest({ ...values, files: uploader.files });
afterSubmit(helpers)();
} catch (error) {
afterSubmit(helpers)(error?.response?.data?.error, error?.response?.data?.errors);
}
},
[sendSaveRequest, uploader.files]
);
return useFormik<INode>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit, onSubmit,
@ -60,17 +64,6 @@ export const useNodeFormFormik = (
initialStatus: '', initialStatus: '',
validateOnChange: true, validateOnChange: true,
}); });
useEffect(
() => {
formik.setFieldValue('files', uploader.files);
},
// because it breaks files logic
// eslint-disable-next-line
[uploader.files, formik.setFieldValue]
);
return formik;
}; };
export const useNodeFormContext = () => useFormikContext<INode>(); export const useNodeFormContext = () => useFormikContext<INode>();

View file

@ -3,10 +3,6 @@ import { useMemo } from 'react';
import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { UPLOAD_TYPES } from '~/redux/uploads/constants';
export const useNodeImages = (node: INode) => { export const useNodeImages = (node: INode) => {
if (!node?.files) {
return [];
}
return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
node.files, node.files,
]); ]);

View file

@ -4,7 +4,7 @@ import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectUser } from '~/redux/auth/selectors'; import { selectUser } from '~/redux/auth/selectors';
import { INode } from '~/redux/types'; import { INode } from '~/redux/types';
export const useNodePermissions = (node: INode) => { export const useNodePermissions = (node?: INode) => {
const user = useShallowSelect(selectUser); const user = useShallowSelect(selectUser);
const edit = useMemo(() => canEditNode(node, user), [node, user]); const edit = useMemo(() => canEditNode(node, user), [node, user]);
const like = useMemo(() => canLikeNode(node, user), [node, user]); const like = useMemo(() => canLikeNode(node, user), [node, user]);

View file

@ -1,19 +1,24 @@
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { nodeDeleteTag, nodeUpdateTags } from '~/redux/node/actions'; import { ITag } from '~/redux/types';
import { INode, ITag } from '~/redux/types';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { useGetNode } from '~/utils/hooks/data/useGetNode';
import { apiDeleteNodeTag, apiPostNodeTags } from '~/redux/node/api';
export const useNodeTags = (id: INode['id']) => { export const useNodeTags = (id: number) => {
const dispatch = useDispatch(); const { update } = useGetNode(id);
const history = useHistory(); const history = useHistory();
const onChange = useCallback( const onChange = useCallback(
(tags: string[]) => { async (tags: string[]) => {
dispatch(nodeUpdateTags(id, tags)); try {
const result = await apiPostNodeTags({ id, tags });
await update({ tags: result.node.tags });
} catch (error) {
console.warn(error);
}
}, },
[dispatch, id] [id, update]
); );
const onClick = useCallback( const onClick = useCallback(
@ -28,10 +33,15 @@ export const useNodeTags = (id: INode['id']) => {
); );
const onDelete = useCallback( const onDelete = useCallback(
(tagId: ITag['ID']) => { async (tagId: ITag['ID']) => {
dispatch(nodeDeleteTag(id, tagId)); try {
const result = await apiDeleteNodeTag({ id, tagId });
await update({ tags: result.tags });
} catch (e) {
console.warn(e);
}
}, },
[dispatch, id] [id, update]
); );
return { onDelete, onChange, onClick }; return { onDelete, onChange, onClick };

View file

@ -2,15 +2,22 @@ import { INode } from '~/redux/types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { labSeenNode } from '~/redux/lab/actions'; import { labSeenNode } from '~/redux/lab/actions';
import { flowSeenNode } from '~/redux/flow/actions'; import { flowSeenNode } from '~/redux/flow/actions';
import { useEffect } from 'react';
// useOnNodeSeen updates node seen status across all needed places // useOnNodeSeen updates node seen status across all needed places
export const useOnNodeSeen = (node: INode) => { export const useOnNodeSeen = (node?: INode) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
useEffect(() => {
if (!node?.id) {
return;
}
// Remove node from updated // Remove node from updated
if (node.is_promoted) { if (node.is_promoted) {
dispatch(flowSeenNode(node.id)); dispatch(flowSeenNode(node.id));
} else { } else {
dispatch(labSeenNode(node.id)); dispatch(labSeenNode(node.id));
} }
}, [dispatch, node]);
}; };

View file

@ -4,18 +4,17 @@ import { IUser } from '~/redux/auth/types';
import { path } from 'ramda'; import { path } from 'ramda';
import { NODE_TYPES } from '~/redux/node/constants'; import { NODE_TYPES } from '~/redux/node/constants';
export const canEditNode = (node: Partial<INode>, user: Partial<IUser>): boolean => export const canEditNode = (node?: Partial<INode>, user?: Partial<IUser>): boolean =>
path(['role'], user) === USER_ROLES.ADMIN || path(['role'], user) === USER_ROLES.ADMIN ||
(path(['user', 'id'], node) && path(['user', 'id'], node) === path(['id'], user)); (path(['user', 'id'], node) && path(['user', 'id'], node) === path(['id'], user));
export const canEditComment = (comment: Partial<ICommentGroup>, user: Partial<IUser>): boolean => export const canEditComment = (comment?: Partial<ICommentGroup>, user?: Partial<IUser>): boolean =>
path(['role'], user) === USER_ROLES.ADMIN || path(['role'], user) === USER_ROLES.ADMIN || path(['user', 'id'], comment) === path(['id'], user);
(path(['user', 'id'], comment) && path(['user', 'id'], comment) === path(['id'], user));
export const canLikeNode = (node: Partial<INode>, user: Partial<IUser>): boolean => export const canLikeNode = (node?: Partial<INode>, user?: Partial<IUser>): boolean =>
path(['role'], user) && path(['role'], user) !== USER_ROLES.GUEST; path(['role'], user) !== USER_ROLES.GUEST;
export const canStarNode = (node: Partial<INode>, user: Partial<IUser>): boolean => export const canStarNode = (node?: Partial<INode>, user?: Partial<IUser>): boolean =>
(node.type === NODE_TYPES.IMAGE || node.is_promoted === false) && path(['type'], node) === NODE_TYPES.IMAGE &&
path(['role'], user) && path(['is_promoted'], node) === false &&
path(['role'], user) === USER_ROLES.ADMIN; path(['role'], user) === USER_ROLES.ADMIN;