mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
added lab search
This commit is contained in:
parent
16d12f92da
commit
ddf2b6eda3
16 changed files with 149 additions and 28 deletions
|
@ -7,9 +7,9 @@ import {
|
||||||
} from '~/types/lab';
|
} from '~/types/lab';
|
||||||
import { api, cleanResult } from '~/utils/api';
|
import { api, cleanResult } from '~/utils/api';
|
||||||
|
|
||||||
export const getLabNodes = ({ offset, limit, sort }: GetLabNodesRequest) =>
|
export const getLabNodes = ({ offset, limit, sort, search }: GetLabNodesRequest) =>
|
||||||
api
|
api
|
||||||
.get<GetLabNodesResult>(API.LAB.NODES, { params: { offset, limit, sort } })
|
.get<GetLabNodesResult>(API.LAB.NODES, { params: { offset, limit, sort, search } })
|
||||||
.then(cleanResult);
|
.then(cleanResult);
|
||||||
|
|
||||||
export const getLabStats = () => api.get<GetLabStatsResult>(API.LAB.STATS).then(cleanResult);
|
export const getLabStats = () => api.get<GetLabStatsResult>(API.LAB.STATS).then(cleanResult);
|
||||||
|
|
|
@ -1,4 +1,13 @@
|
||||||
import React, { ChangeEvent, FC, useCallback, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
FC,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -6,11 +15,21 @@ import { Icon } from '~/components/input/Icon';
|
||||||
import { InputWrapper } from '~/components/input/InputWrapper';
|
import { InputWrapper } from '~/components/input/InputWrapper';
|
||||||
import { useTranslatedError } from '~/hooks/data/useTranslatedError';
|
import { useTranslatedError } from '~/hooks/data/useTranslatedError';
|
||||||
import { useFocusEvent } from '~/hooks/dom/useFocusEvent';
|
import { useFocusEvent } from '~/hooks/dom/useFocusEvent';
|
||||||
import { IInputTextProps } from '~/types';
|
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
const InputText: FC<IInputTextProps> = ({
|
export type InputTextProps = Omit<
|
||||||
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
|
'prefix'
|
||||||
|
> & {
|
||||||
|
handler?: (value: string) => void;
|
||||||
|
title?: string;
|
||||||
|
error?: string;
|
||||||
|
suffix?: ReactNode;
|
||||||
|
prefix?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputText: FC<InputTextProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
handler,
|
handler,
|
||||||
required = false,
|
required = false,
|
||||||
|
@ -43,7 +62,12 @@ const InputText: FC<IInputTextProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputWrapper title={title} error={translatedError} focused={focused} notEmpty={!!value}>
|
<InputWrapper title={title} error={translatedError} focused={focused} notEmpty={!!value}>
|
||||||
<div className={classNames(styles.input, { [styles.has_error]: !!error })}>
|
<div
|
||||||
|
className={classNames(styles.input, {
|
||||||
|
[styles.has_error]: !!error,
|
||||||
|
[styles.has_prefix]: prefix,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -9,6 +9,12 @@
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.has_prefix {
|
||||||
|
input {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: $input_height;
|
height: $input_height;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
12
src/components/input/SearchInput/index.tsx
Normal file
12
src/components/input/SearchInput/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React, { VFC } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
import { InputText, InputTextProps } from '~/components/input/InputText';
|
||||||
|
|
||||||
|
interface SearchInputProps extends Omit<InputTextProps, 'prefix' | 'suffix'> {}
|
||||||
|
|
||||||
|
const SearchInput: VFC<SearchInputProps> = ({ ...props }) => (
|
||||||
|
<InputText {...props} prefix={<Icon icon="search" />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export { SearchInput };
|
|
@ -1,6 +1,9 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
|
import { Filler } from '~/components/containers/Filler';
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import { InputText } from '~/components/input/InputText';
|
||||||
|
import { SearchInput } from '~/components/input/SearchInput';
|
||||||
import { HorizontalMenu } from '~/components/menu/HorizontalMenu';
|
import { HorizontalMenu } from '~/components/menu/HorizontalMenu';
|
||||||
import { LabNodesSort } from '~/types/lab';
|
import { LabNodesSort } from '~/types/lab';
|
||||||
import { useLabContext } from '~/utils/context/LabContextProvider';
|
import { useLabContext } from '~/utils/context/LabContextProvider';
|
||||||
|
@ -12,10 +15,10 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const LabHead: FC<IProps> = ({ isLoading }) => {
|
const LabHead: FC<IProps> = ({ isLoading }) => {
|
||||||
const { sort, setSort } = useLabContext();
|
const { sort, setSort, search, setSearch } = useLabContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group className={styles.wrap} horizontal>
|
<div className={styles.wrap}>
|
||||||
<HorizontalMenu>
|
<HorizontalMenu>
|
||||||
<HorizontalMenu.Item
|
<HorizontalMenu.Item
|
||||||
color="green"
|
color="green"
|
||||||
|
@ -47,7 +50,13 @@ const LabHead: FC<IProps> = ({ isLoading }) => {
|
||||||
Важные
|
Важные
|
||||||
</HorizontalMenu.Item>
|
</HorizontalMenu.Item>
|
||||||
</HorizontalMenu>
|
</HorizontalMenu>
|
||||||
</Group>
|
|
||||||
|
<Filler />
|
||||||
|
|
||||||
|
<div className={styles.search}>
|
||||||
|
<SearchInput value={search} handler={setSearch} placeholder="Поиск" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,16 @@
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
@include tablet {
|
||||||
|
margin-top: $gap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
19
src/components/lab/LabNoResults/index.tsx
Normal file
19
src/components/lab/LabNoResults/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import React, { VFC } from 'react';
|
||||||
|
|
||||||
|
import { Card } from '~/components/containers/Card';
|
||||||
|
import { Button } from '~/components/input/Button';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
interface LabNoResultsProps {
|
||||||
|
resetSearch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabNoResults: VFC<LabNoResultsProps> = ({ resetSearch }) => (
|
||||||
|
<Card className={styles.wrap}>
|
||||||
|
<div className={styles.title}> Здесь ничего нет</div>
|
||||||
|
<Button onClick={resetSearch}>Сбросить поиск</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { LabNoResults };
|
16
src/components/lab/LabNoResults/styles.module.scss
Normal file
16
src/components/lab/LabNoResults/styles.module.scss
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
@import "src/styles/variables";
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
padding: $gap * 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 33vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_20_semibold;
|
||||||
|
margin-bottom: $gap * 2;
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import React, { FC } from 'react';
|
||||||
import Masonry from 'react-masonry-css';
|
import Masonry from 'react-masonry-css';
|
||||||
|
|
||||||
import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
|
import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
|
||||||
|
import { LabNoResults } from '~/components/lab/LabNoResults';
|
||||||
import { LabNode } from '~/components/lab/LabNode';
|
import { LabNode } from '~/components/lab/LabNode';
|
||||||
import { EMPTY_NODE, NODE_TYPES } from '~/constants/node';
|
import { EMPTY_NODE, NODE_TYPES } from '~/constants/node';
|
||||||
import { useLabContext } from '~/utils/context/LabContextProvider';
|
import { useLabContext } from '~/utils/context/LabContextProvider';
|
||||||
|
@ -30,7 +31,7 @@ const LoadingNode = () => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const LabGrid: FC<IProps> = () => {
|
const LabGrid: FC<IProps> = () => {
|
||||||
const { isLoading, nodes, hasMore, loadMore } = useLabContext();
|
const { isLoading, nodes, hasMore, loadMore, search, setSearch } = useLabContext();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
@ -52,6 +53,10 @@ const LabGrid: FC<IProps> = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (search && !nodes.length) {
|
||||||
|
return <LabNoResults resetSearch={() => setSearch('')} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll hasMore={hasMore} loadMore={loadMore}>
|
<InfiniteScroll hasMore={hasMore} loadMore={loadMore}>
|
||||||
<Masonry
|
<Masonry
|
||||||
|
|
12
src/hooks/data/useDebouncedValue.ts
Normal file
12
src/hooks/data/useDebouncedValue.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const useDebouncedValue = <T>(val: T, delay = 300) => {
|
||||||
|
const [state, setState] = useState<T>(val);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setState(val), delay);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [val, delay]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
|
@ -4,15 +4,17 @@ import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite';
|
||||||
|
|
||||||
import { getLabNodes } from '~/api/lab';
|
import { getLabNodes } from '~/api/lab';
|
||||||
import { useAuth } from '~/hooks/auth/useAuth';
|
import { useAuth } from '~/hooks/auth/useAuth';
|
||||||
|
import { useDebouncedValue } from '~/hooks/data/useDebouncedValue';
|
||||||
import { useLabStore } from '~/store/lab/useLabStore';
|
import { useLabStore } from '~/store/lab/useLabStore';
|
||||||
import { INode } from '~/types';
|
import { INode } from '~/types';
|
||||||
import { GetLabNodesRequest, ILabNode, LabNodesSort } from '~/types/lab';
|
import { GetLabNodesRequest, ILabNode, LabNodesSort } from '~/types/lab';
|
||||||
import { flatten, uniqBy } from '~/utils/ramda';
|
import { flatten, uniqBy } from '~/utils/ramda';
|
||||||
|
|
||||||
const getKey: (isUser: boolean, sort?: LabNodesSort) => SWRInfiniteKeyLoader = (isUser, sort) => (
|
const getKey: (isUser: boolean, sort?: LabNodesSort, search?: string) => SWRInfiniteKeyLoader = (
|
||||||
index,
|
isUser,
|
||||||
prev: ILabNode[]
|
sort,
|
||||||
) => {
|
search
|
||||||
|
) => (index, prev: ILabNode[]) => {
|
||||||
if (!isUser) return null;
|
if (!isUser) return null;
|
||||||
if (index > 0 && (!prev?.length || prev.length < 20)) return null;
|
if (index > 0 && (!prev?.length || prev.length < 20)) return null;
|
||||||
|
|
||||||
|
@ -20,6 +22,7 @@ const getKey: (isUser: boolean, sort?: LabNodesSort) => SWRInfiniteKeyLoader = (
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset: index * 20,
|
offset: index * 20,
|
||||||
sort: sort || LabNodesSort.New,
|
sort: sort || LabNodesSort.New,
|
||||||
|
search: search || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
return JSON.stringify(props);
|
return JSON.stringify(props);
|
||||||
|
@ -33,12 +36,13 @@ const parseKey = (key: string): GetLabNodesRequest => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGetLabNodes = (sort?: LabNodesSort) => {
|
export const useGetLabNodes = (sort?: LabNodesSort, search?: string) => {
|
||||||
const labStore = useLabStore();
|
const labStore = useLabStore();
|
||||||
const { isUser } = useAuth();
|
const { isUser } = useAuth();
|
||||||
|
const searchDebounced = useDebouncedValue(search);
|
||||||
|
|
||||||
const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
|
const { data, isValidating, size, setSize, mutate } = useSWRInfinite(
|
||||||
getKey(isUser, sort),
|
getKey(isUser, sort, searchDebounced),
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
const result = await getLabNodes(parseKey(key));
|
const result = await getLabNodes(parseKey(key));
|
||||||
return result.nodes;
|
return result.nodes;
|
||||||
|
@ -46,6 +50,7 @@ export const useGetLabNodes = (sort?: LabNodesSort) => {
|
||||||
{
|
{
|
||||||
fallbackData: [labStore.nodes],
|
fallbackData: [labStore.nodes],
|
||||||
onSuccess: data => labStore.setNodes(flatten(data)),
|
onSuccess: data => labStore.setNodes(flatten(data)),
|
||||||
|
dedupingInterval: 300,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { LabNodesSort } from '~/types/lab';
|
||||||
|
|
||||||
export const useLab = () => {
|
export const useLab = () => {
|
||||||
const [sort, setSort] = useState<LabNodesSort>(LabNodesSort.New);
|
const [sort, setSort] = useState<LabNodesSort>(LabNodesSort.New);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const { nodes, isLoading, loadMore, hasMore } = useGetLabNodes(sort);
|
const { nodes, isLoading, loadMore, hasMore } = useGetLabNodes(sort, search);
|
||||||
const { tags, heroes, updates, isLoading: isLoadingStats } = useGetLabStats();
|
const { tags, heroes, updates, isLoading: isLoadingStats } = useGetLabStats();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -21,5 +22,7 @@ export const useLab = () => {
|
||||||
updates,
|
updates,
|
||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,17 +15,6 @@ export interface ITag {
|
||||||
readonly updated_at: string;
|
readonly updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IInputTextProps = DetailedHTMLProps<
|
|
||||||
InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
HTMLInputElement
|
|
||||||
> & {
|
|
||||||
handler?: (value: string) => void;
|
|
||||||
title?: string;
|
|
||||||
error?: string;
|
|
||||||
suffix?: ReactElement;
|
|
||||||
prefix?: ReactElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IIcon = string;
|
export type IIcon = string;
|
||||||
|
|
||||||
export type ValueOf<T> = T[keyof T];
|
export type ValueOf<T> = T[keyof T];
|
||||||
|
|
|
@ -30,6 +30,7 @@ export type GetLabNodesRequest = {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
after?: string;
|
after?: string;
|
||||||
sort?: LabNodesSort;
|
sort?: LabNodesSort;
|
||||||
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ILabNode {
|
export interface ILabNode {
|
||||||
|
|
|
@ -15,6 +15,8 @@ export interface LabContextProps {
|
||||||
updates: IFlowNode[];
|
updates: IFlowNode[];
|
||||||
sort: LabNodesSort;
|
sort: LabNodesSort;
|
||||||
setSort: (sort: LabNodesSort) => void;
|
setSort: (sort: LabNodesSort) => void;
|
||||||
|
search: string;
|
||||||
|
setSearch: (val: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValues: LabContextProps = {
|
const defaultValues: LabContextProps = {
|
||||||
|
@ -28,6 +30,8 @@ const defaultValues: LabContextProps = {
|
||||||
updates: [],
|
updates: [],
|
||||||
sort: LabNodesSort.New,
|
sort: LabNodesSort.New,
|
||||||
setSort: () => {},
|
setSort: () => {},
|
||||||
|
search: '',
|
||||||
|
setSearch: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const LabContext = createContext<LabContextProps>(defaultValues);
|
const LabContext = createContext<LabContextProps>(defaultValues);
|
||||||
|
|
|
@ -17,6 +17,8 @@ const LabProvider: FC<LabProviderProps> = ({ children }) => {
|
||||||
updates,
|
updates,
|
||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
} = useLab();
|
} = useLab();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -31,6 +33,8 @@ const LabProvider: FC<LabProviderProps> = ({ children }) => {
|
||||||
updates={updates}
|
updates={updates}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
setSort={setSort}
|
setSort={setSort}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LabContextProvider>
|
</LabContextProvider>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue