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

added tag deletion interface

This commit is contained in:
Fedor Katurov 2021-09-28 15:39:29 +07:00
parent 0a75feef8d
commit 04a7b28a53
16 changed files with 195 additions and 39 deletions

View file

@ -27,7 +27,7 @@ const LabTags: FC<IProps> = ({ tags, isLoading }) => {
return ( return (
<div className={styles.tags}> <div className={styles.tags}>
{tags.slice(0, 10).map(tag => ( {tags.slice(0, 10).map(tag => (
<Tag tag={tag} key={tag.id} /> <Tag tag={tag} key={tag.ID} />
))} ))}
</div> </div>
); );

View file

@ -16,6 +16,7 @@ import { NodeAuthorBlock } from '~/components/node/NodeAuthorBlock';
interface IProps { interface IProps {
node: INode; node: INode;
canEdit: boolean;
isLoading: boolean; isLoading: boolean;
commentsOrder: 'ASC' | 'DESC'; commentsOrder: 'ASC' | 'DESC';
comments: IComment[]; comments: IComment[];
@ -26,6 +27,7 @@ interface IProps {
const NodeBottomBlock: FC<IProps> = ({ const NodeBottomBlock: FC<IProps> = ({
node, node,
canEdit,
isLoading, isLoading,
isLoadingComments, isLoadingComments,
comments, comments,
@ -66,7 +68,7 @@ const NodeBottomBlock: FC<IProps> = ({
<NodeAuthorBlock node={node} /> <NodeAuthorBlock node={node} />
</div> </div>
<div className={styles.left_item}> <div className={styles.left_item}>
<NodeTagsBlock node={node} isLoading={isLoading} /> <NodeTagsBlock node={node} canEdit={canEdit} isLoading={isLoading} />
</div> </div>
<div className={styles.left_item}> <div className={styles.left_item}>
<NodeRelatedBlock isLoading={isLoading} node={node} related={related} /> <NodeRelatedBlock isLoading={isLoading} node={node} related={related} />

View file

@ -3,16 +3,27 @@ import { ITag } from '~/redux/types';
import { Tags } from '~/components/tags/Tags'; import { Tags } from '~/components/tags/Tags';
interface IProps { interface IProps {
is_deletable?: boolean;
is_editable?: boolean; is_editable?: boolean;
tags: ITag[]; tags: ITag[];
onChange?: (tags: string[]) => void; onChange?: (tags: string[]) => void;
onTagClick?: (tag: Partial<ITag>) => void; onTagClick?: (tag: Partial<ITag>) => void;
onTagDelete?: (id: ITag['ID']) => void;
} }
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange, onTagClick }) => { const NodeTags: FC<IProps> = memo(
return ( ({ is_editable, is_deletable, tags, onChange, onTagClick, onTagDelete }) => {
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} onTagClick={onTagClick} /> return (
); <Tags
}); tags={tags}
is_editable={is_editable}
onTagsChange={onChange}
onTagClick={onTagClick}
onTagDelete={onTagDelete}
is_deletable={is_deletable}
/>
);
}
);
export { NodeTags }; export { NodeTags };

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import { INode, ITag } from '~/redux/types'; import { INode, ITag } from '~/redux/types';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { nodeUpdateTags } from '~/redux/node/actions'; import { nodeDeleteTag, nodeUpdateTags } from '~/redux/node/actions';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { NodeTags } from '~/components/node/NodeTags'; import { NodeTags } from '~/components/node/NodeTags';
@ -9,10 +9,11 @@ import { useUser } from '~/utils/hooks/user/userUser';
interface IProps { interface IProps {
node: INode; node: INode;
canEdit: boolean;
isLoading: boolean; isLoading: boolean;
} }
const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => { const NodeTagsBlock: FC<IProps> = ({ node, canEdit, isLoading }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const history = useHistory(); const history = useHistory();
const { is_user } = useUser(); const { is_user } = useUser();
@ -35,6 +36,13 @@ const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
[history, node] [history, node]
); );
const onTagDelete = useCallback(
(tagId: ITag['ID']) => {
dispatch(nodeDeleteTag(node.id, tagId));
},
[dispatch, node.id]
);
if (isLoading) { if (isLoading) {
return null; return null;
} }
@ -42,9 +50,11 @@ const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
return ( return (
<NodeTags <NodeTags
is_editable={is_user} is_editable={is_user}
is_deletable={canEdit}
tags={node.tags} tags={node.tags}
onChange={onTagsChange} onChange={onTagsChange}
onTagClick={onTagClick} onTagClick={onTagClick}
onTagDelete={onTagDelete}
/> />
); );
}; };

View file

@ -12,26 +12,46 @@ interface IProps {
tag: Partial<ITag>; tag: Partial<ITag>;
size?: 'normal' | 'big'; size?: 'normal' | 'big';
is_deletable?: boolean;
is_hoverable?: boolean; is_hoverable?: boolean;
is_editing?: boolean; is_editing?: boolean;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
onClick?: (tag: Partial<ITag>) => void; onClick?: (tag: Partial<ITag>) => void;
onDelete?: (id: ITag['ID']) => void;
} }
const Tag: FC<IProps> = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => { const Tag: FC<IProps> = ({
tag,
is_deletable,
is_hoverable,
is_editing,
size = 'normal',
onClick,
onDelete,
}) => {
const onClickHandler = useCallback(() => { const onClickHandler = useCallback(() => {
if (!onClick) return; if (!onClick) return;
onClick(tag); onClick(tag);
}, [tag, onClick]); }, [tag, onClick]);
const onDeleteHandler = useCallback(() => {
if (!onDelete) {
return;
}
onDelete(tag.ID!);
}, [onDelete, tag]);
return ( return (
<TagWrapper <TagWrapper
feature={getTagFeature(tag)} feature={getTagFeature(tag)}
size={size} size={size}
is_deletable={is_deletable}
is_hoverable={is_hoverable} is_hoverable={is_hoverable}
is_editing={is_editing} is_editing={is_editing}
onClick={onClick && onClickHandler} onClick={onClick && onClickHandler}
onDelete={onDeleteHandler}
title={tag.title} title={tag.title}
/> />
); );

View file

@ -1,14 +1,18 @@
import React, { FC } from 'react'; import React, { FC, useCallback, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { Manager, Popper, Reference } from 'react-popper';
import { Icon } from '~/components/input/Icon';
interface IProps { interface IProps {
feature?: string; feature?: string;
size?: string; size?: string;
is_deletable?: boolean;
is_hoverable?: boolean; is_hoverable?: boolean;
is_editing?: boolean; is_editing?: boolean;
has_input?: boolean; has_input?: boolean;
onClick?: () => void; onClick?: () => void;
onDelete?: () => void;
title?: string; title?: string;
} }
@ -16,25 +20,39 @@ const TagWrapper: FC<IProps> = ({
children, children,
feature, feature,
size, size,
is_deletable,
is_hoverable, is_hoverable,
is_editing, is_editing,
has_input, has_input,
onClick, onClick,
onDelete,
title = '', title = '',
}) => ( }) => {
<div const deletable = is_deletable && !is_editing && !has_input;
className={classNames(styles.tag, feature, size, {
is_hoverable, return (
is_editing, <div
input: has_input, className={classNames(styles.tag, feature, size, {
clickable: onClick, is_hoverable,
})} is_editing,
onClick={onClick} deletable,
> input: has_input,
<div className={styles.hole} /> clickable: onClick,
<div className={styles.title}>{title}</div> })}
{children} >
</div> <div className={styles.content} onClick={onClick}>
); <div className={styles.hole} />
<div className={styles.title}>{title}</div>
{children}
</div>
{deletable && (
<button type="button" className={styles.delete} onClick={onDelete}>
<Icon icon="close" size={20} />
</button>
)}
</div>
);
};
export { TagWrapper }; export { TagWrapper };

View file

@ -5,21 +5,21 @@ $big: 1.2;
.tag { .tag {
@include outer_shadow(); @include outer_shadow();
overflow: hidden;
cursor: default; cursor: default;
height: $tag_height; height: $tag_height;
background: $tag_bg; background: $tag_bg;
display: flex;
flex-direction: row;
align-items: center;
justify-content: stretch;
border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2); border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2);
font: $font_14_semibold; font: $font_14_semibold;
align-self: flex-start; align-self: flex-start;
padding: 0 8px 0 0; padding: 0 8px 0 0;
//margin: 0 $gap $gap 0;
position: relative; position: relative;
z-index: 12; z-index: 12;
&:hover {
z-index: 40;
}
&:global(.big) { &:global(.big) {
height: $tag_height * $big; height: $tag_height * $big;
font: $font_16_semibold; font: $font_16_semibold;
@ -98,6 +98,19 @@ $big: 1.2;
} }
} }
.content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: stretch;
width: 100%;
transition: transform 250ms;
:hover:global(.deletable) > & {
transform: translate(-32px, 0);
}
}
.hole { .hole {
width: $tag_height; width: $tag_height;
height: $tag_height; height: $tag_height;
@ -123,3 +136,28 @@ $big: 1.2;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
button.delete {
@include inner_shadow;
width: 32px;
height: 100%;
z-index: 24;
background: $red;
border: none;
padding: 0;
margin: 0 0 0 -5px;
border-radius: 0;
position: absolute;
right: -32px;
top: 0;
transition: transform 250ms;
transform: translate(0, 0);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
:hover > & {
transform: translate(-32px, 0);
}
}

View file

@ -8,12 +8,22 @@ import { separateTags } from '~/utils/tag';
type IProps = HTMLAttributes<HTMLDivElement> & { type IProps = HTMLAttributes<HTMLDivElement> & {
tags: Partial<ITag>[]; tags: Partial<ITag>[];
is_deletable?: boolean;
is_editable?: boolean; is_editable?: boolean;
onTagsChange?: (tags: string[]) => void; onTagsChange?: (tags: string[]) => void;
onTagClick?: (tag: Partial<ITag>) => void; onTagClick?: (tag: Partial<ITag>) => void;
onTagDelete?: (id: ITag['ID']) => void;
}; };
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => { export const Tags: FC<IProps> = ({
tags,
is_deletable,
is_editable,
onTagsChange,
onTagClick,
onTagDelete,
...props
}) => {
const [data, setData] = useState<string[]>([]); const [data, setData] = useState<string[]>([]);
const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]); const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]);
@ -56,11 +66,23 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
return ( return (
<TagField {...props}> <TagField {...props}>
{catTags.map(tag => ( {catTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} /> <Tag
key={tag.title}
tag={tag}
onClick={onTagClick}
is_deletable={is_deletable}
onDelete={onTagDelete}
/>
))} ))}
{ordinaryTags.map(tag => ( {ordinaryTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} /> <Tag
key={tag.title}
tag={tag}
onClick={onTagClick}
is_deletable={is_deletable}
onDelete={onTagDelete}
/>
))} ))}
{data.map(title => ( {data.map(title => (

View file

@ -1,4 +1,4 @@
import { IComment, INode } from '~/redux/types'; import { IComment, INode, ITag } from '~/redux/types';
import { ISocialProvider } from '~/redux/auth/types'; import { ISocialProvider } from '~/redux/auth/types';
export const API = { export const API = {
@ -30,6 +30,7 @@ export const API = {
COMMENT: (id: INode['id']) => `/node/${id}/comment`, COMMENT: (id: INode['id']) => `/node/${id}/comment`,
RELATED: (id: INode['id']) => `/node/${id}/related`, RELATED: (id: INode['id']) => `/node/${id}/related`,
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`, UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
DELETE_TAG: (id: INode['id'], tagId: ITag['ID']) => `/node/${id}/tags/${tagId}`,
POST_LIKE: (id: INode['id']) => `/node/${id}/like`, POST_LIKE: (id: INode['id']) => `/node/${id}/like`,
POST_HEROIC: (id: INode['id']) => `/node/${id}/heroic`, POST_HEROIC: (id: INode['id']) => `/node/${id}/heroic`,
POST_LOCK: (id: INode['id']) => `/node/${id}/lock`, POST_LOCK: (id: INode['id']) => `/node/${id}/lock`,

View file

@ -18,6 +18,8 @@ import { useLoadNode } from '~/utils/hooks/node/useLoadNode';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { EditorEditDialog } from '~/containers/dialogs/EditorEditDialog'; import { EditorEditDialog } from '~/containers/dialogs/EditorEditDialog';
import { useOnNodeSeen } from '~/utils/hooks/node/useOnNodeSeen'; import { useOnNodeSeen } from '~/utils/hooks/node/useOnNodeSeen';
import { canEditNode } from '~/utils/node';
import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions';
type IProps = RouteComponentProps<{ id: string }> & {}; type IProps = RouteComponentProps<{ id: string }> & {};
@ -42,6 +44,7 @@ const NodeLayout: FC<IProps> = memo(
useOnNodeSeen(current); useOnNodeSeen(current);
const { head, block } = useNodeBlocks(current, is_loading); const { head, block } = useNodeBlocks(current, is_loading);
const [canEdit] = useNodePermissions(current);
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
@ -54,13 +57,14 @@ const NodeLayout: FC<IProps> = memo(
<NodePanel node={current} isLoading={is_loading} /> <NodePanel node={current} isLoading={is_loading} />
<NodeBottomBlock <NodeBottomBlock
canEdit={canEdit}
node={current} node={current}
isLoadingComments={is_loading_comments}
comments={comments} comments={comments}
isLoading={is_loading}
commentsCount={comment_count} commentsCount={comment_count}
commentsOrder="DESC" commentsOrder="DESC"
related={related} related={related}
isLoadingComments={is_loading_comments}
isLoading={is_loading}
/> />
<Footer /> <Footer />

View file

@ -86,6 +86,12 @@ export const nodeUpdateTags = (id: INode['id'], tags: string[]) => ({
tags, tags,
}); });
export const nodeDeleteTag = (id: INode['id'], tagId: ITag['ID']) => ({
type: NODE_ACTIONS.DELETE_TAG,
id: id!,
tagId,
});
export const nodeSetTags = (tags: ITag[]) => ({ export const nodeSetTags = (tags: ITag[]) => ({
type: NODE_ACTIONS.SET_TAGS, type: NODE_ACTIONS.SET_TAGS,
tags, tags,

View file

@ -3,6 +3,8 @@ import { IComment, INode } from '../types';
import { API } from '~/constants/api'; import { API } from '~/constants/api';
import { COMMENTS_DISPLAY } from './constants'; import { COMMENTS_DISPLAY } from './constants';
import { import {
ApiDeleteNodeTagsRequest,
ApiDeleteNodeTagsResult,
ApiGetNodeRelatedRequest, ApiGetNodeRelatedRequest,
ApiGetNodeRelatedResult, ApiGetNodeRelatedResult,
ApiGetNodeRequest, ApiGetNodeRequest,
@ -101,6 +103,9 @@ export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) =>
.post<ApiPostNodeTagsResult>(API.NODE.UPDATE_TAGS(id), { tags }) .post<ApiPostNodeTagsResult>(API.NODE.UPDATE_TAGS(id), { tags })
.then(cleanResult); .then(cleanResult);
export const apiDeleteNodeTag = ({ id, tagId }: ApiDeleteNodeTagsRequest) =>
api.delete<ApiDeleteNodeTagsResult>(API.NODE.DELETE_TAG(id, tagId)).then(cleanResult);
export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) => export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) =>
api.post<ApiPostNodeLikeResult>(API.NODE.POST_LIKE(id)).then(cleanResult); api.post<ApiPostNodeLikeResult>(API.NODE.POST_LIKE(id)).then(cleanResult);

View file

@ -52,6 +52,7 @@ export const NODE_ACTIONS = {
SET_RELATED: `${prefix}SET_RELATED`, SET_RELATED: `${prefix}SET_RELATED`,
UPDATE_TAGS: `${prefix}UPDATE_TAGS`, UPDATE_TAGS: `${prefix}UPDATE_TAGS`,
DELETE_TAG: `${prefix}DELETE_TAG`,
SET_TAGS: `${prefix}SET_TAGS`, SET_TAGS: `${prefix}SET_TAGS`,
SET_COVER_IMAGE: `${prefix}SET_COVER_IMAGE`, SET_COVER_IMAGE: `${prefix}SET_COVER_IMAGE`,
}; };

View file

@ -10,6 +10,7 @@ import {
} from './constants'; } from './constants';
import { import {
nodeCreate, nodeCreate,
nodeDeleteTag,
nodeEdit, nodeEdit,
nodeGotoNode, nodeGotoNode,
nodeLike, nodeLike,
@ -30,6 +31,7 @@ import {
nodeUpdateTags, nodeUpdateTags,
} from './actions'; } from './actions';
import { import {
apiDeleteNodeTag,
apiGetNode, apiGetNode,
apiGetNodeComments, apiGetNodeComments,
apiGetNodeRelated, apiGetNodeRelated,
@ -246,6 +248,13 @@ function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
} catch {} } 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>) { function* onCreateSaga({ node_type: type, isLab }: ReturnType<typeof nodeCreate>) {
if (!type || !has(type, NODE_EDITOR_DIALOGS)) return; if (!type || !has(type, NODE_EDITOR_DIALOGS)) return;
@ -385,6 +394,7 @@ export default function* nodeSaga() {
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_COMMENT, onPostComment);
yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags); yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags);
yield takeLatest(NODE_ACTIONS.DELETE_TAG, onDeleteTag);
yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga); yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga);
yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga); yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga);
yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga); yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga);

View file

@ -1,4 +1,4 @@
import { IComment, INode } from '~/redux/types'; import { IComment, INode, ITag } from '~/redux/types';
import { INodeState } from '~/redux/node/reducer'; import { INodeState } from '~/redux/node/reducer';
export interface IEditorComponentProps {} export interface IEditorComponentProps {}
@ -56,6 +56,14 @@ export type ApiPostNodeTagsResult = {
node: INode; node: INode;
}; };
export type ApiDeleteNodeTagsRequest = {
id: INode['id'];
tagId: ITag['ID'];
};
export type ApiDeleteNodeTagsResult = {
tags: ITag[];
};
export type ApiPostNodeLikeRequest = { id: INode['id'] }; export type ApiPostNodeLikeRequest = { id: INode['id'] };
export type ApiPostNodeLikeResult = { is_liked: boolean }; export type ApiPostNodeLikeResult = { is_liked: boolean };

View file

@ -6,7 +6,7 @@ import { CallEffect } from 'redux-saga/effects';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
export interface ITag { export interface ITag {
id: number; ID: number;
title: string; title: string;
data: Record<string, string>; data: Record<string, string>;