SkyPrinter - Search -

SearchBoxImg


SearchBox 항공권(국가, 도시, 지역) 검색영역

SkyPrinter 검색 영역


SkyPrinter 바로가기


목차

  1. SearchBox 폴더구조

  2. Search API 알아보기 - List Places -

  3. Redux Module 파악하기

  4. SearchBox 컴포넌트 파악하기

  5. 결과


SearchBox 폴더 구조

  • index.jsx
  • BoundSearchBox.jsx
  • RenderPlaceList.jsx
  • ParseWord.jsx
  • BoundChangeBox.jsx
  • CheckBox.jsx

Search API 알아보기 - List Places -

사용자 입력한 검색어를 통해 API를 호출하여 출발지와 도착지의 리스트를 보여줍니다.

country : 국가
language: 언어
area : 입력한 장소
isDestination : 출발지 도착지 여부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import axios from 'axios';

const SELECT_AREA_API_URL =
'https://www.skyscanner.co.kr/g/autosuggest-flights';

export default class SearchService {
static async SelectArea(country, language, area, isDestination) {
return await axios.get(
`${SELECT_AREA_API_URL}/${country}/${language}/${area}`,
{
isDestination: isDestination, // 출발지 도착지 여부
enable_general_search_v2: true,
},
);
}
}

img


Redux Module 파악하기

비동기 처리(유효성 검사)가 필요한 부분은 미들웨어(Redux Saga)로 처리를 하였습니다.

places module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// src/redux/modules/places.jsx
import { takeEvery, put, select } from 'redux-saga/effects';
import { SET_ERROR } from './error';

// ACTIONS
export const SET_PLACE = 'skyprinter/places/SET_PLACE';
export const FETCH_PLACE = 'skyprinter/places/FETCH_PLACE';
export const SWITCH_PLACES = 'skyprinter/places/SWITCH_PLACES';
export const INITIALIZE_PLACES = 'skyprinter/places/INITIALIZE_PLACES';

// ACTION CREATORS
export const initializePlaces = () => ({
type: INITIALIZE_PLACES
});
export const setPlace = places => ({ type: SET_PLACE, places });
export const switchPlaces = () => ({ type: SWITCH_PLACES });

export function* fetchPlaces(action) {
const error = yield select(state => state.error);

try {
yield put({
type: FETCH_PLACE,
places: action.places
});

// 유효성 검사
if (error.errorOccurred) {
const places = yield select(state => state.places);
let newErrors = error.errors;
if (newErrors !== null) {
if (places.inBoundId && places.outBoundId) {
newErrors = newErrors.filter(e => e.type !== 'Incorrect places');
}
if (places.inBoundId !== places.outBoundId) {
newErrors = newErrors.filter(e => e.type !== 'PlaceId is same');
}
if (places.inBoundId.length !== 2 && places.outBoundId.length !== 2) {
newErrors = newErrors.filter(e => e.type !== 'No Country');
}
newErrors.length === 0 || newErrors === null
? yield put({ type: SET_ERROR, errors: null })
: yield put({ type: SET_ERROR, errors: newErrors });
}
}
} catch (error) {
console.log(error);
}
}

// ROOT SAGA
export function* placesSaga() {
yield takeEvery(SET_PLACE, fetchPlaces);
}

// INIITIAL STATE
const initialState = {
inBoundId: '',
inBoundName: '',
outBoundId: '',
outBoundName: ''
};

// REDUCER
export default function places(state = initialState, action) {
switch (action.type) {
case INITIALIZE_PLACES:
return initialState;

case FETCH_PLACE:
const { places } = action;
if (places.type === 'inBound') {
return {
inBoundId: places.PlaceId,
inBoundName: places.PlaceName,
outBoundId: state.outBoundId,
outBoundName: state.outBoundName
};
} else {
return {
inBoundId: state.inBoundId,
inBoundName: state.inBoundName,
outBoundId: places.PlaceId,
outBoundName: places.PlaceName
};
}
case SWITCH_PLACES:
return {
inBoundId: state.outBoundId,
inBoundName: state.outBoundName,
outBoundId: state.inBoundId,
outBoundName: state.inBoundName
};
default:
return state;
}
}

PlacesContainer

컨테이너를 두어 리덕스의 상태와 액션들을 실제로 사용할 컴포넌트에게 전달해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/containers/PlacesContainer.jsx
import { connect } from 'react-redux';
import { switchPlaces, setPlace } from '../redux/modules/places';

import SearchBox from '../components/Main/SearchBox';

const mapStateToProps = state => {
return {
places: state.places,
};
};

const mapDispatchToProps = dispatch => ({
setPlace: places => {
dispatch(setPlace(places));
},
switchPlaces: () => {
dispatch(switchPlaces());
},
});

export default connect(mapStateToProps, mapDispatchToProps)(SearchBox);

SearchBox 컴포넌트 파악하기

상태 정리

index.jsx
  • 선언한 상태

    bound → BoundSearchBox.jsx

    • 초기값 : 리덕스에는 Name, Id를 모두 저장합니다. Name만 지역 상태로 만든 이유는 switch한 결과를 props를 통해 내려주고 BoundSearchBox에서 라이브러리가 확인할 수 있게 하기 위함입니다.

      1
      2
      3
      4
      const [bound, setBound] = useState({
      inBoundName: places.inBoundName,
      outBoundName: places.outBoundName,
      });
    • 상태 변경 함수 : selectBound → BoundSearchBox.jsx

      1
      2
      3
      const selectBound = (PlaceId, PlaceName, type) => {
      setPlace({ PlaceId, PlaceName, type }); // 컨테이너에서 전달받은 액션
      };
    • 상태 변경 함수 : changeBound → BoundChangeBox.jsx

      1
      2
      3
      4
      5
      6
      7
      const changeBound = () => {
      setBound({ // 컨테이너에서 전달받은 액션
      inBoundName: places.outBoundName,
      outBoundName: places.inBoundName,
      });
      switchPlaces(); // boundName을 서로 교환해줍니다.
      };

BoundSearchBox (출발지/도착지)
  • 선언한 상태

    suggestions → RenderPlaceList.jsx

    • 초기값

      1
      []
    • 상태 변경 함수 : searchPlace → BoundSearchBox.jsx

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      const searchPlace = async value => {
      try {
      const { data } = await SearchService.SelectArea(
      'KR',
      'ko-KR',
      value,
      isDestination,
      );
      setSuggestions(data);
      } catch (e) {
      console.error(e);
      }
      };

  • 물려받은 상태 및 물려받은 상태 변경 함수

    ➤ index.jsx : bound , Fn: selectBound


  • 사용 서비스

    SearchService

  • 사용 라이브러리

    AutoSuggest

  • 키보드 접근성 엔터키 추가하기

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const pressEnter = (e, type) => {
    if (e.keyCode === 13 && e.target.nodeName === 'INPUT') {
    const form = e.target.form;
    const index = Array.prototype.indexOf.call(form, e.target);
    type === 'inBound'
    ? form.elements[index + 3].focus()
    : form.elements[index + 2].focus();
    e.preventDefault();
    }
    };

RenderPlaceList
  • 선언한 상태

    없음


  • 물려받은 상태 및 물려받은 상태 변경 함수

    ➤ BoundSearchBox.jsx : suggestions

  • 사용 라이브러리

    dompurify : 검색어 하이라이팅을 적용하기 위해서 사용하였습니다.

    처음에는 for문을 반복해서 하이라이팅의 배열에 적혀있는 문자열을 파싱해오고 그 결과를 따로 저장해서 화면에 뿌려주는 방식을 채택했으나 비정상적으로 많은 반복문을 돌게 되면서 프로그램의 성능저하가 우려되 dompurify를 이용하여 innerHTML을 채택하는 방식을 사용했습니다. XSS스크립트 공격을 방지하면서 dangerouslySetInnerHTML 속성을 이용할 수 있습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    const sanitizer = dompurify.sanitize;
    const Result = ParsePlaceList(place, suggestion);
    ...
    <span
    dangerouslySetInnerHTML={{
    __html: sanitizer(Result.CountryName),
    }}
    />

ParsePlaceList.jsx

RenderPlaceList에서 가져온 장소명을 하이라이팅 처리해주는 함수입니다

하이라이팅 처리가된 배열에게 <strong></strong> 태그를 앞 뒤로 삽입합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
const ParsePlaceList = (place, suggestion) => {
const Result = {};
const insertTag = (highlightings, str) => {
const starts = [];
const ends = [];

highlightings.forEach(highlighting => {
starts.push(highlighting[0]);
ends.push(highlighting[1]);
});

return str
.split('')
.map((chr, pos) => {
if (starts.indexOf(pos) !== -1) chr = '<strong>' + chr;
if (ends.indexOf(pos) !== -1) chr = '</strong>' + chr;
return chr;
})
.join('');
};

const WordArray = insertTag(
suggestion.Highlighting,
suggestion.ResultingPhrase,
);

if (place === 'Country') {
Result.CountryName = WordArray.split('|');
} else {
const ResultingArray = WordArray.split('|');
Result.PlaceName = ResultingArray[0].includes(',')
? ResultingArray[0].split(',')[0].split('(')[0]
: ResultingArray[0];
Result.CountryName = ResultingArray[ResultingArray.length - 1];
}

return Result;
};

export default ParsePlaceList;
const ParsePlaceList = (place, suggestion) => {
const Result = {};
const insertTag = (highlightings, str) => {
const starts = [];
const ends = [];

highlightings.forEach(highlighting => {
starts.push(highlighting[0]);
ends.push(highlighting[1]);
});

return str
.split('')
.map((chr, pos) => {
if (starts.indexOf(pos) !== -1) chr = '<strong>' + chr;
if (ends.indexOf(pos) !== -1) chr = '</strong>' + chr;
return chr;
})
.join('');
};

const WordArray = insertTag(
suggestion.Highlighting,
suggestion.ResultingPhrase,
);

if (place === 'Country') {
Result.CountryName = WordArray.split('|');
} else {
const ResultingArray = WordArray.split('|');
Result.PlaceName = ResultingArray[0].includes(',')
? ResultingArray[0].split(',')[0].split('(')[0]
: ResultingArray[0];
Result.CountryName = ResultingArray[ResultingArray.length - 1];
}

return Result;
};

export default ParsePlaceList;

BoundChangeBox.jsx
  • 선언한 상태

    없음


  • 물려받은 상태 및 물려받은 상태 변경 함수

    ➤ index.jsx : Fn: changeBound

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const changeBound = () => {
    if (!(bound.inBoundName || bound.outBoundName)) return;\
    setBound({
    inBoundId: bound.outBoundId,
    outBoundId: bound.inBoundId,
    inBoundName: bound.outBoundName,
    outBoundName: bound.inBoundName,
    });
    };

결과

검색 리스트 불러오기 및 하이라이팅 처리

  • 입력한 내용에 따라 자동으로 리스트를 불러오기
  • 입력한 내용에 맞춰 글자가 굵어지는 하이라이팅 처리
  • 키보드 접근성 준수(화살표 방향키, Tab키, 엔터키)

SearchList


국가, 도시, 공항 기준 이미지 및 CSS 적용하기

  • 국가는 깃발모양, 도시는 건물모양, 공항역은 비행기모양의 svg 이미지가 각각 렌더링 됩니다.

SeparateCountry

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/components/Main/SearchBox/BoundSearchBox.jsx
// 도시 예
...
const CityName = useRef(null);
...
return (
<RenderPlaceList
place="AirStation"
suggestion={suggestion}
hasCity={suggestion.CityId === CityName.current}
/>
);
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/components/Main/SearchBox/RenderPlaceList.jsx

switch (place) {
case 'Country': {
return (
<ListSection>
...
</ListSection>
);
}
case 'City': {
return (
<ListSection>
...
</ListSection>
);
}
default: { // AirPort
return (
<ListSection hasCity={hasCity}>
...
</ListSection>
);
}
}
});
Share