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

Merge pull request #14 from muerwre/13-tag-autocomplete

13 tag autocomplete
This commit is contained in:
muerwre 2020-10-31 20:50:14 +07:00 committed by GitHub
commit b43c47a044
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 656 additions and 216 deletions

View file

@ -3,4 +3,8 @@
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
&> * {
margin: 0 $gap $gap 0;
}
}

View file

@ -1,6 +1,6 @@
import React, { FC, memo } from 'react';
import { Tags } from '../Tags';
import { ITag } from '~/redux/types';
import { Tags } from '~/components/tags/Tags';
interface IProps {
is_editable?: boolean;

View file

@ -1,17 +0,0 @@
import React, { FC, memo } from "react";
import { Tags } from "../Tags";
import { ITag } from "~/redux/types";
interface IProps {
is_editable?: boolean;
tags: ITag[];
onChange?: (tags: string[]) => void;
}
const NodeTagsPlaceholder: FC<IProps> = memo(
({ is_editable, tags, onChange }) => (
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
)
);
export { NodeTagsPlaceholder };

View file

@ -0,0 +1,15 @@
import React, { FC, memo } from 'react';
import { ITag } from '~/redux/types';
import { Tags } from '~/components/tags/Tags';
interface IProps {
is_editable?: boolean;
tags: ITag[];
onChange?: (tags: string[]) => void;
}
const NodeTagsPlaceholder: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
));
export { NodeTagsPlaceholder };

View file

@ -1,69 +0,0 @@
import React, { ChangeEventHandler, FC, FocusEventHandler, KeyboardEventHandler, useCallback, } from 'react';
import * as styles from './styles.scss';
import { ITag } from '~/redux/types';
import classNames = require('classnames');
const getTagFeature = (tag: Partial<ITag>) => {
if (tag.title.substr(0, 1) === '/') return 'green';
return '';
};
interface IProps {
tag: Partial<ITag>;
size?: 'normal' | 'big';
is_hoverable?: boolean;
is_editing?: boolean;
onInput?: ChangeEventHandler<HTMLInputElement>;
onKeyUp?: KeyboardEventHandler;
onBlur?: FocusEventHandler<HTMLInputElement>;
onClick?: (tag: Partial<ITag>) => void;
}
const Tag: FC<IProps> = ({
tag,
is_hoverable,
is_editing,
size = 'normal',
onInput,
onKeyUp,
onBlur,
onClick,
}) => {
const onClickHandler = useCallback(() => {
if (!onClick) return;
onClick(tag);
}, [tag, onClick]);
return (
<div
className={classNames(styles.tag, getTagFeature(tag), size, {
is_hoverable,
is_editing,
input: !!onInput,
clickable: !!onClick,
})}
onClick={onClickHandler}
>
<div className={styles.hole} />
<div className={styles.title}>{tag.title}</div>
{onInput && (
<input
type="text"
value={tag.title}
size={1}
placeholder="Добавить"
maxLength={24}
onChange={onInput}
onKeyUp={onKeyUp}
onBlur={onBlur}
/>
)}
</div>
);
};
export { Tag };

View file

@ -1,118 +0,0 @@
import React, {
ChangeEvent,
FC,
HTMLAttributes,
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { TagField } from '~/components/containers/TagField';
import { ITag } from '~/redux/types';
import { Tag } from '~/components/node/Tag';
import uniq from 'ramda/es/uniq';
type IProps = HTMLAttributes<HTMLDivElement> & {
tags: Partial<ITag>[];
is_editable?: boolean;
onTagsChange?: (tags: string[]) => void;
onTagClick?: (tag: Partial<ITag>) => void;
};
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
const [input, setInput] = useState('');
const [data, setData] = useState([]);
const timer = useRef(null);
const [catTags, ordinaryTags] = useMemo(
() =>
(tags || []).reduce(
(obj, tag) =>
tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]],
[[], []]
),
[tags]
);
const onInput = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
clearTimeout(timer.current);
setInput(value);
},
[setInput, timer]
);
const onKeyUp = useCallback(
({ key }: KeyboardEvent) => {
if (key === 'Backspace' && input === '' && data.length) {
setData(data.slice(0, data.length - 1));
setInput(data[data.length - 1].title);
}
if (key === 'Enter' || key === ',' || key === 'Comma') {
setData(
uniq([
...data,
...input
.split(',')
.map((title: string) =>
title
.trim()
.substr(0, 32)
.toLowerCase()
)
.filter(el => el.length > 0)
.filter(el => !tags.some(tag => tag.title.trim() === el.trim()))
.map(title => ({
title,
})),
])
);
setInput('');
}
},
[input, setInput, data, setData]
);
const onSubmit = useCallback(() => {
const title = input && input.trim();
const items = (title ? [...data, { title }] : data)
.filter(tag => tag.title.length > 0)
.map(tag => ({
...tag,
title: tag.title.toLowerCase(),
}));
if (!items.length) return;
setData(items);
setInput('');
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
}, [tags, data, onTagsChange, input, setInput]);
useEffect(() => {
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
}, [tags]);
return (
<TagField {...props}>
{catTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
))}
{ordinaryTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
))}
{data.map(tag => (
<Tag key={tag.title} tag={tag} is_editing />
))}
{is_editable && (
<Tag tag={{ title: input }} onInput={onInput} onKeyUp={onKeyUp} onBlur={onSubmit} />
)}
</TagField>
);
};

View file

@ -0,0 +1,40 @@
import React, { FC, FocusEventHandler, useCallback } from 'react';
import { ITag } from '~/redux/types';
import { TagWrapper } from '~/components/tags/TagWrapper';
const getTagFeature = (tag: Partial<ITag>) => {
if (tag.title.substr(0, 1) === '/') return 'green';
return '';
};
interface IProps {
tag: Partial<ITag>;
size?: 'normal' | 'big';
is_hoverable?: boolean;
is_editing?: boolean;
onBlur?: FocusEventHandler<HTMLInputElement>;
onClick?: (tag: Partial<ITag>) => void;
}
const Tag: FC<IProps> = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => {
const onClickHandler = useCallback(() => {
if (!onClick) return;
onClick(tag);
}, [tag, onClick]);
return (
<TagWrapper
feature={getTagFeature(tag)}
size={size}
is_hoverable={is_hoverable}
is_editing={is_editing}
onClick={onClick && onClickHandler}
title={tag.title}
/>
);
};
export { Tag };

View file

View file

@ -0,0 +1,136 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
import { connect } from 'react-redux';
import * as TAG_ACTIONS from '~/redux/tag/actions';
import { selectTagAutocomplete } from '~/redux/tag/selectors';
import { separateTagOptions } from '~/utils/tag';
import { TagAutocompleteRow } from '~/components/tags/TagAutocompleteRow';
const mapStateToProps = selectTagAutocomplete;
const mapDispatchToProps = {
tagSetAutocomplete: TAG_ACTIONS.tagSetAutocomplete,
tagLoadAutocomplete: TAG_ACTIONS.tagLoadAutocomplete,
};
type Props = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
exclude: string[];
input: HTMLInputElement;
onSelect: (val: string) => void;
search: string;
};
const TagAutocompleteUnconnected: FC<Props> = ({
exclude,
input,
onSelect,
search,
tagSetAutocomplete,
tagLoadAutocomplete,
options,
}) => {
const [top, setTop] = useState(false);
const [left, setLeft] = useState(false);
const [selected, setSelected] = useState(-1);
const [categories, tags] = useMemo(
() =>
separateTagOptions(options.filter(option => option !== search && !exclude.includes(option))),
[options, search, exclude]
);
const scroll = useRef<HTMLDivElement>(null);
const onKeyDown = useCallback(
event => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setSelected(selected < options.length - 1 ? selected + 1 : -1);
return;
case 'ArrowUp':
event.preventDefault();
setSelected(selected > -1 ? selected - 1 : options.length - 1);
return;
case 'Enter':
event.preventDefault();
onSelect(selected >= 0 ? [...categories, ...tags][selected] : search);
}
},
[setSelected, selected, categories, tags, onSelect, search]
);
const onScroll = useCallback(() => {
if (!scroll.current) return;
const { y, height, x, width } = scroll.current.getBoundingClientRect();
const newTop = window.innerHeight - y - height <= (top ? 120 : 10);
if (top !== newTop) setTop(newTop);
const newLeft = x <= 0;
if (newLeft !== left) setLeft(newLeft);
}, [scroll.current, top, left]);
useEffect(() => {
input.addEventListener('keydown', onKeyDown, false);
return () => input.removeEventListener('keydown', onKeyDown);
}, [input, onKeyDown]);
useEffect(() => {
setSelected(-1);
tagLoadAutocomplete(search, exclude);
}, [search]);
useEffect(() => {
tagSetAutocomplete({ options: [] });
return () => tagSetAutocomplete({ options: [] });
}, [tagSetAutocomplete]);
useEffect(() => {
if (!scroll.current || !scroll.current?.children[selected + 1]) return;
const el = scroll.current?.children[selected + 1] as HTMLDivElement;
const { scrollTop, clientHeight } = scroll.current;
const { offsetTop } = el;
if (clientHeight - scrollTop + el.clientHeight < offsetTop || offsetTop < scrollTop) {
scroll.current.scrollTo(0, el.offsetTop - el.clientHeight);
}
}, [selected, scroll.current]);
useEffect(() => {
onScroll();
window.addEventListener('resize', onScroll);
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('resize', onScroll);
window.removeEventListener('scroll', onScroll);
};
}, []);
return (
<div className={classNames(styles.window, { [styles.top]: top, [styles.left]: left })}>
<div className={styles.scroll} ref={scroll}>
<TagAutocompleteRow selected={selected === -1} title={search} type="enter" />
{categories.map((item, i) => (
<TagAutocompleteRow selected={selected === i} title={item} type="right" key={item} />
))}
{tags.map((item, i) => (
<TagAutocompleteRow
selected={selected === categories.length + i}
title={item}
type="tag"
key={item}
/>
))}
</div>
</div>
);
};
const TagAutocomplete = connect(mapStateToProps, mapDispatchToProps)(TagAutocompleteUnconnected);
export { TagAutocomplete };

View file

@ -0,0 +1,37 @@
@keyframes appear {
0% { opacity: 0 }
100% { opacity: 100 }
}
$row_height: 24px;
.window {
box-shadow: transparentize(white, 0.8) 0 0 0 1px;
position: absolute;
top: -2px;
right: -2px;
width: calc(100vw - 15px);
max-width: 300px;
background: darken($content_bg, 1%);
z-index: 10;
border-radius: 3px;
padding: $tag_height + 2px 0 0;
animation: appear 0.25s forwards;
&.top {
bottom: -2px;
top: auto;
padding: 0 0 $tag_height;
}
&.left {
right: auto;
left: -2px;
}
}
.scroll {
overflow: auto;
max-height: 7 * $row_height + $tag_height;
padding: 0 0 $gap / 2;
}

View file

@ -0,0 +1,19 @@
import React, { FC } from 'react';
import styles from './styles.module.scss';
import classNames from 'classnames';
import { Icon } from '~/components/input/Icon';
interface IProps {
selected: boolean;
title: string;
type: string;
}
const TagAutocompleteRow: FC<IProps> = ({ selected, type, title }) => (
<div className={classNames(styles.row, styles[type], { [styles.selected]: selected })}>
<Icon icon={type} size={16} />
<span>{title}</span>
</div>
);
export { TagAutocompleteRow };

View file

@ -0,0 +1,33 @@
$row_height: 24px;
.row {
height: $row_height;
padding: 0 $gap;
display: flex;
align-items: center;
justify-content: flex-start;
font: $font_16_semibold;
border-top: lighten($content_bg, 2%) solid 1px;
opacity: 0.5;
cursor: pointer;
transition: all 0.1s;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&:hover, &.selected {
opacity: 1;
background: lighten($content_bg, 4%);
}
&.right {
color: $wisegreen;
opacity: 0.7;
}
svg {
margin-right: 5px;
fill: currentColor;
flex: 0 0 16px;
}
}

View file

@ -0,0 +1,140 @@
import React, { ChangeEvent, FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import { TagAutocomplete } from '~/components/tags/TagAutocomplete';
import { TagWrapper } from '~/components/tags/TagWrapper';
import styles from './styles.module.scss';
const placeholder = 'Добавить';
const prepareInput = (input: string): string[] => {
return input
.split(',')
.map((title: string) =>
title
.trim()
.substr(0, 32)
.toLowerCase()
)
.filter(el => el.length > 0);
};
interface IProps {
onAppend: (tags: string[]) => void;
onClearTag: () => string | undefined;
onSubmit: (last: string[]) => void;
exclude: string[];
}
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const [focused, setFocused] = useState(false);
const [input, setInput] = useState('');
const ref = useRef<HTMLInputElement>(null);
const wrapper = useRef<HTMLDivElement>(null);
const onInput = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (!value.trim()) {
setInput(value || '');
return;
}
const items = prepareInput(value);
if (items.length > 1) {
onAppend(items.slice(0, items.length - 1));
}
setInput(items[items.length - 1] || '');
},
[setInput]
);
const onKeyUp = useCallback(
({ key }: KeyboardEvent) => {
if (key === 'Escape' && ref.current) {
setInput('');
ref.current.blur();
return;
}
if (key === 'Backspace' && input === '') {
setInput(onClearTag() || '');
return;
}
if (key === ',' || key === 'Comma') {
const created = prepareInput(input);
if (created.length) {
onAppend(created);
}
setInput('');
}
},
[input, setInput, onClearTag, onAppend, onSubmit, ref.current, wrapper.current]
);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(
event => {
if (wrapper.current.contains(event.target)) {
ref.current.focus();
return;
}
setFocused(false);
if (input.trim()) {
setInput('');
}
onSubmit([]);
},
[input, onAppend, setInput, onSubmit]
);
const onAutocompleteSelect = useCallback(
(val: string) => {
onAppend([val]);
setInput('');
},
[onAppend, setInput]
);
const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]);
useEffect(() => {
document.addEventListener('click', onBlur);
return () => document.removeEventListener('click', onBlur);
}, [onBlur]);
return (
<div className={styles.wrap} ref={wrapper}>
<TagWrapper title={input || placeholder} has_input={true} feature={feature}>
<input
type="text"
value={input}
size={1}
placeholder={placeholder}
maxLength={24}
onChange={onInput}
onKeyDown={onKeyUp}
onFocus={onFocus}
ref={ref}
/>
</TagWrapper>
{onInput && focused && input?.length > 0 && (
<TagAutocomplete
exclude={exclude}
input={ref.current}
onSelect={onAutocompleteSelect}
search={input}
/>
)}
</div>
);
};
export { TagInput };

View file

@ -0,0 +1,4 @@
.wrap {
position: relative;
z-index: 20;
}

View file

@ -0,0 +1,40 @@
import React, { FC } from 'react';
import classNames from 'classnames';
import styles from './styles.module.scss';
interface IProps {
feature?: string;
size?: string;
is_hoverable?: boolean;
is_editing?: boolean;
has_input?: boolean;
onClick?: () => void;
title?: string;
}
const TagWrapper: FC<IProps> = ({
children,
feature,
size,
is_hoverable,
is_editing,
has_input,
onClick,
title = '',
}) => (
<div
className={classNames(styles.tag, feature, size, {
is_hoverable,
is_editing,
input: has_input,
clickable: onClick,
})}
onClick={onClick}
>
<div className={styles.hole} />
<div className={styles.title}>{title}</div>
{children}
</div>
);
export { TagWrapper };

View file

@ -14,8 +14,9 @@ $big: 1.2;
font: $font_14_semibold;
align-self: flex-start;
padding: 0 8px 0 0;
margin: 0 $gap $gap 0;
//margin: 0 $gap $gap 0;
position: relative;
z-index: 12;
&:global(.big) {
height: $tag_height * $big;
@ -88,6 +89,7 @@ $big: 1.2;
top: 0;
bottom: 0;
width: 100%;
min-width: 100px;
padding-left: $tag_height;
padding-right: 5px;
box-sizing: border-box;
@ -118,3 +120,4 @@ $big: 1.2;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,76 @@
import React, { FC, HTMLAttributes, useCallback, useEffect, useMemo, useState } from 'react';
import { TagField } from '~/components/containers/TagField';
import { ITag } from '~/redux/types';
import uniq from 'ramda/es/uniq';
import { Tag } from '~/components/tags/Tag';
import { TagInput } from '~/components/tags/TagInput';
import { separateTags } from '~/utils/tag';
type IProps = HTMLAttributes<HTMLDivElement> & {
tags: Partial<ITag>[];
is_editable?: boolean;
onTagsChange?: (tags: string[]) => void;
onTagClick?: (tag: Partial<ITag>) => void;
};
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
const [data, setData] = useState<string[]>([]);
const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]);
const onSubmit = useCallback(
(last: string[]) => {
const exist = tags.map(tag => tag.title);
onTagsChange(uniq([...exist, ...data, ...last]));
},
[data]
);
useEffect(() => {
setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim())));
}, [tags]);
const onAppendTag = useCallback(
(created: string[]) => {
setData(uniq([...data, ...created]).filter(title => !tags.some(it => it.title === title)));
},
[data, setData, tags]
);
const onClearTag = useCallback((): string | undefined => {
if (!data.length) return;
const last = data[data.length - 1];
setData(data.slice(0, data.length - 1));
return last;
}, [data, setData]);
const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [
data,
tags,
]);
return (
<TagField {...props}>
{catTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
))}
{ordinaryTags.map(tag => (
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
))}
{data.map(title => (
<Tag key={title} tag={{ title }} is_editing />
))}
{is_editable && (
<TagInput
onAppend={onAppendTag}
onClearTag={onClearTag}
onSubmit={onSubmit}
exclude={exclude}
/>
)}
</TagField>
);
};

View file

@ -48,5 +48,6 @@ export const API = {
},
TAG: {
NODES: `/tag/nodes`,
AUTOCOMPLETE: `/tag/autocomplete`,
},
};

View file

@ -23,7 +23,9 @@
justify-content: flex-start;
padding-left: $gap / 2;
min-width: 0;
position: relative;
z-index: 10;
@media (max-width: 1024px) {
padding-left: 0;
padding-top: $comment_height / 2;

View file

@ -2,7 +2,6 @@ import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { SidebarWrapper } from '~/containers/sidebars/SidebarWrapper';
import styles from './styles.module.scss';
import { useHistory, useRouteMatch } from 'react-router';
import { Tag } from '~/components/node/Tag';
import { Icon } from '~/components/input/Icon';
import { Link } from 'react-router-dom';
import { TagSidebarList } from '~/components/sidebar/TagSidebarList';
@ -11,6 +10,7 @@ import { selectTagNodes } from '~/redux/tag/selectors';
import * as ACTIONS from '~/redux/tag/actions';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
import { Tag } from '~/components/tags/Tag';
const mapStateToProps = state => ({
nodes: selectTagNodes(state),

View file

@ -7,6 +7,17 @@ export const tagSetNodes = (nodes: Partial<ITagState['nodes']>) => ({
});
export const tagLoadNodes = (tag: string) => ({
type: TAG_ACTIONS.LOAD_TAG_NODES,
type: TAG_ACTIONS.LOAD_NODES,
tag,
});
export const tagSetAutocomplete = (autocomplete: Partial<ITagState['autocomplete']>) => ({
type: TAG_ACTIONS.SET_TAG_AUTOCOMPLETE,
autocomplete,
});
export const tagLoadAutocomplete = (search: string, exclude: string[]) => ({
type: TAG_ACTIONS.LOAD_AUTOCOMPLETE,
search,
exclude,
});

View file

@ -17,3 +17,17 @@ export const getTagNodes = ({
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
.then(resultMiddleware)
.catch(errorMiddleware);
export const getTagAutocomplete = ({
search,
exclude,
access,
}: {
access: string;
search: string;
exclude: string[];
}): Promise<IResultWithStatus<{ tags: string[] }>> =>
api
.get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } }))
.then(resultMiddleware)
.catch(errorMiddleware);

View file

@ -1,6 +1,8 @@
const prefix = 'TAG.';
export const TAG_ACTIONS = {
LOAD_TAG_NODES: `${prefix}LOAD_TAG_NODES`,
LOAD_NODES: `${prefix}LOAD_TAG_NODES`,
SET_TAG_NODES: `${prefix}SET_TAG_NODES`,
SET_TAG_AUTOCOMPLETE: `${prefix}SET_TAG_NODES`,
LOAD_AUTOCOMPLETE: `${prefix}LOAD_TAG_AUTOCOMPLETE`,
};

View file

@ -1,6 +1,6 @@
import { TAG_ACTIONS } from '~/redux/tag/constants';
import { ITagState } from '~/redux/tag/index';
import { tagSetNodes } from '~/redux/tag/actions';
import { tagSetAutocomplete, tagSetNodes } from '~/redux/tag/actions';
const setNodes = (state: ITagState, { nodes }: ReturnType<typeof tagSetNodes>) => ({
...state,
@ -10,6 +10,18 @@ const setNodes = (state: ITagState, { nodes }: ReturnType<typeof tagSetNodes>) =
},
});
const setAutocomplete = (
state: ITagState,
{ autocomplete }: ReturnType<typeof tagSetAutocomplete>
) => ({
...state,
autocomplete: {
...state.autocomplete,
...autocomplete,
},
});
export const TAG_HANDLERS = {
[TAG_ACTIONS.SET_TAG_NODES]: setNodes,
[TAG_ACTIONS.SET_TAG_AUTOCOMPLETE]: setAutocomplete,
};

View file

@ -8,6 +8,10 @@ export interface ITagState {
count: number;
isLoading: boolean;
};
autocomplete: {
isLoading: boolean;
options: string[];
};
}
const INITIAL_STATE: ITagState = {
@ -16,6 +20,10 @@ const INITIAL_STATE: ITagState = {
count: 0,
isLoading: true,
},
autocomplete: {
isLoading: true,
options: [],
},
};
export default createReducer(INITIAL_STATE, TAG_HANDLERS);

View file

@ -1,9 +1,9 @@
import { TAG_ACTIONS } from '~/redux/tag/constants';
import { call, put, select, takeLatest } from 'redux-saga/effects';
import { tagLoadNodes, tagSetNodes } from '~/redux/tag/actions';
import { call, delay, put, select, takeLatest } from 'redux-saga/effects';
import { tagLoadAutocomplete, tagLoadNodes, tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions';
import { reqWrapper } from '~/redux/auth/sagas';
import { selectTagNodes } from '~/redux/tag/selectors';
import { getTagNodes } from '~/redux/tag/api';
import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api';
import { Unwrap } from '~/redux/types';
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
@ -26,6 +26,28 @@ function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
}
}
export default function* tagSaga() {
yield takeLatest(TAG_ACTIONS.LOAD_TAG_NODES, loadTagNodes);
function* loadAutocomplete({ search, exclude }: ReturnType<typeof tagLoadAutocomplete>) {
if (search.length < 3) return;
try {
yield put(tagSetAutocomplete({ isLoading: true }));
yield delay(100);
const { data, error }: Unwrap<ReturnType<typeof getTagAutocomplete>> = yield call(
reqWrapper,
getTagAutocomplete,
{ search, exclude }
);
if (error) throw new Error(error);
yield put(tagSetAutocomplete({ options: data.tags, isLoading: false }));
} catch (e) {
yield put(tagSetAutocomplete({ isLoading: false }));
}
}
export default function* tagSaga() {
yield takeLatest(TAG_ACTIONS.LOAD_NODES, loadTagNodes);
yield takeLatest(TAG_ACTIONS.LOAD_AUTOCOMPLETE, loadAutocomplete);
}

View file

@ -2,3 +2,4 @@ import { IState } from '~/redux/store';
export const selectTag = (state: IState) => state.tag;
export const selectTagNodes = (state: IState) => state.tag.nodes;
export const selectTagAutocomplete = (state: IState) => state.tag.autocomplete;

View file

@ -160,6 +160,11 @@ const Sprites: FC<{}> = () => (
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
</g>
<g id="keyboard" stroke="none">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z" />
</g>
<g id="key" stroke="none">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
@ -220,6 +225,11 @@ const Sprites: FC<{}> = () => (
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
</g>
<g id="tag" stroke="none">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16zM16 17H5V7h11l3.55 5L16 17z" />
</g>
<g id="messages" stroke="none">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />

13
src/utils/tag.ts Normal file
View file

@ -0,0 +1,13 @@
import { ITag } from '~/redux/types';
export const separateTags = (tags: Partial<ITag>[]): Partial<ITag>[][] =>
(tags || []).reduce(
(obj, tag) =>
tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]],
[[], []]
);
export const separateTagOptions = (options: string[]): string[][] =>
separateTags(options.map((title): Partial<ITag> => ({ title }))).map(item =>
item.map(({ title }) => title)
);

View file

@ -176,6 +176,7 @@ module.exports = () => {
contentBase: 'dist',
publicPath: '/',
hot: true,
open: false,
},
};
};