PM-11147: Add import logins view (#1019)

This commit is contained in:
Matt Czech 2024-10-10 13:50:28 -05:00 committed by GitHub
parent 317cb918ef
commit 621d9019be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 450 additions and 2 deletions

View File

@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "import.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "import-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -1007,3 +1007,11 @@
"NewLogin" = "New login";
"ImportSavedLogins" = "Import saved logins";
"ImportSavedLoginsDescriptionLong" = "Use a computer to import logins from an existing password manager.";
"ImportLogins" = "Import logins";
"GiveYourVaultAHeadStart" = "Give your vault a head start";
"ImportLoginsDescriptionLong" = "From your computer, follow these instructions to export saved passwords from your browser or other password manager. Then, safely import them to Bitwarden.";
"ImportLoginsLater" = "Import logins later";
"ImportLoginsLaterQuestion" = "Import logins later?";
"YouCanReturnToCompleteThisStepAnytimeInVaultUnderSettings" = "You can return to complete this step anytime in Vault under Settings.";
"DoYouHaveAComputerAvailable" = "Do you have a computer available?";
"DoYouHaveAComputerAvailableDescriptionLong" = "The following instructions will guide you through importing logins from your desktop or laptop computer.";

View File

@ -91,6 +91,43 @@ extension Alert {
)
}
/// An alert asking the user if they have a computer available to import logins.
///
/// - Parameter action: The action taken when the user taps on continue.
/// - Returns: An alert asking the user if they have a computer available to import logins.
///
static func importLoginsComputerAvailable(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.doYouHaveAComputerAvailable,
message: Localizations.doYouHaveAComputerAvailableDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
await action()
},
]
)
}
/// An alert confirming that the user wants to import logins later in settings.
///
/// - Parameter action: The action taken when the user taps on Confirm to import logins later
/// in settings.
/// - Returns: An alert confirming that the user wants to import logins later in settings.
///
static func importLoginsLater(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.importLoginsLaterQuestion,
message: Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.confirm, style: .default) { _ in
await action()
},
]
)
}
/// An alert presenting the user with more options for a vault list item.
///
/// - Parameters:

View File

@ -61,6 +61,46 @@ class AlertVaultTests: BitwardenTestCase {
XCTAssertEqual(subject.alertActions[3].style, .cancel)
}
/// `importLoginsComputerAvailable(action:)` constructs an `Alert` that confirms that the user
/// has a computer available to import logins.
func test_importLoginsComputerAvailable() async throws {
var actionCalled = false
let subject = Alert.importLoginsComputerAvailable { actionCalled = true }
XCTAssertEqual(subject.title, Localizations.doYouHaveAComputerAvailable)
XCTAssertEqual(subject.message, Localizations.doYouHaveAComputerAvailableDescriptionLong)
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
XCTAssertEqual(subject.alertActions[0].style, .cancel)
XCTAssertEqual(subject.alertActions[1].title, Localizations.continue)
XCTAssertEqual(subject.alertActions[1].style, .default)
try await subject.tapCancel()
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.continue)
XCTAssertTrue(actionCalled)
}
/// `static importLoginsLater(action:)` constructs an `Alert` that confirms that the user
/// wants to import logins later in settings.
func test_importLoginsLater() async throws {
var actionCalled = false
let subject = Alert.importLoginsLater { actionCalled = true }
XCTAssertEqual(subject.title, Localizations.importLoginsLaterQuestion)
XCTAssertEqual(subject.message, Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings)
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
XCTAssertEqual(subject.alertActions[0].style, .cancel)
XCTAssertEqual(subject.alertActions[1].title, Localizations.confirm)
XCTAssertEqual(subject.alertActions[1].style, .default)
try await subject.tapCancel()
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.confirm)
XCTAssertTrue(actionCalled)
}
/// `passwordAutofillInformation()` constructs an `Alert` that informs the user about password
/// autofill.
func test_passwordAutofillInformation() {

View File

@ -0,0 +1,11 @@
// MARK: - ImportLoginsAction
/// Actions that can be processed by a `ImportLoginsProcessor`.
///
enum ImportLoginsAction: Equatable {
/// Dismiss the view.
case dismiss
/// The get started button was tapped.
case getStarted
}

View File

@ -0,0 +1,8 @@
// MARK: - ImportLoginsEffect
/// Effects handled by the `ImportLoginsProcessor`.
///
enum ImportLoginsEffect: Equatable {
/// The import logins button was tapped.
case importLoginsLater
}

View File

@ -0,0 +1,78 @@
// MARK: - ImportLoginsProcessor
/// The processor used to manage state and handle actions for the import logins screen.
///
class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect> {
// MARK: Types
typealias Services = HasErrorReporter
& HasStateService
// MARK: Private Properties
/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<VaultRoute, AuthAction>
/// The services used by this processor.
private let services: Services
// MARK: Initialization
/// Creates a new `ImportLoginsProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - services: The services required by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<VaultRoute, AuthAction>,
services: Services,
state: ImportLoginsState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: ImportLoginsEffect) async {
switch effect {
case .importLoginsLater:
showImportLoginsLaterAlert()
}
}
override func receive(_ action: ImportLoginsAction) {
switch action {
case .dismiss:
coordinator.navigate(to: .dismiss)
case .getStarted:
showGetStartAlert()
}
}
// MARK: Private
/// Shows the alert confirming the user wants to get started on importing logins.
///
private func showGetStartAlert() {
coordinator.showAlert(.importLoginsComputerAvailable {
// TODO: PM-11150 Show step 1
})
}
/// Shows the alert confirming the user wants to import logins later.
///
private func showImportLoginsLaterAlert() {
coordinator.showAlert(.importLoginsLater {
do {
try await self.services.stateService.setAccountSetupImportLogins(.setUpLater)
} catch {
self.services.errorReporter.log(error: error)
}
self.coordinator.navigate(to: .dismiss)
})
}
}

View File

@ -0,0 +1,89 @@
import XCTest
@testable import BitwardenShared
class ImportLoginsProcessorTests: BitwardenTestCase {
// MARK: Properties
var coordinator: MockCoordinator<VaultRoute, AuthAction>!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: ImportLoginsProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
stateService = MockStateService()
subject = ImportLoginsProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
errorReporter: errorReporter,
stateService: stateService
),
state: ImportLoginsState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
errorReporter = nil
stateService = nil
subject = nil
}
// MARK: Tests
/// `perform(_:)` with `.importLoginsLater` shows an alert for confirming the user wants to
/// import logins later.
@MainActor
func test_perform_importLoginsLater() async throws {
stateService.activeAccount = .fixture()
stateService.accountSetupImportLogins["1"] = .incomplete
await subject.perform(.importLoginsLater)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsLater {})
try await alert.tapAction(title: Localizations.confirm)
XCTAssertEqual(coordinator.routes, [.dismiss])
XCTAssertEqual(stateService.accountSetupImportLogins["1"], .setUpLater)
}
/// `perform(_:)` with `.importLoginsLater` logs an error if one occurs.
@MainActor
func test_perform_importLoginsLater_error() async throws {
await subject.perform(.importLoginsLater)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsLater {})
try await alert.tapAction(title: Localizations.confirm)
XCTAssertEqual(coordinator.routes, [.dismiss])
XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount])
}
/// `receive(_:)` with `.dismiss` dismisses the view.
@MainActor
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `receive(_:)` with `.getStarted` shows an alert for the user to confirm they have a
/// computer available.
@MainActor
func test_receive_getStarted() throws {
subject.receive(.getStarted)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsComputerAvailable {})
}
}

View File

@ -0,0 +1,7 @@
// MARK: - ImportLoginsState
/// An object that defines the current state of a `ImportLoginsView`.
///
struct ImportLoginsState: Equatable, Sendable {
// MARK: Properties
}

View File

@ -0,0 +1,54 @@
import SwiftUI
// MARK: - ImportLoginsView
/// A view that instructs the user how to import their logins from another password manager.
///
struct ImportLoginsView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect>
// MARK: View
var body: some View {
VStack(spacing: 32) {
PageHeaderView(
image: Asset.Images.import,
title: Localizations.giveYourVaultAHeadStart,
message: Localizations.importLoginsDescriptionLong
)
VStack(spacing: 12) {
Button(Localizations.getStarted) {
store.send(.getStarted)
}
.buttonStyle(.primary())
AsyncButton(Localizations.importLoginsLater) {
await store.perform(.importLoginsLater)
}
.buttonStyle(.transparent)
}
}
.padding(.top, 8)
.frame(maxWidth: .infinity)
.scrollView()
.navigationBar(title: Localizations.importLogins, titleDisplayMode: .inline)
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview {
ImportLoginsView(store: Store(processor: StateProcessor(state: ImportLoginsState())))
.navStackWrapped
}
#endif

View File

@ -0,0 +1,66 @@
import SnapshotTesting
import ViewInspector
import XCTest
@testable import BitwardenShared
class ImportLoginsViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect>!
var subject: ImportLoginsView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: ImportLoginsState())
subject = ImportLoginsView(store: Store(processor: processor))
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the dismiss button dispatches the `dismiss` action.
@MainActor
func test_dismiss_tap() throws {
let button = try subject.inspect().find(button: Localizations.cancel)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
}
/// Tapping the get started button dispatches the `getStarted` action.
@MainActor
func test_getStarted_tap() throws {
let button = try subject.inspect().find(button: Localizations.getStarted)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .getStarted)
}
/// Tapping the import logins later button performs the `importLoginsLater` effect.
@MainActor
func test_importLoginsLater_tap() async throws {
let button = try subject.inspect().find(asyncButton: Localizations.importLoginsLater)
try await button.tap()
XCTAssertEqual(processor.effects.last, .importLoginsLater)
}
// MARK: Snapshots
/// The import logins view renders correctly.
@MainActor
func test_snapshot_importLogins() {
assertSnapshots(
of: subject.navStackWrapped,
as: [.defaultPortrait, .defaultPortraitDark, .tallPortraitAX5(heightMultiple: 2), .defaultLandscape]
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -171,8 +171,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
case let .group(group, filter):
showGroup(group, filter: filter)
case .importLogins:
// TODO: PM-11147 Show import logins
break
showImportLogins()
case .list:
showList()
case let .loginRequest(loginRequest):
@ -244,6 +243,21 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
)
}
/// Shows the import login items screen.
///
private func showImportLogins() {
let processor = ImportLoginsProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: ImportLoginsState()
)
let view = ImportLoginsView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = .fullScreen
stackNavigator?.present(navigationController)
}
/// Shows the vault list screen.
///
private func showList() {

View File

@ -126,6 +126,17 @@ class VaultCoordinatorTests: BitwardenTestCase {
XCTAssertEqual(view.store.state.vaultFilterType, .allVaults)
}
/// `navigate(to:)` with `.importLogins` presents the import logins view onto the stack navigator.
@MainActor
func test_navigateTo_importLogins() throws {
subject.navigate(to: .importLogins)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
let navigationController = try XCTUnwrap(action.view as? UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ImportLoginsView>)
}
/// `navigate(to:)` with `.list` pushes the vault list view onto the stack navigator.
@MainActor
func test_navigateTo_list_withoutPresented() throws {