diff --git a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldEditorView.swift b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldEditorView.swift index f64f3b74030..d54fc149981 100644 --- a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldEditorView.swift +++ b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldEditorView.swift @@ -1,39 +1,18 @@ import SwiftUI -struct AztecEditorView: UIViewControllerRepresentable { - @Binding var html: String - - func makeUIViewController(context: Context) -> AztecEditorViewController { - let viewProperties = EditorViewProperties(navigationTitle: "Navigation title", /* todo replace string */ - placeholderText: "placeholder text", /* todo replace string */ - showSaveChangesActionSheet: true) - - let controller = AztecEditorViewController( - content: html, - product: nil, - viewProperties: viewProperties, - isAIGenerationEnabled: false - ) - - return controller - } - - func updateUIViewController(_ uiViewController: AztecEditorViewController, context: Context) { - // Update the view controller if needed - } -} - struct CustomFieldEditorView: View { @State private var key: String @State private var value: String - @State private var hasUnsavedChanges: Bool = false - @State private var showRichTextEditor: Bool = false - @State private var showingActionSheet: Bool = false + @State private var showRichTextEditor = false + @State private var showingActionSheet = false private let initialKey: String private let initialValue: String private let isReadOnlyValue: Bool + private var hasUnsavedChanges: Bool { + key != initialKey || value != initialValue + } /// Initializer for custom field editor /// - Parameters: @@ -53,54 +32,59 @@ struct CustomFieldEditorView: View { VStack(alignment: .leading, spacing: Layout.sectionSpacing) { // Key Input VStack(alignment: .leading, spacing: Layout.subSectionsSpacing) { - Text("Key") // todo-13493: set String to be translatable + Text(Localization.keyLabel) .foregroundColor(Color(.text)) .subheadlineStyle() - TextField("Enter key", text: $key) // todo-13493: set String to be translatable - .bodyStyle() + TextField(Localization.keyPlaceholder, text: $key) + .foregroundColor(Color(.text)) + .subheadlineStyle() .padding(insets: Layout.inputInsets) .background(Color(.listForeground(modal: false))) .overlay( RoundedRectangle(cornerRadius: Layout.cornerRadius).stroke(Color(.separator)) ) - .onChange(of: key) { _ in - checkForModifications() - } } // Value Input VStack(alignment: .leading, spacing: Layout.subSectionsSpacing) { HStack { - Text("Value") // todo-13493: set String to be translatable + Text(Localization.valueLabel) .foregroundColor(Color(.text)) .subheadlineStyle() + .frame(maxWidth: .infinity, alignment: .leading) Spacer() if !isReadOnlyValue { - Button(action: { - showRichTextEditor = true - }) { - Text("Edit in Rich Text Editor") // todo-13493: set String to be translatable - .font(.footnote) + Picker(Localization.editorPickerLabel, selection: $showRichTextEditor) { + Text(Localization.editorPickerText).tag(false) + Text(Localization.editorPickerHTML).tag(true) } - .buttonStyle(.plain) - .foregroundColor(Color(uiColor: .accent)) + .pickerStyle(.segmented) } } - TextEditor(text: isReadOnlyValue ? .constant(value) : $value) - .bodyStyle() + if showRichTextEditor { + AztecEditorView(content: $value) .frame(minHeight: Layout.minimumEditorSize) + .clipped() .padding(insets: Layout.inputInsets) .background(Color(.listForeground(modal: false))) .overlay( RoundedRectangle(cornerRadius: Layout.cornerRadius).stroke(Color(.separator)) ) - .onChange(of: value) { _ in - checkForModifications() - } + } else { + TextEditor(text: isReadOnlyValue ? .constant(value) : $value) + .foregroundColor(Color(.text)) + .subheadlineStyle() + .frame(minHeight: Layout.minimumEditorSize) + .padding(insets: Layout.inputInsets) + .background(Color(.listForeground(modal: false))) + .overlay( + RoundedRectangle(cornerRadius: Layout.cornerRadius).stroke(Color(.separator)) + ) + } } } .padding() @@ -128,12 +112,6 @@ struct CustomFieldEditorView: View { } } } - .sheet(isPresented: $showRichTextEditor) { - RichTextEditor(html: $value) - .onDisappear { - checkForModifications() - } - } } @ViewBuilder @@ -153,45 +131,11 @@ struct CustomFieldEditorView: View { } } - private func checkForModifications() { - hasUnsavedChanges = key != initialKey || value != initialValue - } - private func saveChanges() { // todo-13493: add save logic } } -private struct RichTextEditor: View { - @Binding var html: String - @State private var isModified: Bool = false - @Environment(\.presentationMode) var presentationMode - - var body: some View { - NavigationView { - AztecEditorView(html: $html) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - presentationMode.wrappedValue.dismiss() - } label: { - Text("Cancel") // todo-13493: set String to be translatable - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button { - // todo-13493: implement save action - presentationMode.wrappedValue.dismiss() - } label: { - Text("Done") // todo-13493: set String to be translatable - } - .disabled(!isModified) - } - } - } - } -} - // MARK: Constants private extension CustomFieldEditorView { enum Layout { @@ -201,6 +145,44 @@ private extension CustomFieldEditorView { static let inputInsets = EdgeInsets(top: 8, leading: 5, bottom: 8, trailing: 5) static let minimumEditorSize: CGFloat = 400 } + + enum Localization { + static let keyLabel = NSLocalizedString( + "customFieldEditorView.keyLabel", + value: "Key", + comment: "Label for the Key field" + ) + + static let keyPlaceholder = NSLocalizedString( + "customFieldEditorView.keyPlaceholder", + value: "Enter key", + comment: "Placeholder for the Key field" + ) + + static let valueLabel = NSLocalizedString( + "customFieldEditorView.valueLabel", + value: "Value", + comment: "Label for the Value field" + ) + + static let editorPickerLabel = NSLocalizedString( + "customFieldEditorView.editorPickerLabel", + value: "Choose text editing mode", + comment: "Label for the Editor type picker" + ) + + static let editorPickerText = NSLocalizedString( + "customFieldEditorView.editorPickerText", + value: "Text", + comment: "Picker option for using Text Editor" + ) + + static let editorPickerHTML = NSLocalizedString( + "customFieldEditorView.editorPickerHTML", + value: "HTML", + comment: "Picker option for using Text Editor" + ) + } } #Preview { diff --git a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldViewModel.swift b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldViewModel.swift index 452ef60eabe..3545f2ae917 100644 --- a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldViewModel.swift @@ -37,7 +37,7 @@ struct CustomFieldViewModel: Identifiable { self.init( id: metadata.metadataID, title: metadata.key, - content: metadata.value.removedHTMLTags, + content: metadata.value, contentURL: contentURL ) } diff --git a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift index f6d6210835b..11b0c8f6523 100644 --- a/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift +++ b/WooCommerce/Classes/ViewRelated/Custom Fields/CustomFieldsListView.swift @@ -18,14 +18,14 @@ struct CustomFieldsListView: View { ) { CustomFieldRow(isEditable: true, title: customField.title, - content: customField.content, + content: customField.content.removedHTMLTags, contentURL: customField.contentURL) } } else { CustomFieldRow(isEditable: false, title: customField.title, - content: customField.content, + content: customField.content.removedHTMLTags, contentURL: customField.contentURL) } diff --git a/WooCommerce/Classes/ViewRelated/Editor/AztecEditorView.swift b/WooCommerce/Classes/ViewRelated/Editor/AztecEditorView.swift new file mode 100644 index 00000000000..46811096bc9 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Editor/AztecEditorView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// `UIViewControllerRepresentable` to use an Aztec Editor in SwiftUI. +/// Params: +/// - content: the content of the editor. It's a Binding so that the parent View can get the latest Editor content. +/// +struct AztecEditorView: UIViewControllerRepresentable { + @Binding var content: String + + func makeUIViewController(context: Context) -> AztecEditorViewController { + let controller = EditorFactory().customFieldRichTextEditor(initialValue: content) + var ignoreInitialContent = true + + guard let aztecController = controller as? AztecEditorViewController else { + fatalError("EditorFactory must return an AztecEditorViewController, but returned \(type(of: controller))") + } + + aztecController.onContentChanged = { text in + /// In addition to user's change action, this callback is invokeddssd during the View Controller's `viewDidLoad` too. + /// This check is needed to avoid setting the value back to the binding at that point, as doing so will trigger the + /// "Modifying state during view update, this will cause undefined behavior" issue. + if ignoreInitialContent { + ignoreInitialContent = false + return + } + + content = text + } + return aztecController + } + + func updateUIViewController(_ uiViewController: AztecEditorViewController, context: Context) { + } +} diff --git a/WooCommerce/Classes/ViewRelated/Editor/AztecEditorViewController.swift b/WooCommerce/Classes/ViewRelated/Editor/AztecEditorViewController.swift index dc7917e1059..4538af299b5 100644 --- a/WooCommerce/Classes/ViewRelated/Editor/AztecEditorViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Editor/AztecEditorViewController.swift @@ -5,6 +5,7 @@ import WordPressEditor /// Aztec's Native Editor! final class AztecEditorViewController: UIViewController, Editor { var onContentSave: OnContentSave? + var onContentChanged: ((String) -> Void)? private var content: String private var productName: String? @@ -300,6 +301,7 @@ extension AztecEditorViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { refreshPlaceholderVisibility() formatBar.update(editorView: editorView) + onContentChanged?(getHTML()) } func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { diff --git a/WooCommerce/Classes/ViewRelated/Editor/EditorFactory.swift b/WooCommerce/Classes/ViewRelated/Editor/EditorFactory.swift index 4196af8df6d..72ee1358c83 100644 --- a/WooCommerce/Classes/ViewRelated/Editor/EditorFactory.swift +++ b/WooCommerce/Classes/ViewRelated/Editor/EditorFactory.swift @@ -1,39 +1,81 @@ import Yosemite protocol Editor { - typealias OnContentSave = (_ content: String, _ productName: String?) -> Void + typealias OnContentSave = (_ content: String, _ additionalInfo: String?) -> Void var onContentSave: OnContentSave? { get } } /// This class takes care of instantiating the editor. /// final class EditorFactory { + struct EditorContext { + let initialContent: String? + let navigationTitle: String + let placeholderText: String + let showSaveChangesActionSheet: Bool + let isAIGenerationEnabled: Bool + } // MARK: - Editor: Instantiation func productDescriptionEditor(product: ProductFormDataModel, isAIGenerationEnabled: Bool, onContentSave: @escaping Editor.OnContentSave) -> Editor & UIViewController { - let viewProperties = EditorViewProperties(navigationTitle: Localization.productDescriptionTitle, - placeholderText: Localization.placeholderText(product: product), - showSaveChangesActionSheet: true) - let editor = AztecEditorViewController(content: product.description, - product: product, - viewProperties: viewProperties, - isAIGenerationEnabled: isAIGenerationEnabled) - editor.onContentSave = onContentSave - return editor + let context = EditorContext( + initialContent: product.description, + navigationTitle: Localization.productDescriptionTitle, + placeholderText: Localization.placeholderText(product: product), + showSaveChangesActionSheet: true, + isAIGenerationEnabled: isAIGenerationEnabled + ) + + return createEditor(context: context, product: product, onContentSave: onContentSave) } func productShortDescriptionEditor(product: ProductFormDataModel, onContentSave: @escaping Editor.OnContentSave) -> Editor & UIViewController { - let viewProperties = EditorViewProperties(navigationTitle: Localization.productShortDescriptionTitle, - placeholderText: Localization.placeholderText(product: product), - showSaveChangesActionSheet: true) - let editor = AztecEditorViewController(content: product.shortDescription, - product: product, - viewProperties: viewProperties, - isAIGenerationEnabled: false) + let context = EditorContext( + initialContent: product.shortDescription, + navigationTitle: Localization.productShortDescriptionTitle, + placeholderText: Localization.placeholderText(product: product), + showSaveChangesActionSheet: true, + isAIGenerationEnabled: false + ) + + return createEditor(context: context, product: product, onContentSave: onContentSave) + } + + // onContentSave is optional because this can be used in SwiftUI with `UIViewControllerRepresentable` and the + // saving callback is to be managed separately there. + func customFieldRichTextEditor(initialValue: String, + onContentSave: Editor.OnContentSave? = nil) -> Editor & UIViewController { + let context = EditorContext( + initialContent: initialValue, + navigationTitle: Localization.customFieldsValueTitle, + placeholderText: Localization.customFieldsValuePlaceholder, + showSaveChangesActionSheet: true, + isAIGenerationEnabled: false + ) + + return createEditor(context: context, onContentSave: onContentSave) + } + + func createEditor(context: EditorContext, + product: ProductFormDataModel? = nil, + onContentSave: Editor.OnContentSave?) -> Editor & UIViewController { + let viewProperties = EditorViewProperties( + navigationTitle: context.navigationTitle, + placeholderText: context.placeholderText, + showSaveChangesActionSheet: context.showSaveChangesActionSheet + ) + + let editor = AztecEditorViewController( + content: context.initialContent, + product: product, + viewProperties: viewProperties, + isAIGenerationEnabled: context.isAIGenerationEnabled + ) + editor.onContentSave = onContentSave return editor } @@ -57,5 +99,16 @@ private extension EditorFactory { } return String(format: Localization.placeholderFormat, product.name) } + + static let customFieldsValueTitle = NSLocalizedString( + "editorFactory.customFieldsValueTitle", + value: "Value", + comment: "The value of custom field to be edited.") + + static let customFieldsValuePlaceholder = NSLocalizedString( + "editorFactory.customFieldsValuePlaceholder", + value: "Enter custom field value", + comment: "Placeholder text inside the editor's text field.") + } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 255f003d669..3f41d55b929 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1706,6 +1706,7 @@ 86967D832B4E3EC300C20CA8 /* BlazeAdUrlParameter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86967D822B4E3EC300C20CA8 /* BlazeAdUrlParameter.swift */; }; 8697AFBD2B60F56A00EFAF21 /* BlazeAdDestinationSettingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8697AFBC2B60F56A00EFAF21 /* BlazeAdDestinationSettingViewModelTests.swift */; }; 8697AFBF2B622DEA00EFAF21 /* BlazeAddParameterViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8697AFBE2B622DEA00EFAF21 /* BlazeAddParameterViewModelTests.swift */; }; + 869C2AA42C91791B00DDEE13 /* AztecEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 869C2AA32C91791B00DDEE13 /* AztecEditorView.swift */; }; 86A4EBBD2B2F1306008011F5 /* ThemesPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A4EBBC2B2F1306008011F5 /* ThemesPreviewViewModel.swift */; }; 86B3E2552C6B1F160002420B /* HelpAndSupportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B3E2542C6B1F160002420B /* HelpAndSupportViewModel.swift */; }; 86B3E2572C6B249C0002420B /* HelpAndSupportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B3E2562C6B249C0002420B /* HelpAndSupportViewModelTests.swift */; }; @@ -4739,6 +4740,7 @@ 86967D822B4E3EC300C20CA8 /* BlazeAdUrlParameter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeAdUrlParameter.swift; sourceTree = ""; }; 8697AFBC2B60F56A00EFAF21 /* BlazeAdDestinationSettingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeAdDestinationSettingViewModelTests.swift; sourceTree = ""; }; 8697AFBE2B622DEA00EFAF21 /* BlazeAddParameterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeAddParameterViewModelTests.swift; sourceTree = ""; }; + 869C2AA32C91791B00DDEE13 /* AztecEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecEditorView.swift; sourceTree = ""; }; 86A4EBBC2B2F1306008011F5 /* ThemesPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemesPreviewViewModel.swift; sourceTree = ""; }; 86B3E2542C6B1F160002420B /* HelpAndSupportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpAndSupportViewModel.swift; sourceTree = ""; }; 86B3E2562C6B249C0002420B /* HelpAndSupportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpAndSupportViewModelTests.swift; sourceTree = ""; }; @@ -6728,6 +6730,7 @@ 024DF3062372C18D006658FE /* AztecUIConfigurator.swift */, 024DF3082372CA00006658FE /* EditorViewProperties.swift */, 02D29A8F29F7C2DA00473D6D /* AztecAIViewFactory.swift */, + 869C2AA32C91791B00DDEE13 /* AztecEditorView.swift */, ); path = Editor; sourceTree = ""; @@ -16337,6 +16340,7 @@ E12FB786266E0CAE0039E9C2 /* ApllicationLogDetailView.swift in Sources */, 74EC34A5225FE21F004BBC2E /* ProductLoaderViewController.swift in Sources */, CE8CCD43239AC06E009DBD22 /* RefundDetailsViewController.swift in Sources */, + 869C2AA42C91791B00DDEE13 /* AztecEditorView.swift in Sources */, B560D68C2195BD1E0027BB7E /* NoteDetailsCommentTableViewCell.swift in Sources */, 014BD4BA2C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift in Sources */, DEA88F502AA9D0100037273B /* AddEditProductCategoryViewModel.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldViewModelTests.swift index 3cb9854280b..eba81b66220 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Custom Fields/CustomFieldViewModelTests.swift @@ -17,17 +17,6 @@ class CustomFieldViewModelTests: XCTestCase { XCTAssertEqual(viewModel.contentURL, url) } - func test_init_with_MetaData_strips_HTML_from_metadata_value() throws { - // Given - let metadata = MetaData(metadataID: 0, key: "HTML Metadata", value: "Fancy Metadata") - - // When - let viewModel = CustomFieldViewModel(metadata: metadata) - - // Then - XCTAssertEqual(viewModel.content, "Fancy Metadata") - } - func test_init_with_MetaData_creates_contentURL_from_metadata_value() throws { // Given let urlString = "https://woocommerce.com/"