diff --git a/Application/DevLogApp/Sources/Resource/Localizable.xcstrings b/Application/DevLogApp/Sources/Resource/Localizable.xcstrings index fba808af..6a0c1aee 100644 --- a/Application/DevLogApp/Sources/Resource/Localizable.xcstrings +++ b/Application/DevLogApp/Sources/Resource/Localizable.xcstrings @@ -1595,6 +1595,40 @@ } } }, + "search_hash_guide_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type like #123 to find that Todo directly." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#123처럼 입력하면 해당 Todo를 바로 찾을 수 있습니다." + } + } + } + }, + "search_hash_guide_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a Todo number" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 번호를 입력해주세요" + } + } + } + }, "settings_account" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/DevLogPresentation/Sources/Search/SearchFeature.swift b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift index 2a26ebcb..1cbbc1af 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchFeature.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift @@ -57,6 +57,10 @@ struct SearchFeature { var shouldShowMoreWebPages: Bool { !showAllWebPages && contentsLimit < webPages.count } + + var isHashOnlyQuery: Bool { + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == "#" + } } enum Action: BindableAction, Equatable { @@ -108,7 +112,7 @@ struct SearchFeature { state.showAllTodos = false state.showAllWebPages = false let trimmed = state.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { + if trimmed.isEmpty || trimmed == "#" { state.webPages = [] state.todos = [] return Self.cancelSearchEffect(isLoading: state.isLoading) @@ -141,7 +145,7 @@ struct SearchFeature { return saveRecentQueriesEffect([]) case .store(.applySearchQuery(let query)): let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { + if trimmed.isEmpty || trimmed == "#" { state.webPages = [] state.todos = [] return Self.cancelSearchEffect(isLoading: state.isLoading) @@ -232,20 +236,16 @@ private extension SearchFeature { } func fetchEffect(_ query: String, isLoading: Bool) -> Effect { - let searchesTodoOnly = Self.searchesTodoOnly(query) + let skipsWebPages = query.hasPrefix("#") return .run { [fetchTodosUseCase, fetchWebPagesUseCase] send in do { async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) - async let webPageItems = Self.fetchWebPageItems( - query: query, - searchesTodoOnly: searchesTodoOnly, - fetchWebPagesUseCase: fetchWebPagesUseCase - ) + let webPages = skipsWebPages ? [] : try await fetchWebPagesUseCase.execute(query) let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } - let resolvedWebPageItems = try await webPageItems + let webPageItems = webPages.map { WebPageItem(from: $0) } await send(.store(.fetchTodos(todoItems))) - await send(.store(.fetchWebPage(resolvedWebPageItems))) + await send(.store(.fetchWebPage(webPageItems))) if isLoading { await send(.loading(.end(target: .default, mode: .immediate))) } @@ -273,23 +273,6 @@ private extension SearchFeature { } } - static func searchesTodoOnly(_ query: String) -> Bool { - query.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#") - } - - static func fetchWebPageItems( - query: String, - searchesTodoOnly: Bool, - fetchWebPagesUseCase: FetchWebPagesUseCase - ) async throws -> [WebPageItem] { - if searchesTodoOnly { - return [] - } - - let webPages = try await fetchWebPagesUseCase.execute(query) - return webPages.map { WebPageItem(from: $0) } - } - static func alertState() -> AlertState { AlertState { TextState(String(localized: "common_error_title")) diff --git a/Application/DevLogPresentation/Sources/Search/SearchView.swift b/Application/DevLogPresentation/Sources/Search/SearchView.swift index 80e4a637..4511d392 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchView.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchView.swift @@ -62,6 +62,8 @@ struct SearchView: View { recentQueries } } + } else if store.isHashOnlyQuery { + hashGuide } else if store.isLoading { LoadingView() } else if store.webPages.isEmpty && store.todos.isEmpty { @@ -104,6 +106,22 @@ struct SearchView: View { .frame(maxWidth: .infinity) } + private var hashGuide: some View { + VStack(spacing: 8) { + Spacer() + Text(String(localized: "search_hash_guide_title")) + .font(.headline) + .foregroundStyle(Color(.label)) + Text(String(localized: "search_hash_guide_message")) + .font(.subheadline) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.center) + Spacer() + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + } + private var searchResults: some View { VStack(alignment: .leading, spacing: 16) { if !store.todos.isEmpty { diff --git a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift index 8925ca5b..6bd8dec0 100644 --- a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift @@ -24,6 +24,7 @@ struct SearchStoreTestAdapter { var recentQueries: [String] { Array(store.state.recentQueries) } var showAllTodos: Bool { store.state.showAllTodos } var showAllWebPages: Bool { store.state.showAllWebPages } + var isHashOnlyQuery: Bool { store.state.isHashOnlyQuery } var alert: AlertState? { store.state.alert } init( @@ -100,7 +101,7 @@ struct SearchStoreTestAdapter { $0.searchQuery = query $0.showAllTodos = false $0.showAllWebPages = false - if trimmed.isEmpty { + if trimmed.isEmpty || $0.isHashOnlyQuery { $0.todos = [] $0.webPages = [] } @@ -108,7 +109,7 @@ struct SearchStoreTestAdapter { if wasLoading { await receiveEndLoading() } - if !trimmed.isEmpty { + if !trimmed.isEmpty && !store.state.isHashOnlyQuery { await receiveBeginLoading() } } diff --git a/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift index 8bfb8507..72caa2ef 100644 --- a/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTests.swift @@ -122,6 +122,30 @@ struct SearchFeatureTests { #expect(!adapter.isLoading) } + @Test("# 단독 검색어는 안내 상태로 전환하고 조회를 시작하지 않는다") + func 해시_단독_검색어는_안내_상태로_전환하고_조회를_시작하지_않는다() async { + let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! + let webPage = WebPageItem(from: makeSearchWebPage(urlString: "https://swift.org")) + let todoSpy = SearchFetchTodosUseCaseSpy() + let webSpy = SearchFetchWebPagesUseCaseSpy() + let adapter = SearchStoreTestAdapter( + initialTodos: [todo], + initialWebPages: [webPage], + isLoading: true, + fetchWebPagesUseCase: webSpy, + fetchTodosUseCase: todoSpy + ) + + await adapter.setSearchQuery("#") + + #expect(adapter.isHashOnlyQuery) + #expect(adapter.todos.isEmpty) + #expect(adapter.webPages.isEmpty) + #expect(!adapter.isLoading) + #expect(todoSpy.queries.isEmpty) + #expect(webSpy.queries.isEmpty) + } + @Test("# 검색어는 WebPage 조회를 생략하고 Todo만 반영한다") func 해시태그_검색어는_WebPage_조회를_생략하고_Todo만_반영한다() async { let todo = makeSearchTodo(id: "todo-1", title: "Issue")