mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 04:46:40 +07:00
added tag autocomplete
This commit is contained in:
parent
1414245a1a
commit
359cfaee7a
18 changed files with 375 additions and 80 deletions
|
@ -1,9 +1,136 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import classNames from 'classnames';
|
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';
|
||||||
|
|
||||||
interface IProps {}
|
const mapStateToProps = selectTagAutocomplete;
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
tagSetAutocomplete: TAG_ACTIONS.tagSetAutocomplete,
|
||||||
|
tagLoadAutocomplete: TAG_ACTIONS.tagLoadAutocomplete,
|
||||||
|
};
|
||||||
|
|
||||||
const TagAutocomplete: FC<IProps> = () => <div className={classNames(styles.window)}>auto</div>;
|
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 };
|
export { TagAutocomplete };
|
||||||
|
|
|
@ -3,18 +3,35 @@
|
||||||
100% { opacity: 100 }
|
100% { opacity: 100 }
|
||||||
}
|
}
|
||||||
|
|
||||||
.window {
|
$row_height: 24px;
|
||||||
box-shadow: transparentize(black, 0.5) 4px 4px 4px, inset transparentize(white, 0.95) 1px 1px;
|
|
||||||
|
|
||||||
|
.window {
|
||||||
|
box-shadow: transparentize(white, 0.8) 0 0 0 1px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -2px;
|
||||||
right: 0;
|
right: -2px;
|
||||||
width: calc(90vw - 20px);
|
width: calc(100vw - 15px);
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
background: lighten($content_bg, 4%);
|
background: darken($content_bg, 1%);
|
||||||
height: 100px;
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding-top: $tag_height;
|
padding: $tag_height + 2px 0 0;
|
||||||
animation: appear 0.25s forwards;
|
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;
|
||||||
}
|
}
|
||||||
|
|
19
src/components/tags/TagAutocompleteRow/index.tsx
Normal file
19
src/components/tags/TagAutocompleteRow/index.tsx
Normal 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 };
|
33
src/components/tags/TagAutocompleteRow/styles.module.scss
Normal file
33
src/components/tags/TagAutocompleteRow/styles.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,4 @@
|
||||||
import React, {
|
import React, { ChangeEvent, FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
||||||
ChangeEvent,
|
|
||||||
FC,
|
|
||||||
FocusEventHandler,
|
|
||||||
KeyboardEvent,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { TagAutocomplete } from '~/components/tags/TagAutocomplete';
|
import { TagAutocomplete } from '~/components/tags/TagAutocomplete';
|
||||||
import { TagWrapper } from '~/components/tags/TagWrapper';
|
import { TagWrapper } from '~/components/tags/TagWrapper';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
@ -30,17 +21,19 @@ interface IProps {
|
||||||
onAppend: (tags: string[]) => void;
|
onAppend: (tags: string[]) => void;
|
||||||
onClearTag: () => string | undefined;
|
onClearTag: () => string | undefined;
|
||||||
onSubmit: (last: string[]) => void;
|
onSubmit: (last: string[]) => void;
|
||||||
|
exclude: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
|
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const wrapper = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const onInput = useCallback(
|
const onInput = useCallback(
|
||||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
setInput(value);
|
setInput(value || '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +43,7 @@ const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
|
||||||
onAppend(items.slice(0, items.length - 1));
|
onAppend(items.slice(0, items.length - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
setInput(items[items.length - 1]);
|
setInput(items[items.length - 1] || '');
|
||||||
},
|
},
|
||||||
[setInput]
|
[setInput]
|
||||||
);
|
);
|
||||||
|
@ -68,7 +61,7 @@ const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' || key === ',' || key === 'Comma') {
|
if (key === ',' || key === 'Comma') {
|
||||||
const created = prepareInput(input);
|
const created = prepareInput(input);
|
||||||
|
|
||||||
if (created.length) {
|
if (created.length) {
|
||||||
|
@ -77,31 +70,47 @@ const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' && ref.current) {
|
|
||||||
ref.current.blur();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[input, setInput, onClearTag, onAppend, onSubmit, ref.current]
|
[input, setInput, onClearTag, onAppend, onSubmit, ref.current, wrapper.current]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFocus = useCallback(() => setFocused(true), []);
|
const onFocus = useCallback(() => setFocused(true), []);
|
||||||
const onBlur = useCallback<FocusEventHandler<HTMLInputElement>>(() => {
|
const onBlur = useCallback(
|
||||||
setFocused(false);
|
event => {
|
||||||
|
if (wrapper.current.contains(event.target)) {
|
||||||
|
ref.current.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.trim()) {
|
setFocused(false);
|
||||||
const created = prepareInput(input);
|
|
||||||
onAppend(created);
|
if (input.trim()) {
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit([]);
|
||||||
|
},
|
||||||
|
[input, onAppend, setInput, onSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAutocompleteSelect = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
onAppend([val]);
|
||||||
setInput('');
|
setInput('');
|
||||||
onSubmit(created);
|
},
|
||||||
}
|
[onAppend, setInput]
|
||||||
}, [input, onAppend, setInput, onSubmit]);
|
);
|
||||||
|
|
||||||
const feature = useMemo(() => (input.substr(0, 1) === '/' ? 'green' : ''), [input]);
|
const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', onBlur);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('click', onBlur);
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap} ref={wrapper}>
|
||||||
{onInput && focused && <TagAutocomplete />}
|
|
||||||
<TagWrapper title={input || placeholder} has_input={true} feature={feature}>
|
<TagWrapper title={input || placeholder} has_input={true} feature={feature}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -110,12 +119,20 @@ const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
maxLength={24}
|
maxLength={24}
|
||||||
onChange={onInput}
|
onChange={onInput}
|
||||||
onKeyUp={onKeyUp}
|
onKeyDown={onKeyUp}
|
||||||
onBlur={onBlur}
|
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
</TagWrapper>
|
</TagWrapper>
|
||||||
|
|
||||||
|
{onInput && focused && input?.length > 0 && (
|
||||||
|
<TagAutocomplete
|
||||||
|
exclude={exclude}
|
||||||
|
input={ref.current}
|
||||||
|
onSelect={onAutocompleteSelect}
|
||||||
|
search={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
.wrap {
|
.wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 13;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ITag } from '~/redux/types';
|
||||||
import uniq from 'ramda/es/uniq';
|
import uniq from 'ramda/es/uniq';
|
||||||
import { Tag } from '~/components/tags/Tag';
|
import { Tag } from '~/components/tags/Tag';
|
||||||
import { TagInput } from '~/components/tags/TagInput';
|
import { TagInput } from '~/components/tags/TagInput';
|
||||||
|
import { separateTags } from '~/utils/tag';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
tags: Partial<ITag>[];
|
tags: Partial<ITag>[];
|
||||||
|
@ -15,15 +16,7 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
|
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
|
||||||
const [data, setData] = useState<string[]>([]);
|
const [data, setData] = useState<string[]>([]);
|
||||||
|
|
||||||
const [catTags, ordinaryTags] = useMemo(
|
const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]);
|
||||||
() =>
|
|
||||||
(tags || []).reduce(
|
|
||||||
(obj, tag) =>
|
|
||||||
tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]],
|
|
||||||
[[], []]
|
|
||||||
),
|
|
||||||
[tags]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(last: string[]) => {
|
(last: string[]) => {
|
||||||
|
@ -33,23 +26,6 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
//
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim())));
|
setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
@ -68,6 +44,11 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
|
||||||
return last;
|
return last;
|
||||||
}, [data, setData]);
|
}, [data, setData]);
|
||||||
|
|
||||||
|
const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [
|
||||||
|
data,
|
||||||
|
tags,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TagField {...props}>
|
<TagField {...props}>
|
||||||
{catTags.map(tag => (
|
{catTags.map(tag => (
|
||||||
|
@ -83,7 +64,12 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{is_editable && (
|
{is_editable && (
|
||||||
<TagInput onAppend={onAppendTag} onClearTag={onClearTag} onSubmit={onSubmit} />
|
<TagInput
|
||||||
|
onAppend={onAppendTag}
|
||||||
|
onClearTag={onClearTag}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
exclude={exclude}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TagField>
|
</TagField>
|
||||||
);
|
);
|
||||||
|
|
|
@ -48,5 +48,6 @@ export const API = {
|
||||||
},
|
},
|
||||||
TAG: {
|
TAG: {
|
||||||
NODES: `/tag/nodes`,
|
NODES: `/tag/nodes`,
|
||||||
|
AUTOCOMPLETE: `/tag/autocomplete`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -23,6 +23,8 @@
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding-left: $gap / 2;
|
padding-left: $gap / 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
|
@ -7,6 +7,17 @@ export const tagSetNodes = (nodes: Partial<ITagState['nodes']>) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tagLoadNodes = (tag: string) => ({
|
export const tagLoadNodes = (tag: string) => ({
|
||||||
type: TAG_ACTIONS.LOAD_TAG_NODES,
|
type: TAG_ACTIONS.LOAD_NODES,
|
||||||
tag,
|
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,
|
||||||
|
});
|
||||||
|
|
|
@ -17,3 +17,17 @@ export const getTagNodes = ({
|
||||||
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
|
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
|
||||||
.then(resultMiddleware)
|
.then(resultMiddleware)
|
||||||
.catch(errorMiddleware);
|
.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);
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
const prefix = 'TAG.';
|
const prefix = 'TAG.';
|
||||||
|
|
||||||
export const TAG_ACTIONS = {
|
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_NODES: `${prefix}SET_TAG_NODES`,
|
||||||
|
SET_TAG_AUTOCOMPLETE: `${prefix}SET_TAG_NODES`,
|
||||||
|
LOAD_AUTOCOMPLETE: `${prefix}LOAD_TAG_AUTOCOMPLETE`,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
||||||
import { ITagState } from '~/redux/tag/index';
|
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>) => ({
|
const setNodes = (state: ITagState, { nodes }: ReturnType<typeof tagSetNodes>) => ({
|
||||||
...state,
|
...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 = {
|
export const TAG_HANDLERS = {
|
||||||
[TAG_ACTIONS.SET_TAG_NODES]: setNodes,
|
[TAG_ACTIONS.SET_TAG_NODES]: setNodes,
|
||||||
|
[TAG_ACTIONS.SET_TAG_AUTOCOMPLETE]: setAutocomplete,
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,6 +8,10 @@ export interface ITagState {
|
||||||
count: number;
|
count: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
};
|
};
|
||||||
|
autocomplete: {
|
||||||
|
isLoading: boolean;
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_STATE: ITagState = {
|
const INITIAL_STATE: ITagState = {
|
||||||
|
@ -16,6 +20,10 @@ const INITIAL_STATE: ITagState = {
|
||||||
count: 0,
|
count: 0,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
},
|
},
|
||||||
|
autocomplete: {
|
||||||
|
isLoading: true,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createReducer(INITIAL_STATE, TAG_HANDLERS);
|
export default createReducer(INITIAL_STATE, TAG_HANDLERS);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
import { TAG_ACTIONS } from '~/redux/tag/constants';
|
||||||
import { call, put, select, takeLatest } from 'redux-saga/effects';
|
import { call, delay, put, select, takeLatest } from 'redux-saga/effects';
|
||||||
import { tagLoadNodes, tagSetNodes } from '~/redux/tag/actions';
|
import { tagLoadAutocomplete, tagLoadNodes, tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions';
|
||||||
import { reqWrapper } from '~/redux/auth/sagas';
|
import { reqWrapper } from '~/redux/auth/sagas';
|
||||||
import { selectTagNodes } from '~/redux/tag/selectors';
|
import { selectTagNodes } from '~/redux/tag/selectors';
|
||||||
import { getTagNodes } from '~/redux/tag/api';
|
import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api';
|
||||||
import { Unwrap } from '~/redux/types';
|
import { Unwrap } from '~/redux/types';
|
||||||
|
|
||||||
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
||||||
|
@ -26,6 +26,28 @@ function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function* tagSaga() {
|
function* loadAutocomplete({ search, exclude }: ReturnType<typeof tagLoadAutocomplete>) {
|
||||||
yield takeLatest(TAG_ACTIONS.LOAD_TAG_NODES, loadTagNodes);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,3 +2,4 @@ import { IState } from '~/redux/store';
|
||||||
|
|
||||||
export const selectTag = (state: IState) => state.tag;
|
export const selectTag = (state: IState) => state.tag;
|
||||||
export const selectTagNodes = (state: IState) => state.tag.nodes;
|
export const selectTagNodes = (state: IState) => state.tag.nodes;
|
||||||
|
export const selectTagAutocomplete = (state: IState) => state.tag.autocomplete;
|
||||||
|
|
|
@ -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" />
|
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||||
</g>
|
</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">
|
<g id="key" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<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" />
|
<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" />
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<g id="messages" stroke="none">
|
||||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
<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" />
|
<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
13
src/utils/tag.ts
Normal 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)
|
||||||
|
);
|
Loading…
Add table
Add a link
Reference in a new issue