Help adding search to the To Dos

I'm following the To Dos exercise in the tour of the the Composable Architecture. As an exercise, I'm modifying the the end of episode two to implement filtering the To Dos by a case insensitive contains if search term state exists.

I'm having trouble scoping the Store<AppState, AppAction> to a Store<SearchTerm?, SearchTermAction> as per the please-don't-judge-me-I'm-learning code below. Where I have written the comment // COMPILER ERROR HERE, the compiler tells me:

Cannot convert value of type 'Store<SearchTerm?, AppAction>' to expected argument type 'Store<SearchTerm?, SearchTermAction>'

Just like the TodoView, I would expect I can convert between the global AppAction and local SearchTermAction. Can anyone please suggest where I am going wrong? Thanks!

If I can overcome this hurdle, my next step is to pull back the search term reducer into the app reducer. I don't know if pull back is the right tool for composing the app reducer with the search term reducer.

import SwiftUI
import ComposableArchitecture

struct AppState: Equatable {
    var allTodos = [Todo]()
    var todos = [Todo]()
    
    var searchTerm: SearchTerm?
}

struct SearchTerm: Equatable {
    var filter: String
}

enum AppAction {
    case todo(index: Int, action: TodoAction)
    case searchTermChanged(SearchTerm?)
}

struct AppEnvironment {
    
}

struct Todo: Equatable, Identifiable {
    var description = ""
    var isComplete = false
    let id: UUID
}

enum TodoAction {
    case checkboxTapped
    case textFieldChanged(String)
}

struct TodoEnvironment {
    
}

let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment>() { state, action, environment -> Effect<TodoAction, Never> in
    switch action {
    case .checkboxTapped:
        state.isComplete.toggle()
        return .none
        
    case .textFieldChanged(let text):
        state.description = text
        return .none
    }
}

//let listReducer = Reducer<AppState, AppAction, AppEnvironment>() { state, action, environment -> Effect<AppAction, Never> in
//    switch action {
//    case .todo:
//        break
//    case .searchTermChanged(let term):
//        state.searchTerm = term
//
//        if let term = term {
//            state.todos = state.allTodos.filter { $0.description.localizedCaseInsensitiveContains(term.filter) }
//        } else {
//            state.todos = state.allTodos
//        }
//    }
//    return .none
//}

enum SearchTermAction {
    case filterTextChanged(String)
}

struct SearchTermEnvironment {
    
}

let searchTermReducer = Reducer<SearchTerm?, SearchTermAction, SearchTermEnvironment>() { state, action, environment -> Effect<SearchTermAction, Never> in
    switch action {
    case .filterTextChanged(let text):
        if text.isEmpty {
            state = nil
        }
        else {
            var term = state ?? SearchTerm(filter: "")
            term.filter = text
            state = term
        }
    }
    return .none
}

// Business logic defined as: how state is mutated and the effects of those mutations.
// Bind together: app state, actions and effects
// Responsibilites: perform mutations and return effects
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = todoReducer
    .forEach(state: \AppState.todos,
             action: /AppAction.todo(index:action:),
             environment: { _ -> TodoEnvironment in TodoEnvironment() })
//    .combined(with: searchTermReducer.pullback(state: \AppState.searchTerm,
//                                               action: /AppAction.searchTermChanged,
//                                               environment: { _ in SearchTermEnvironment() }))

struct ContentView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                SearchTermView(store: store.scope(state: \.searchTerm, // transform AppState to Optional<SearchTerm> state
                                                  action: AppAction.searchTermChanged)) // COMPILER ERROR HERE. transform Optional<SearchTerm> to AppState
                
                NavigationView {
                    WithViewStore(self.store) { viewStore in
                        List {
                            ForEachStore(self.store.scope(state: \.todos, action: AppAction.todo(index:action:)), content: TodoView.init(store:))
                        }
                    }
                    .navigationBarTitle("Todos")
                }
            }
        }
    }
}

struct SearchTermView: View {
    let store: Store<SearchTerm?, SearchTermAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            TextField("Title", text: viewStore.binding(get: { $0?.filter ?? "" }, send: SearchTermAction.filterTextChanged))
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
    }
}

struct TodoView: View {
    let store: Store<Todo, TodoAction>
    
    var body: some View {
        WithViewStore(store) { todoViewStore in
            HStack {
                Button(action: { todoViewStore.send(.checkboxTapped) }) {
                    Image(systemName: todoViewStore.isComplete ? "checkmark.square" : "square")
                }
                .buttonStyle(PlainButtonStyle())
                
                TextField("Untitled todo", text: todoViewStore.binding(get: \.description, send: TodoAction.textFieldChanged))
            }
            .foregroundColor(todoViewStore.isComplete ? .gray : nil)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(store: Store(initialState: AppState(allTodos: [
            Todo(description: "Milk", isComplete: false, id: UUID()),
            Todo(description: "Hand Soap", isComplete: true, id: UUID()),
            Todo(description: "Eggs", isComplete: false, id: UUID())
        ]), reducer: appReducer, environment: AppEnvironment()))
    }
}

I don't have much time right now to test the code but seeing the optional I would recommend that you use the optional reducer method to deal with that.
You also can use IfLetStore to simplify the view and make it work without optionals.

Thanks for the suggestion. I eliminated the optional all together to make life easier for myself but I'm left with this compiler error:

Cannot convert value of type 'Store<SearchTerm, AppAction>' to expected argument type 'Store<SearchTerm, SearchTermAction>'

I guess I'm not understanding something about the scope operator. How do I create a focused store on to the searchTerm from the AppState value with its own enumeration of actions?

To my eyes, it seems my code is essentially identical in spirit to 01-GettingStarted-Composition-TwoCounters.swift (where in my code above AppState is to SearchTerm what TwoCountersState is to CounterState) so I'm not sure what I'm missing.

You need to use action in there:

enum AppAction {
case todo(index: Int, action: TodoAction)
case searchTermChanged(SearchTermAction) }

By doing this you are telling to scope in that state using that action.

1 Like

Oh, thank you @grinder81! This was exactly what I was missing.

Here's my final code with the search term filters on both description and isComplete. Could anyone please recommend ways I could improve this code?

import SwiftUI
import ComposableArchitecture

struct AppState: Equatable {
    var allTodos = [Todo]()
    var todos = [Todo]()
    
    var searchTerm = SearchTerm(filter: "", isCompletedOnly: false)
}

enum AppAction {
    case todo(index: Int, action: TodoAction)
    case searchTermChanged(SearchTermAction)
}

struct AppEnvironment { }

struct Todo: Equatable, Identifiable {
    var description = ""
    var isComplete = false
    let id: UUID
}

enum TodoAction {
    case checkboxTapped
    case textFieldChanged(String)
}

struct TodoEnvironment { }

struct SearchTerm: Equatable {
    var filter: String
    var isCompletedOnly = false
}

enum SearchTermAction {
    case filterTextChanged(String)
    case toggleIsCompletedOnly
}

struct SearchTermEnvironment { }

let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment>() { state, action, environment -> Effect<TodoAction, Never> in
    switch action {
    case .checkboxTapped:
        state.isComplete.toggle()
        return .none
        
    case .textFieldChanged(let text):
        state.description = text
        return .none
    }
}

let listReducer = Reducer<AppState, AppAction, AppEnvironment>() { state, action, environment -> Effect<AppAction, Never> in
    switch action {
    case .todo:
        break
    case .searchTermChanged:
        let term = state.searchTerm
        state.todos = state.allTodos.filter { (term.filter.isEmpty || $0.description.localizedCaseInsensitiveContains(term.filter)) && (!term.isCompletedOnly || term.isCompletedOnly && $0.isComplete)
        }
    }
    return .none
}

let searchTermReducer = Reducer<SearchTerm, SearchTermAction, SearchTermEnvironment>() { state, action, environment -> Effect<SearchTermAction, Never> in
    switch action {
    case .filterTextChanged(let text):
        state.filter = text
        
    case .toggleIsCompletedOnly:
        state.isCompletedOnly.toggle()
    }
    return .none
}

// Business logic defined as: how state is mutated and the effects of those mutations.
// Bind together: app state, actions and effects
// Responsibilites: perform mutations and return effects
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = todoReducer
    .forEach(state: \AppState.todos,
             action: /AppAction.todo(index:action:),
             environment: { _ -> TodoEnvironment in TodoEnvironment() })
    // Order is important here:
    // the search term reducer must perform mutations to SearchTerm state before the list reducer mutates the AppState state.
    .combined(with: searchTermReducer.pullback(state: \AppState.searchTerm,
                                               action: /AppAction.searchTermChanged,
                                               environment: { _ in SearchTermEnvironment() }))
    .combined(with: listReducer)


struct ContentView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                SearchTermView(store: self.store.scope(state: \.searchTerm, action: AppAction.searchTermChanged))
                
                NavigationView {
                    WithViewStore(self.store) { viewStore in
                        List {
                            ForEachStore(self.store.scope(state: \.todos, action: AppAction.todo(index:action:)), content: TodoView.init(store:))
                        }
                    }
                    .navigationBarTitle("Todos")
                }
            }
        }
    }
}

struct SearchTermView: View {
    let store: Store<SearchTerm, SearchTermAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            HStack {
                TextField("Title", text: viewStore.binding(get: { $0.filter }, send: SearchTermAction.filterTextChanged))
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .onAppear {
                    viewStore.send(.filterTextChanged(viewStore.state.filter))
                }
                
                Toggle("Is Completed Only", isOn: viewStore.binding(get: { $0.isCompletedOnly }, send: SearchTermAction.toggleIsCompletedOnly))
            }
        }
    }
}

struct TodoView: View {
    let store: Store<Todo, TodoAction>
    
    var body: some View {
        WithViewStore(store) { todoViewStore in
            HStack {
                Button(action: { todoViewStore.send(.checkboxTapped) }) {
                    Image(systemName: todoViewStore.isComplete ? "checkmark.square" : "square")
                }
                .buttonStyle(PlainButtonStyle())
                
                TextField("Untitled todo", text: todoViewStore.binding(get: \.description, send: TodoAction.textFieldChanged))
            }
            .foregroundColor(todoViewStore.isComplete ? .gray : nil)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(store: Store(initialState: AppState(allTodos: [
            Todo(description: "Milk", isComplete: false, id: UUID()),
            Todo(description: "Hand Soap", isComplete: true, id: UUID()),
            Todo(description: "Eggs", isComplete: false, id: UUID())
        ]), reducer: appReducer, environment: AppEnvironment()))
    }
}