Skip to content

Commit

Permalink
[FEAT] 검색창 자동 완성 (#418)
Browse files Browse the repository at this point in the history
* refactor: PrefixTokenizer 구현 수정

* refactor: PrefixTokenizer 구현 수정

* feat: 검색 위젯 토큰화 구현

* chore: 화면 테스트를 위한 뷰어 패널 코드 수정 (반영 안될 수 있는 커밋)

* feat: 토큰 검색창 CSS 설정

* chore: 일부 주석 추가 및 코드 위치 변경

* chore: yarn.lock 파일 커밋
  • Loading branch information
kimminkyeu authored Nov 11, 2024
1 parent f61220a commit 04c97d2
Show file tree
Hide file tree
Showing 10 changed files with 2,096 additions and 2,710 deletions.
2 changes: 2 additions & 0 deletions frontend/techpick/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@
"re-resizable": "^6.10.0",
"react": "^18",
"react-arborist": "^3.4.0",
"react-customize-token-input": "^2.6.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18",
"react-hot-toast": "^2.4.1",
"react-spinners": "^0.14.1",
"techpick-shared": "workspace:*",
"use-immer": "^0.10.0",
"uuid": "^11.0.2",
"zustand": "^4.5.5"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect } from 'react';
import { PickListViewerPanel } from '@/components/PickListViewerPanel/PickListViewerPanel';
import { useTreeStore } from '@/stores/dndTreeStore/dndTreeStore';

export default function UnclassifiedFolderPage() {
Expand All @@ -18,5 +19,5 @@ export default function UnclassifiedFolderPage() {
[basicFolderMap, selectSingleFolder]
);

return <h1>UnclassifiedFolderPage page</h1>;
return <PickListViewerPanel />;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { style } from '@vanilla-extract/css';
import { colorThemeContract } from 'techpick-shared';

// const { color } = colorThemeContract;
import { colorThemeContract, sizes, space } from 'techpick-shared';

export const globalLayout = style({
backgroundColor: colorThemeContract.color.background,
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100vh',
padding: space['8'],

'@media': {
'screen and (min-width: 1440px)': {
minWidth: sizes['3xs'],
},
},
});

export const headerLayout = style({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
getPickList,
GetPickResponse,
} from '@/components/PickListViewerPanel/api/getPickList';
// import { useSearchParam } from '@/components/PickListViewerPanel/model/useSearchParam';
import { useSearchParam } from '@/components/PickListViewerPanel/model/useSearchParam';
import {
globalLayout,
mainLayout,
Expand Down Expand Up @@ -34,20 +34,17 @@ function PickListWidget({ viewTemplate }: ListWidgetProps) {

useEffect(() => {
(async () => {
const response = await getPickList
.withSearchParam
// useSearchParam.getState()
();
const response = await getPickList.withSearchParam(
useSearchParam.getState()
);
if (response) setPickResponse(response);
})(/*IIFE*/);
}, [pickResponse]);

/*
const removeDuplicate = (res: GetPickResponse) => {
const pickList = res.flatMap((eachFolder) => eachFolder.pickList);
return groupBy((pick) => pick.parentFolderId, pickList);
};
*/
// const removeDuplicate = (res: GetPickResponse) => {
// const pickList = res.flatMap((eachFolder) => eachFolder.pickList);
// return groupBy((pick) => pick.parentFolderId, pickList);
// };

return (
<div className={viewTemplate.listLayoutStyle}>
Expand All @@ -58,7 +55,6 @@ function PickListWidget({ viewTemplate }: ListWidgetProps) {
</div>
);
}

/*
const groupBy = <T,>(selector: (data: T) => unknown, sourceList: T[]) => {
return Array.from(Map.groupBy(sourceList, selector).values()).flat();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Command } from 'cmdk';
import { FolderOpen, Tag } from 'lucide-react';
import WrappedTokenInput, {
KEY_DOWN_HANDLER_CONFIG_OPTION,
TokenInputRef,
} from 'react-customize-token-input';
import { SelectedTagItem } from '@/components';
import { listItemStyle } from '@/components/PickListViewerPanel/SearchWidget/SearchWidget.css';
import { getEntries } from '@/components/PickListViewerPanel/types/common.type';
import {
TokenInfo,
TokenPrefixPattern,
} from '@/components/PickListViewerPanel/util/tokenizer/PrefixTokenizer.type';
import { Token } from '@/components/PickListViewerPanel/util/tokenizer/PrefixTokenizer.type';
import { useTreeStore } from '@/stores/dndTreeStore/dndTreeStore';
import { useTagStore } from '@/stores/tagStore';
import { getStringTokenizer } from '../util';
import { PrefixPatternBuilder } from '../util/tokenizer/PrefixPatternBuilder';
import { FolderType, TagType } from '@/types';

import './tokenInput.css';

type SearchKey = 'TAG' | 'FOLDER' | 'NONE';

const pattern = new PrefixPatternBuilder<SearchKey>()
Expand All @@ -21,110 +24,179 @@ const pattern = new PrefixPatternBuilder<SearchKey>()
.ifNoneMatch('NONE')
.build();

const findPrefixByKey = (
targetKey: SearchKey,
pattern: TokenPrefixPattern<SearchKey>
) => {
const target = getEntries(pattern).find(([key, _]) => key === targetKey);
return target![1]; // target cannot be null
};
interface TokenLabelProps {
token: Token<SearchKey>;
icon?: React.ReactNode;
}

const initialAutocompleteContext: TokenInfo<SearchKey> = {
key: 'NONE',
token: '',
};
export function TokenLabel(props: TokenLabelProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '4px',
}}
>
{props.icon}
{`${props.token.text}`}
</div>
);
}

export function SearchWidget() {
// tokenizer
// tokenizer + input
const [input, setInput] = useState('');
const inputRef = useRef<TokenInputRef>(null);
const inputTokenizer = useMemo(
() => getStringTokenizer<SearchKey>(pattern),
[]
);
// basic inputs
const inputRef = useRef<HTMLInputElement>(null);
// last input Token
const [autocompleteContext, setAutocompleteContext] = useState<
TokenInfo<SearchKey>
>(initialAutocompleteContext);
// full input value
const [searchInput, setSearchInput] = useState('');
// user data to use in recommendation
// token list + current token context
const [tokens, setTokens] = useState<Token<SearchKey>[]>([]);
const [tokenInputContext, setTokenInputContext] =
useState<Token<SearchKey> | null>(null);
// fetched user data for auto-completion
const { tagList, fetchingTagList } = useTagStore();
// user data to use in recommendation
const { getFolderList, getFolders } = useTreeStore();

useEffect(function loadTagAndFolderList() {
fetchingTagList(); // fetch tag list
getFolders(); // fetch folder list
}, []);
/**
* @description
* 토큰 리스트에 변경이 일어나면, 실제 검색 api를 호출하기 위한 작업을 수행.
*/
useEffect(
function fixSearchQueryOnTokenListChange() {
if (tokens.length <= 0) return;
console.log(tokens);
// TODO: convert token lists to global search state
// const tagList = tokens.filter((token) => token.key === 'TAG');
// const folderList = tokens.filter((token) => token.key === 'FOLDER');
// const textList = tokens.filter((token) => token.key === 'NONE');
},
[tokens]
);

useEffect(
function loadTagAndFolderList() {
fetchingTagList(); // fetch tag list
getFolders(); // fetch folder list
},
[fetchingTagList, getFolders]
);

useEffect(
function parseLastTokenOfSearchInput() {
const wordList = searchInput.split(' ');
const lastTokenInfo = inputTokenizer
.tokenize(wordList[wordList.length - 1])
.getLastTokenInfo();
setAutocompleteContext(lastTokenInfo ?? initialAutocompleteContext);
function setAutocompleteModeByInput() {
if (!input) {
resetInputContext();
return;
}
const currentToken = inputTokenizer.tokenize(input).getLastToken();
currentToken && setTokenInputContext(currentToken);
},
[searchInput]
[input, inputTokenizer]
);

const doInputAutoComplete = (item: TagType | FolderType) => () => {
const prefix = findPrefixByKey(autocompleteContext.key, pattern);
setSearchInput((prev) => {
const wordList = prev.split(' ');
wordList.pop();
wordList.push(`${prefix}${item.name}`);
return wordList.join(' ');
});
// close autocomplete context
setAutocompleteContext({ key: 'NONE', token: '' });
const buildTokenIfNotAutocomplete = (input: string): Token<SearchKey> => {
return {
key: 'NONE',
text: input,
id: -1,
};
};

const editTokenList = (newTokenValues: Token<SearchKey>[]) => {
setTokens(newTokenValues);
resetInputContext();
};

const renderTokenLabel = (token: Token<SearchKey>) => {
switch (token.key) {
case 'FOLDER':
return <TokenLabel token={token} icon={<FolderOpen size={'14px'} />} />;
case 'TAG':
return <TokenLabel token={token} icon={<Tag size={'14px'} />} />;
case 'NONE':
default:
return <>{`${token.text}`}</>;
}
};

const onAutocompleteSelect = (item: TagType | FolderType) => () => {
if (!tokenInputContext) return;
setTokens((prev) => [
...prev,
{ key: tokenInputContext.key, text: item.name, id: item.id },
]);
resetInputContext();
};

const resetInputContext = () => {
setInput('');
setTokenInputContext(null);
inputRef.current?.setCreatorValue('');
inputRef.current?.focus();
};

const setKeyEvent = (key?: SearchKey) => {
return key && key !== 'NONE'
? { onEnter: KEY_DOWN_HANDLER_CONFIG_OPTION.OFF }
: { onEnter: KEY_DOWN_HANDLER_CONFIG_OPTION.ON };
};

return (
<Command
style={{ width: '70%' }} // TODO: change to css.ts + 화면 크기에 따라 맞추기
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
label={'Search'}
>
<input // NOTE: 화면에 렌더링되는 Input
ref={inputRef}
// className={inputStyle}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
<Command.Input // NOTE: 자동 완성 기능을 위한 Input 이며, 화면에 렌더링 되지 않음
<Command.Input // just for auto-completion
style={{ display: 'none' }}
value={autocompleteContext.token}
value={tokenInputContext?.text}
/>
{/* TODO: 현재 Enter를 누르지 않고 다른 UI 클릭 시 그냥 토큰이 생성됩니다.
이 부분을 막아 주는 작업이 추가로 필요합니다.
-----------------------------------------------------------
재현 예시
- 1. #을 누르면 태그 리스트가 보여짐.
- 2. 그 상태로 다른 UI 창 클릭시, #이 그대로 입력 된다.
- 3. 또 자동 완성 아이템이 아닌, input 창을 클릭하면 #이 그대로 입력된다.
----------------------------------------------------------- */}
<WrappedTokenInput
ref={inputRef}
tokenValues={tokens}
specialKeyDown={setKeyEvent(tokenInputContext?.key)}
onTokenValuesChange={editTokenList}
onBuildTokenValue={buildTokenIfNotAutocomplete}
onGetTokenDisplayLabel={renderTokenLabel}
onInputValueChange={setInput}
/>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{autocompleteContext.key === 'TAG' &&
{tokenInputContext?.key === 'TAG' &&
tagList.map((tag) => (
<Command.Item
key={tag.id}
className={listItemStyle}
value={tag.name}
onSelect={doInputAutoComplete(tag)}
onSelect={onAutocompleteSelect(tag)}
keywords={[tag.name]}
>
<SelectedTagItem tag={tag} />
</Command.Item>
))}
{autocompleteContext.key === 'FOLDER' &&
{tokenInputContext?.key === 'FOLDER' &&
getFolderList().map((folder) => (
<Command.Item
key={folder.id}
className={listItemStyle}
value={folder.name}
onSelect={doInputAutoComplete(folder)}
onSelect={onAutocompleteSelect(folder)}
keywords={[folder.name]}
>
{folder.name /* TODO: 폴더 아이템 컴포넌트로 수정 할 것 */}
{/*<FolderItem folder={folder} />*/}
</Command.Item>
))}
</Command.List>
Expand Down
Loading

0 comments on commit 04c97d2

Please sign in to comment.