Skip to content

Commit

Permalink
Merge main into latest
Browse files Browse the repository at this point in the history
  • Loading branch information
github-actions[bot] authored Mar 31, 2024
2 parents d74f71e + d0d23ff commit c7ac609
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 25 deletions.
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ Let’s get into each of these steps.
To tell Wendy that you have a piece of data that needs to sync with a network API later, use `addTask`. Wendy will execute this task at a later time.

```swift
let groceryListItem = GroceryListItem(price: ..., name: ...)

let groceryListItem = GroceryListItem(price: ..., name: ...)

Wendy.shared.addTask(tag: "AddGroceryListItem", data: groceryListItem)

// Or, use an enum to avoid hard-coded strings:
Expand Down Expand Up @@ -167,16 +168,25 @@ func runTask(tag: String, data: Data?) async throws {

Done! You’re using Wendy 🎊!

# Event listeners
# Status changes in UI

Wendy tries to promote a positive user experience with offline-first mobile apps. One important step to this to communicating to your app user the status of their data. If a piece of data in the app has not yet synced successfully with the network API, your app should reflect this status in the UI. Using event listeners is one way to do that.
Wendy tries to promote a positive user experience with offline-first mobile apps. One important step to this to communicating to your app user the status of their data. If a piece of data in the app has not yet synced successfully with the network API, your app should reflect this status in the UI. Using event listeners is how you do that.

When you call `Wendy.shared.addTask()`, that function returns an ID back to you. That ID maps to the 1 task that you added to Wendy. With this ID, you can check the status of a task in Wendy.
Here is some code showing you how to add tasks and then attach listeners to it:

```swift
// Add a listener to Wendy for the task that got added.
// Note: Wendy keeps weak references to listeners. Keep a strong reference in your app.
WendyConfig.addTaskStatusListenerForTask(taskId, listener: self)
Wendy.shared.addTask(tag: "...", data: GroceryListItem(name: "onion", isProduce: true)
Wendy.shared.addTask(tag: "...", data: GroceryListItem(name: "crackers", isProduce: false)

// Now that we have added tasks to Wendy, we will ask Wendy to find some tasks for us and then we will attach a listener to
Wendy.shared.findTasks(containingAll: ["isProduce": true]).forEach { taskIds in
// Add a listener to Wendy for the task that got added.
// Note: Wendy keeps weak references to listeners. Keep a strong reference in your app.
taskIds.forEach { taskId in
WendyConfig.addTaskStatusListenerForTask(taskId, listener: self)
}
}
// When you use .findTasks(), you may need to re-run it after you add new tasks to Wendy. findTasks() gives you the list of tasks *at the time that you call it*.

// Here is an example of making a UIKit View a listener of a Wendy
// The UI changes depending on the state of the sync.
Expand All @@ -193,9 +203,12 @@ extension View: PendingTaskStatusListener {
func skipped(taskId: Double, reason: ReasonPendingTaskSkipped) {
self.text = "Skipped"
}

}
```

Besides listening for status changes of individual tasks, you can also listen to the entire queue of tasks and when the task running is running:
```swift
WendyConfig.addTaskRunnerListener(listener: listener)
```

It’s suggested to view the [Best practices doc][10] to learn more about making a great experience in your offline-first app.
Expand Down
11 changes: 11 additions & 0 deletions Source/Extensions/DataExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ public extension Data {
return DIGraph.shared.jsonAdapter.fromData(self)
}
}

internal extension Data {
func asDictionary() -> [String: AnyHashable] {
do {
return try JSONSerialization.jsonObject(with: self, options: []) as? [String: AnyHashable] ?? [:]
} catch {
return [:]
}
}
}

4 changes: 4 additions & 0 deletions Source/Extensions/PendingTask+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ public extension PendingTask {
func hasBeenAddedToWendy() -> Bool {
return taskId != nil
}

var dataAsDictionary: [String: AnyHashable] {
data?.asDictionary() ?? [:]
}
}
51 changes: 50 additions & 1 deletion Source/Wendy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final public class Wendy: Sendable {
// FileSystemQueueImpl.shared.load()
}

@discardableResult
public func addTask<Data: Codable>(tag: String, data: Data?, groupId: String? = nil) -> Double {
let addedTask = DIGraph.shared.pendingTasksManager.add(tag: tag, data: data, groupId: groupId)

Expand All @@ -44,10 +45,57 @@ final public class Wendy: Sendable {
return addedTask.taskId!
}

@discardableResult
public func addTask<Tag: RawRepresentable, Data: Codable>(tag: Tag, data: Data?, groupId: String? = nil) -> Double where Tag.RawValue == String {
self.addTask(tag: tag.rawValue, data: data, groupId: groupId)
}

/// Returns list of task IDs that contain *all* of the key/value pairs passed in the query.
public func findTasks(containingAll query: [String: any Sendable & Hashable]) async -> [Double] {
// Creating a Task because the getAllTasks() is currently not async. Creating a new Task is to try and make this more performant in case this function called on main thread.
return await Task {
let allTasks = DIGraph.shared.pendingTasksManager.getAllTasks()

return allTasks.filter { task in
let taskData = task.dataAsDictionary

// For every element in query, the task must contain everything in it.
return query.allSatisfy { key, value in
guard taskData.keys.contains(key) else { return false }

let queryValue = AnyHashable(value)
let taskValue = taskData[key]

return queryValue == taskValue
}
}.map { $0.taskId! }
}.value
}

/// Returns list of task IDs that contain *at least one* of the key/value pairs passed in the query.
public func findTasks(containingAny query: [String: any Sendable & Hashable]) async -> [Double] {
// Creating a Task because the getAllTasks() is currently not async. Creating a new Task is to try and make this more performant in case this function called on main thread.
return await Task {
let allTasks = DIGraph.shared.pendingTasksManager.getAllTasks()

return allTasks.filter { task in
let taskData = task.dataAsDictionary

// For every element in query, the task must contain everything in it.
let didFindAMatch = query.first(where: { key, value in
guard taskData.keys.contains(key) else { return false }

let queryValue = AnyHashable(value)
let taskValue = taskData[key]

return queryValue == taskValue
}) != nil

return didFindAMatch
}.map { $0.taskId! }
}.value
}

/**
* Note: This function is for internal use only. There are no checks to make sure that it exists and stuff. It's assumed you know what you're doing.

Expand Down Expand Up @@ -79,7 +127,7 @@ final public class Wendy: Sendable {

return result
}

public func runTask(_ taskId: Double, onComplete: (@Sendable (TaskRunResult) -> Void)?) {
Task {
let result = await self.runTask(taskId)
Expand All @@ -88,6 +136,7 @@ final public class Wendy: Sendable {
}
}

@discardableResult
public func runTasks(filter: RunAllTasksFilter? = nil) async -> PendingTasksRunnerResult {
let result = await pendingTasksRunner.runAllTasks(filter: filter)

Expand Down
102 changes: 86 additions & 16 deletions Tests/WendyIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ class WendyIntegrationTests: TestClass {
case foo
}

let _ = Wendy.shared.addTask(tag: "string-tag", dataId: "string-dataId")
let _ = Wendy.shared.addTask(tag: Tag.foo, dataId: "enum-dataId")
Wendy.shared.addTask(tag: "string-tag", dataId: "string-dataId")
Wendy.shared.addTask(tag: Tag.foo, dataId: "enum-dataId")

taskRunnerStub.runTaskClosure = { tag, data in
guard let dataId: String = data?.wendyDecode() else {
Expand Down Expand Up @@ -69,7 +69,7 @@ class WendyIntegrationTests: TestClass {

let givenTag = AsyncTasks.foo

let _ = Wendy.shared.addTask(tag: givenTag, data: "dataId")
Wendy.shared.addTask(tag: givenTag, data: "dataId")

taskRunnerStub.runTaskClosure = { tag, _ in
let actualTag = AsyncTasks(rawValue: tag)
Expand All @@ -90,7 +90,7 @@ class WendyIntegrationTests: TestClass {
}
}

let _ = Wendy.shared.addTask(tag: "foo", data: CodableObject(foo: .random, nested: .init(bar: .random)))
Wendy.shared.addTask(tag: "foo", data: CodableObject(foo: .random, nested: .init(bar: .random)))

taskRunnerStub.runTaskClosure = { tag, data in
switch tag {
Expand Down Expand Up @@ -119,7 +119,7 @@ class WendyIntegrationTests: TestClass {
.success(nil)
]

let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")

let runTasksResults = await runAllTasks()

Expand All @@ -138,7 +138,7 @@ class WendyIntegrationTests: TestClass {
.failure(ErrorForTesting.foo)
]

let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")

let runTasksResults = await runAllTasks()

Expand All @@ -151,6 +151,76 @@ class WendyIntegrationTests: TestClass {
XCTAssertEqual(runTasks2ndTimeResults.numberTasksRun, 1)
}

// MARK: Find tasks, using query

struct Animal: Codable {
let name: String
let doesHaveLegs: Bool
let doesHaveWings: Bool
let doesHaveFur: Bool
}

let bird = Animal(name: "bird", doesHaveLegs: true, doesHaveWings: true, doesHaveFur: false)
let bear = Animal(name: "bear", doesHaveLegs: true, doesHaveWings: true, doesHaveFur: true)

func test_findTasksContainingAll_givenQuery_expectOnlyFindTasksContainingAllQueryTerms() async {
Wendy.shared.addTask(
tag: .random,
data: bird)
let bearTaskId = Wendy.shared.addTask(
tag: .random,
data: bear)

// Our query is specifically looking for animals with fur. The Animal *must* have fur.
// We expect to only get bear, even though the bird does have legs.
let actual = await Wendy.shared.findTasks(containingAll: ["doesHaveLegs": true, "doesHaveFur": true])

XCTAssertEqual(actual, [bearTaskId])
}

func test_findTasksContainingAll_givenLongQuery_expectNoTasksReturned() async {
Wendy.shared.addTask(
tag: .random,
data: bird)
Wendy.shared.addTask(
tag: .random,
data: bear)

// The bear matches the query, except there is a query term that the bear data type does not define.
// Therefore, we expect to not match any animals.
let actual = await Wendy.shared.findTasks(containingAll: ["doesHaveLegs": true, "doesHaveFur": true, "doesHaveHorns": true])

XCTAssertEqual(actual, [])
}

func test_findTasksContainingAny_expectValueOfQueryTermToMatchTasks() async {
Wendy.shared.addTask(
tag: .random,
data: bird)
Wendy.shared.addTask(
tag: .random,
data: bear)

// Test that the values in query are tested.
let actual = await Wendy.shared.findTasks(containingAny: ["doesHaveLegs": false])

XCTAssertEqual(actual, [])
}

func test_findTasksContainingAny_givenQuery_expectFindTasksContainingAnyQueryTerms() async {
let birdTaskId = Wendy.shared.addTask(
tag: .random,
data: bird)
let bearTaskId = Wendy.shared.addTask(
tag: .random,
data: bear)

// Our query is not very specific. As long as you have legs or have fur, you will be matched. You do not need to have both.
let actual = await Wendy.shared.findTasks(containingAny: ["doesHaveLegs": true, "doesHaveFur": true])

XCTAssertEqual(actual, [birdTaskId, bearTaskId])
}

// MARK: listeners

func test_runnerListener_expectAllCallbacksCalled() async {
Expand All @@ -162,8 +232,8 @@ class WendyIntegrationTests: TestClass {
]

XCTAssertEqual(listener.newTaskAddedCallCount, 0)
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")
XCTAssertEqual(listener.newTaskAddedCallCount, 2)

XCTAssertEqual(listener.runningTaskCallCount, 0)
Expand Down Expand Up @@ -211,8 +281,8 @@ class WendyIntegrationTests: TestClass {
.failure(ErrorForTesting.foo)
]

let _ = Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")

let runTasksResults = await runAllTasks()

Expand All @@ -227,8 +297,8 @@ class WendyIntegrationTests: TestClass {
.success(nil)
]

let _ = Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")
Wendy.shared.addTask(tag: "tag", data: "dataId", groupId: "groupName")

let runTasksResults = await runAllTasks()

Expand All @@ -245,12 +315,12 @@ class WendyIntegrationTests: TestClass {
expectToAddTasks.expectedFulfillmentCount = 2

Task {
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")
expectToAddTasks.fulfill()
}

Task {
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")
expectToAddTasks.fulfill()
}

Expand Down Expand Up @@ -337,8 +407,8 @@ class WendyIntegrationTests: TestClass {
// MARK: clear

func test_clearTasks_givenTasksAdded_expectAllCancelAndDelete() async {
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
let _ = Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")
Wendy.shared.addTask(tag: "tag", data: "dataId")

Wendy.shared.clear()

Expand Down

0 comments on commit c7ac609

Please sign in to comment.