diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift index 1389a5fef2d..c09635fca3b 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift @@ -488,7 +488,7 @@ extension OrderDetailsViewModel { let customFieldsView = UIHostingController( rootView: CustomFieldsListView( isEditable: featureFlagService.isFeatureFlagEnabled(.viewEditCustomFieldsInProductsAndOrders), - customFields: customFields)) + viewModel: CustomFieldsListViewModel(customFields: customFields))) viewController.present(customFieldsView, animated: true) case .seeReceipt: let countryCode = configurationLoader.configuration.countryCode diff --git a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift index 11b0c8f6523..a156e46ba30 100644 --- a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift +++ b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift @@ -2,53 +2,62 @@ import SwiftUI struct CustomFieldsListView: View { @Environment(\.presentationMode) var presentationMode + @ObservedObject private var viewModel: CustomFieldsListViewModel let isEditable: Bool - let customFields: [CustomFieldViewModel] + + init(isEditable: Bool, + viewModel: CustomFieldsListViewModel) { + self.isEditable = isEditable + self.viewModel = viewModel + } var body: some View { NavigationView { - GeometryReader { geometry in - ScrollView { - VStack(alignment: .leading) { - ForEach(customFields) { customField in - if isEditable { - NavigationLink(destination: CustomFieldEditorView(key: customField.title, - value: customField.content) - ) { - CustomFieldRow(isEditable: true, - title: customField.title, - content: customField.content.removedHTMLTags, - contentURL: customField.contentURL) - } - } - else { - CustomFieldRow(isEditable: false, - title: customField.title, - content: customField.content.removedHTMLTags, - contentURL: customField.contentURL) - } - - Divider() - .padding(.leading) - } + List(viewModel.combinedList) { customField in + if isEditable { + NavigationLink(destination: CustomFieldEditorView(key: customField.key, value: customField.value)) { + CustomFieldRow(isEditable: true, + title: customField.key, + content: customField.value.removedHTMLTags, + contentURL: nil) } - .padding(.horizontal, insets: geometry.safeAreaInsets) - .background(Color(.listForeground(modal: false))) + } else { + CustomFieldRow(isEditable: false, + title: customField.key, + content: customField.value.removedHTMLTags, + contentURL: nil) } - .background(Color(.listBackground)) - .ignoresSafeArea(edges: .horizontal) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(action: { - presentationMode.wrappedValue.dismiss() - }, label: { - Image(uiImage: .closeButton) - }) + } + .navigationTitle(Localization.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { + presentationMode.wrappedValue.dismiss() + }, label: { + Image(uiImage: .closeButton) + }) + } + + ToolbarItem(placement: .navigationBarTrailing) { + if isEditable { + HStack { + Button { + // todo-13493: add save handling + } label: { + Text("Save") // todo-13493: set String to be translatable + } + .disabled(!viewModel.hasChanges) + Button(action: { + // todo-13493: add addition handling + }, label: { + Image(systemName: "plus") + .renderingMode(.template) + }) + } } } - .navigationTitle(Localization.title) - .navigationBarTitleDisplayMode(.inline) } } .wooNavigationBarStyle() @@ -105,15 +114,6 @@ private struct CustomFieldRow: View { .footnoteStyle() .lineLimit(isEditable ? 2 : nil) } - }.padding([.leading, .trailing], Constants.vStackPadding) - - Spacer() - - if isEditable { - // Chevron icon - Image(uiImage: .chevronImage) - .flipsForRightToLeftLayoutDirection(true) - .foregroundStyle(Color(.textTertiary)) } } .padding(Constants.hStackPadding) @@ -144,11 +144,13 @@ private extension CustomFieldRow { struct OrderCustomFieldsDetails_Previews: PreviewProvider { static var previews: some View { CustomFieldsListView( - isEditable: false, - customFields: [ - CustomFieldViewModel(id: 0, title: "First Title", content: "First Content"), - CustomFieldViewModel(id: 1, title: "Second Title", content: "Second Content", contentURL: URL(string: "https://woocommerce.com/")) - ]) + isEditable: true, + viewModel: CustomFieldsListViewModel( + customFields: [ + CustomFieldViewModel(id: 0, title: "First Title", content: "First Content"), + CustomFieldViewModel(id: 1, title: "Second Title", content: "Second Content", contentURL: URL(string: "https://woocommerce.com/")) + ]) + ) } } diff --git a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListViewModel.swift b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListViewModel.swift new file mode 100644 index 00000000000..be1aae61922 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListViewModel.swift @@ -0,0 +1,110 @@ +import Foundation + +final class CustomFieldsListViewModel: ObservableObject { + private let originalCustomFields: [CustomFieldViewModel] + + var shouldShowErrorState: Bool { + savingError != nil + } + + @Published private(set) var savingError: Error? + @Published private(set) var combinedList: [CustomFieldUI] = [] + + @Published private var editedFields: [CustomFieldUI] = [] + @Published private var addedFields: [CustomFieldUI] = [] + var hasChanges: Bool { + !editedFields.isEmpty || !addedFields.isEmpty + } + + init(customFields: [CustomFieldViewModel]) { + self.originalCustomFields = customFields + updateCombinedList() + } +} + +// MARK: - Items actions +extension CustomFieldsListViewModel { + /// Params: + /// - index: The index of field to be edited, taken from the `combinedList` array + /// - newField: The new content for the custom field in question + func editField(at index: Int, newField: CustomFieldUI) { + guard index >= 0 && index < combinedList.count else { + DDLogError("⛔️ Error: Invalid index for editing a custom field") + return + } + + let oldField = combinedList[index] + if newField.fieldId == nil { + // When editing a field that has no id yet, it means the field has only been added locally. + editLocallyAddedField(oldField: oldField, newField: newField) + } else { + if let existingId = oldField.fieldId { + editExistingField(idToEdit: existingId, newField: newField) + } else { + DDLogError("⛔️ Error: Trying to edit an existing field but it has no id. It might be the wrong field to edit.") + } + } + + updateCombinedList() + } + + func addField(_ field: CustomFieldUI) { + addedFields.append(field) + updateCombinedList() + } +} + +private extension CustomFieldsListViewModel { + func editLocallyAddedField(oldField: CustomFieldUI, newField: CustomFieldUI) { + if let index = addedFields.firstIndex(where: { $0.key == oldField.key }) { + addedFields[index] = newField + } else { + // This shouldn't happen in normal flow, but logging just in case + DDLogError("⛔️ Error: Trying to edit a locally added field that doesn't exist in addedFields") + } + } + + /// Checking by id when editing an existing field since existing fields will always have them. + func editExistingField(idToEdit: Int64, newField: CustomFieldUI) { + guard idToEdit == newField.fieldId else { + DDLogError("⛔️ Error: Trying to edit existing field but supplied new id is different.") + return + } + + if let index = editedFields.firstIndex(where: { $0.fieldId == idToEdit }) { + // Existing field has been locally edited, let's update it again + editedFields[index] = newField + } else { + // First time the field is locally edited + editedFields.append(newField) + } + } + + func updateCombinedList() { + let editedList = originalCustomFields.map { field in + editedFields.first { $0.fieldId == field.id } ?? CustomFieldUI(customField: field) + } + combinedList = editedList + addedFields + } +} + +extension CustomFieldsListViewModel { + struct CustomFieldUI: Identifiable { + let id = UUID() + let key: String + let value: String + let fieldId: Int64? + + init(key: String, value: String, fieldId: Int64? = nil) { + self.key = key + self.value = value + self.fieldId = fieldId + } + + init(customField: CustomFieldViewModel) { + self.key = customField.title + self.value = customField.content + self.fieldId = customField.id + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index b6553d9232f..49bcf88e6e7 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1712,6 +1712,8 @@ 86DE68822B4BA47A00B437A6 /* BlazeAdDestinationSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86DE68812B4BA47900B437A6 /* BlazeAdDestinationSettingViewModel.swift */; }; 86E40AED2B597DEC00990365 /* BlazeCampaignCreationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E40AEC2B597DEC00990365 /* BlazeCampaignCreationCoordinatorTests.swift */; }; 86F0896F2B307D7E00D668A1 /* ThemesPreviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F0896E2B307D7E00D668A1 /* ThemesPreviewViewModelTests.swift */; }; + 86F5FFE22CA302B300C767C4 /* CustomFieldsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F5FFE12CA302B300C767C4 /* CustomFieldsListViewModel.swift */; }; + 86F5FFE42CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F5FFE32CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift */; }; 86F9D3642C897FFE00B1835B /* CustomFieldEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F9D3632C897FFE00B1835B /* CustomFieldEditorView.swift */; }; 8CD41D4A21F8A7E300CF3C2B /* RELEASE-NOTES.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8CD41D4921F8A7E300CF3C2B /* RELEASE-NOTES.txt */; }; 933A27372222354600C2143A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933A27362222354600C2143A /* Logging.swift */; }; @@ -4736,6 +4738,8 @@ 86DE68812B4BA47900B437A6 /* BlazeAdDestinationSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeAdDestinationSettingViewModel.swift; sourceTree = ""; }; 86E40AEC2B597DEC00990365 /* BlazeCampaignCreationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignCreationCoordinatorTests.swift; sourceTree = ""; }; 86F0896E2B307D7E00D668A1 /* ThemesPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemesPreviewViewModelTests.swift; sourceTree = ""; }; + 86F5FFE12CA302B300C767C4 /* CustomFieldsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFieldsListViewModel.swift; sourceTree = ""; }; + 86F5FFE32CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFieldsListViewModelTests.swift; sourceTree = ""; }; 86F9D3632C897FFE00B1835B /* CustomFieldEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFieldEditorView.swift; sourceTree = ""; }; 8A659E65308A3D9DD79A95F9 /* Pods-WooCommerceTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WooCommerceTests.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WooCommerceTests/Pods-WooCommerceTests.release.xcconfig"; sourceTree = ""; }; 8CA4F6DD220B257000A47B5D /* WooCommerce.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = WooCommerce.debug.xcconfig; path = ../config/WooCommerce.debug.xcconfig; sourceTree = ""; }; @@ -9769,6 +9773,7 @@ isa = PBXGroup; children = ( CC5BA5F4287EDC900072F307 /* CustomFieldViewModelTests.swift */, + 86F5FFE32CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift */, ); path = "Custom Fields"; sourceTree = ""; @@ -10572,6 +10577,7 @@ B626C71A287659D60083820C /* CustomFieldsListView.swift */, B6C838DD28793B3A003AB786 /* CustomFieldViewModel.swift */, 86F9D3632C897FFE00B1835B /* CustomFieldEditorView.swift */, + 86F5FFE12CA302B300C767C4 /* CustomFieldsListViewModel.swift */, ); path = "Custom Fields"; sourceTree = ""; @@ -16095,6 +16101,7 @@ B99B87A72AEFCF0A006B8AB1 /* Order+Empty.swift in Sources */, CE6A8FB62B725A690063564D /* AnalyticsReportLinkViewModel.swift in Sources */, 684AB83C2873DF04003DFDD1 /* CardReaderManualsViewModel.swift in Sources */, + 86F5FFE22CA302B300C767C4 /* CustomFieldsListViewModel.swift in Sources */, 575472812452185300A94C3C /* PushNotification.swift in Sources */, 0396CFAD2981476900E91436 /* CardPresentModalBuiltInConnectingFailed.swift in Sources */, 02C1853B27FF0D9C00ABD764 /* RefundSubmissionUseCase.swift in Sources */, @@ -16417,6 +16424,7 @@ 4552085B25829091001CF873 /* AddAttributeViewModelTests.swift in Sources */, 02F1E6BD2A39805C00C3E4C7 /* ProductDescriptionAICoordinatorTests.swift in Sources */, CC33238C29CDF67D00CA9709 /* ComponentSettingsViewModelTests.swift in Sources */, + 86F5FFE42CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift in Sources */, 0261F5A728D454CF00B7AC72 /* ProductSearchUICommandTests.swift in Sources */, 098FFA1727AD7F5D002EBEE4 /* OrderStatusListDataSourceTests.swift in Sources */, DE19BB1D26C6911900AB70D9 /* ShippingLabelCustomsFormListViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldsListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldsListViewModelTests.swift new file mode 100644 index 00000000000..b4f8b76f09e --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldsListViewModelTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import WooCommerce + +final class CustomFieldsListViewModelTests: XCTestCase { + + private var viewModel: CustomFieldsListViewModel! + + override func setUp() { + super.setUp() + let customFields = [ + CustomFieldViewModel(id: 1, title: "Key1", content: "Value1"), + CustomFieldViewModel(id: 2, title: "Key2", content: "Value2") + ] + viewModel = CustomFieldsListViewModel(customFields: customFields) + } + + override func tearDown() { + viewModel = nil + super.tearDown() + } + + func test_given_initializedViewModel_then_displayedItemsMatchInitialCustomFields() { + // Given: The viewModel is initialized with two custom fields (in setUp) + + // When: No additional action needed, we're testing the initial state + + // Then: The displayed items should match the initial custom fields + XCTAssertEqual(viewModel.combinedList.count, 2) + XCTAssertEqual(viewModel.combinedList[0].key, "Key1") + XCTAssertEqual(viewModel.combinedList[0].value, "Value1") + XCTAssertEqual(viewModel.combinedList[0].fieldId, 1) + XCTAssertEqual(viewModel.combinedList[1].key, "Key2") + XCTAssertEqual(viewModel.combinedList[1].value, "Value2") + XCTAssertEqual(viewModel.combinedList[1].fieldId, 2) + } + + func test_given_existingField_when_editFieldCalled_then_displayedItemsAndPendingChangesAreUpdated() { + // Given: A custom field UI to edit an existing field + let editedField = CustomFieldsListViewModel.CustomFieldUI(key: "EditedKey1", value: "EditedValue1", fieldId: 1) + + // When: Editing the field + viewModel.editField(at: 0, newField: editedField) + + // Then: The number of displayed items remains the same as before and the value is edited correctly + XCTAssertEqual(viewModel.combinedList.count, 2) + XCTAssertEqual(viewModel.combinedList[0].key, "EditedKey1") + XCTAssertEqual(viewModel.combinedList[0].value, "EditedValue1") + } + + func test_given_newField_when_addFieldCalled_then_displayedItemsAndPendingChangesAreUpdated() { + // Given: A new custom field UI to add + let newField = CustomFieldsListViewModel.CustomFieldUI(key: "NewKey", value: "NewValue") + + // When: Adding the new field + viewModel.addField(newField) + + // Then: The pending changes and displayed items should be updated + XCTAssertEqual(viewModel.combinedList.count, 3) + XCTAssertEqual(viewModel.combinedList.last?.key, "NewKey") + XCTAssertEqual(viewModel.combinedList.last?.value, "NewValue") + } + + func test_given_editedAndNewFields_when_updatingDisplayedItems_then_changesAreReflected() { + // Given: An edited field and a new field + let editedField = CustomFieldsListViewModel.CustomFieldUI(key: "EditedKey1", value: "EditedValue1", fieldId: 1) + let newField = CustomFieldsListViewModel.CustomFieldUI(key: "NewKey", value: "NewValue") + + // When: Editing and adding fields + viewModel.editField(at: 0, newField: editedField) + viewModel.addField(newField) + + // Then: The displayed items should reflect both the edited and added fields + XCTAssertEqual(viewModel.combinedList.count, 3) + XCTAssertEqual(viewModel.combinedList[0].key, "EditedKey1") + XCTAssertEqual(viewModel.combinedList[0].value, "EditedValue1") + XCTAssertEqual(viewModel.combinedList[2].key, "NewKey") + XCTAssertEqual(viewModel.combinedList[2].value, "NewValue") + } + + func test_given_variousChanges_when_pendingChangesUpdated_then_hasChangesReflectsCorrectState() { + // Given: Initial state with no changes + XCTAssertFalse(viewModel.hasChanges) + + // When: Editing a field + let editedField = CustomFieldsListViewModel.CustomFieldUI(key: "EditedKey1", value: "EditedValue1", fieldId: 1) + viewModel.editField(at: 0, newField: editedField) + + // Then: hasChanges should be true + XCTAssertTrue(viewModel.hasChanges) + + // When: Adding a new field + let newField = CustomFieldsListViewModel.CustomFieldUI(key: "NewKey", value: "NewValue") + viewModel.addField(newField) + + // Then: hasChanges should be true + XCTAssertTrue(viewModel.hasChanges) + + func test_given_invalidIndex_when_editFieldCalled_then_noChangesAreMade() { + // Given: An invalid index and a custom field UI + let editedField = CustomFieldsListViewModel.CustomFieldUI(key: "EditedKey1", value: "EditedValue1", fieldId: 1) + + // When: Trying to edit a field at an invalid index + viewModel.editField(at: -1, newField: editedField) + + // Then: No changes should be made + XCTAssertEqual(viewModel.combinedList.count, 2) + XCTAssertEqual(viewModel.combinedList[0].key, "Key1") + XCTAssertEqual(viewModel.combinedList[0].value, "Value1") + XCTAssertEqual(viewModel.combinedList[1].key, "Key2") + XCTAssertEqual(viewModel.combinedList[1].value, "Value2") + } + + func test_given_duplicateKey_when_addFieldCalled_then_fieldIsAdded() { + // Given: A new custom field UI with a duplicate key + let newField = CustomFieldsListViewModel.CustomFieldUI(key: "Key1", value: "NewValue") + + // When: Adding the new field + viewModel.addField(newField) + + // Then: The field should be added to the list + XCTAssertEqual(viewModel.combinedList.count, 3) + XCTAssertEqual(viewModel.combinedList.last?.key, "Key1") + XCTAssertEqual(viewModel.combinedList.last?.value, "NewValue") + } + } +}