Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
coordinator.navigate(to: .editCollections(state.cipher), context: self)
case .moveToOrganization:
coordinator.navigate(to: .moveToOrganization(state.cipher), context: self)
case .restore:
// No-op: the restore button isn't shown when editing an item.
break
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ struct AddEditItemView: View {
isCollectionsEnabled: store.state.canAssignToCollection,
isDeleteEnabled: store.state.canBeDeleted,
isMoveToOrganizationEnabled: store.state.canMoveToOrganization,
isRestoreEnabled: false,
store: store.child(
state: { _ in },
mapAction: { .morePressed($0) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

/// Actions that can be handled by an `AddEditItemProcessor` and `ViewItemProcessor`.
enum VaultItemManagementMenuAction: Equatable {
/// The attachments option was pressed.
/// The attachments option was tapped.
case attachments

/// The clone option was pressed.
/// The clone option was tapped.
case clone

/// The collections option was pressed.
/// The collections option was tapped.
case editCollections

/// The move to organization option was pressed.
/// The move to organization option was tapped.
case moveToOrganization

/// The restore option was tapped.
case restore
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class VaultItemManagementMenuViewTests: BitwardenTestCase {
isCollectionsEnabled: true,
isDeleteEnabled: true,
isMoveToOrganizationEnabled: true,
isRestoreEnabled: true,
store: store,
)
}
Expand Down Expand Up @@ -77,4 +78,12 @@ class VaultItemManagementMenuViewTests: BitwardenTestCase {
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .moveToOrganization)
}

/// Tapping the restore option dispatches the `.restore` action.
@MainActor
func test_restore_tap() throws {
let button = try subject.inspect().find(button: Localizations.restore)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .restore)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,21 @@ struct VaultItemManagementMenuView: View {
/// The flag for whether to show the move to organization options.
let isMoveToOrganizationEnabled: Bool

/// The flag for whether to show the restore option.
let isRestoreEnabled: Bool

/// The `Store` for this view.
@ObservedObject var store: Store<Void, VaultItemManagementMenuAction, VaultItemManagementMenuEffect>

var body: some View {
Menu {
if isRestoreEnabled {
Button(Localizations.restore) {
store.send(.restore)
}
.accessibilityIdentifier("RestoreButton")
}

Button(Localizations.attachments) {
store.send(.attachments)
}
Expand Down Expand Up @@ -68,6 +78,7 @@ struct VaultItemManagementMenuView: View {
isCollectionsEnabled: true,
isDeleteEnabled: true,
isMoveToOrganizationEnabled: true,
isRestoreEnabled: true,
store: Store(
processor: StateProcessor(
state: (),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ enum ViewItemEffect: Equatable {
/// The delete option was pressed.
case deletePressed

/// The restore button was pressed.
case restorePressed

/// Toggles displaying one or multiple collections.
case toggleDisplayMultipleCollections

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,6 @@ final class ViewItemProcessor: StateProcessor<ViewItemState, ViewItemAction, Vie
} else {
await showPermanentDeleteConfirmation(cipherState.cipher)
}
case .restorePressed:
await showRestoreItemConfirmation()
case .toggleDisplayMultipleCollections:
toggleDisplayMultipleCollections()
case .totpCodeExpired:
Expand Down Expand Up @@ -398,6 +396,8 @@ private extension ViewItemProcessor {
coordinator.navigate(to: .editCollections(cipher), context: self)
case .moveToOrganization:
coordinator.navigate(to: .moveToOrganization(cipher), context: self)
case .restore:
showRestoreItemConfirmation()
}
}

Expand Down Expand Up @@ -466,7 +466,7 @@ private extension ViewItemProcessor {

/// Shows restore cipher confirmation alert.
///
private func showRestoreItemConfirmation() async {
private func showRestoreItemConfirmation() {
guard case let .data(cipherState) = state.loadingState else { return }
let alert = Alert(
title: Localizations.doYouReallyWantToRestoreCipher,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -883,73 +883,6 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
XCTAssertTrue(delegate.itemDeletedCalled)
}

/// `perform(_:)` with `.restorePressed` presents the confirmation alert before restore the item and displays
/// generic error alert if restoring fails.
@MainActor
func test_perform_restorePressed_genericError() async throws {
let cipherState = CipherItemState(
existing: CipherView.loginFixture(deletedDate: .now, id: "123"),
hasPremium: false,
)!

let state = ViewItemState(
loadingState: .data(cipherState),
)
subject.state = state
struct TestError: Error, Equatable {}
vaultRepository.restoreCipherResult = .failure(TestError())
await subject.perform(.restorePressed)
// Ensure the alert is shown.
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher)
XCTAssertNil(alert?.message)

// Tap the "Yes" button on the alert.
let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes }))
await action.handler?(action, [])

// Ensure the generic error alert is displayed.
let errorAlert = try XCTUnwrap(coordinator.errorAlertsShown.last)
XCTAssertEqual(errorAlert as? TestError, TestError())
XCTAssertEqual(errorReporter.errors.first as? TestError, TestError())
}

/// `perform(_:)` with `.restorePressed` presents the confirmation alert before restore the item and displays
/// toast if restoring succeeds.
@MainActor
func test_perform_restorePressed_success() async throws {
let cipherState = CipherItemState(
existing: CipherView.loginFixture(deletedDate: .now, id: "123"),
hasPremium: false,
)!

let state = ViewItemState(
loadingState: .data(cipherState),
)
subject.state = state
vaultRepository.softDeleteCipherResult = .success(())
await subject.perform(.restorePressed)
// Ensure the alert is shown.
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher)
XCTAssertNil(alert?.message)

// Tap the "Yes" button on the alert.
let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes }))
await action.handler?(action, [])

XCTAssertNil(errorReporter.errors.first)
// Ensure the cipher is deleted and the view is dismissed.
XCTAssertEqual(vaultRepository.restoredCipher.last?.id, "123")
var dismissAction: DismissAction?
if case let .dismiss(onDismiss) = coordinator.routes.last {
dismissAction = onDismiss
}
XCTAssertNotNil(dismissAction)
dismissAction?.action()
XCTAssertTrue(delegate.itemRestoredCalled)
}

/// `perform(_:)` with `.toggleDisplayMultipleCollections` doesn't update the state if
/// loadingState is not `.data(:)`
@MainActor
Expand Down Expand Up @@ -1328,6 +1261,73 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
XCTAssertTrue(coordinator.contexts.last as? ViewItemProcessor === subject)
}

/// `receive(_:)` with `.morePressed(.restore)` presents the confirmation alert before restoring
/// the item and displays generic error alert if restoring fails.
@MainActor
func test_receive_morePressed_restore_genericError() async throws {
let cipherState = CipherItemState(
existing: CipherView.loginFixture(deletedDate: .now, id: "123"),
hasPremium: false,
)!

let state = ViewItemState(
loadingState: .data(cipherState),
)
subject.state = state
struct TestError: Error, Equatable {}
vaultRepository.restoreCipherResult = .failure(TestError())
subject.receive(.morePressed(.restore))
// Ensure the alert is shown.
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher)
XCTAssertNil(alert?.message)

// Tap the "Yes" button on the alert.
let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes }))
await action.handler?(action, [])

// Ensure the generic error alert is displayed.
let errorAlert = try XCTUnwrap(coordinator.errorAlertsShown.last)
XCTAssertEqual(errorAlert as? TestError, TestError())
XCTAssertEqual(errorReporter.errors.first as? TestError, TestError())
}

/// `receive(_:)` with `.morePressed(.restore)` presents the confirmation alert before restoring
/// the item and displays toast if restoring succeeds.
@MainActor
func test_receive_morePressed_restore_success() async throws {
let cipherState = CipherItemState(
existing: CipherView.loginFixture(deletedDate: .now, id: "123"),
hasPremium: false,
)!

let state = ViewItemState(
loadingState: .data(cipherState),
)
subject.state = state
vaultRepository.softDeleteCipherResult = .success(())
subject.receive(.morePressed(.restore))
// Ensure the alert is shown.
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher)
XCTAssertNil(alert?.message)

// Tap the "Yes" button on the alert.
let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes }))
await action.handler?(action, [])

XCTAssertNil(errorReporter.errors.first)
// Ensure the cipher is deleted and the view is dismissed.
XCTAssertEqual(vaultRepository.restoredCipher.last?.id, "123")
var dismissAction: DismissAction?
if case let .dismiss(onDismiss) = coordinator.routes.last {
dismissAction = onDismiss
}
XCTAssertNotNil(dismissAction)
dismissAction?.action()
XCTAssertTrue(delegate.itemRestoredCalled)
}

/// `receive` with `.passwordHistoryPressed` navigates to the password history view.
@MainActor
func test_receive_passwordHistoryPressed() {
Expand Down
12 changes: 3 additions & 9 deletions BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,19 @@ struct ViewItemView: View {
additionalBottomPadding: FloatingActionButton.bottomOffsetPadding,
)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
closeToolbarButton {
store.send(.dismissPressed)
}
}

ToolbarItemGroup(placement: .navigationBarTrailing) {
if isRestoredEnabled {
toolbarButton(Localizations.restore) {
await store.perform(.restorePressed)
}
.accessibilityIdentifier("RestoreButton")
}

ToolbarItemGroup(placement: .topBarTrailing) {
VaultItemManagementMenuView(
isCloneEnabled: store.state.canClone,
isCollectionsEnabled: isCollectionsEnabled,
isDeleteEnabled: isDeleteEnabled,
isMoveToOrganizationEnabled: isMoveToOrganizationEnabled,
isRestoreEnabled: isRestoredEnabled,
store: store.child(
state: { _ in },
mapAction: { .morePressed($0) },
Expand Down