diff --git a/MVMCoreUI.xcodeproj/project.pbxproj b/MVMCoreUI.xcodeproj/project.pbxproj index 12d16ced..d937c0cf 100644 --- a/MVMCoreUI.xcodeproj/project.pbxproj +++ b/MVMCoreUI.xcodeproj/project.pbxproj @@ -581,9 +581,11 @@ EA17584A2BC97EF100A5C0D9 /* BadgeIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1758492BC97EF100A5C0D9 /* BadgeIndicatorModel.swift */; }; EA17584C2BC9894800A5C0D9 /* ButtonIconModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA17584B2BC9894800A5C0D9 /* ButtonIconModel.swift */; }; EA17584E2BC9895A00A5C0D9 /* ButtonIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA17584D2BC9895A00A5C0D9 /* ButtonIcon.swift */; }; + EA1B02DE2C41BFD200F0758B /* RuleVDSModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1B02DD2C41BFD200F0758B /* RuleVDSModel.swift */; }; EA41F4AC2787927100F5B377 /* DynamicRuleFormFieldEffectModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA41F4AB2787927100F5B377 /* DynamicRuleFormFieldEffectModel.swift */; }; EA5124FD243601600051A3A4 /* BGImageHeadlineBodyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5124FC243601600051A3A4 /* BGImageHeadlineBodyButton.swift */; }; EA5124FF2436018E0051A3A4 /* BGImageHeadlineBodyButtonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5124FE2436018E0051A3A4 /* BGImageHeadlineBodyButtonModel.swift */; }; + EA5DBDAB2C35B6C500290DF8 /* FormFieldModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5DBDAA2C35B6C500290DF8 /* FormFieldModel.swift */; }; EA6642912BCDA97300D81DC4 /* TileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6642902BCDA97300D81DC4 /* TileContainer.swift */; }; EA6642932BCDA97D00D81DC4 /* TileContainerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6642922BCDA97D00D81DC4 /* TileContainerModel.swift */; }; EA6E8B952B504A43000139B4 /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6E8B942B504A43000139B4 /* ButtonGroup.swift */; }; @@ -1204,9 +1206,11 @@ EA1758492BC97EF100A5C0D9 /* BadgeIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeIndicatorModel.swift; sourceTree = ""; }; EA17584B2BC9894800A5C0D9 /* ButtonIconModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconModel.swift; sourceTree = ""; }; EA17584D2BC9895A00A5C0D9 /* ButtonIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIcon.swift; sourceTree = ""; }; + EA1B02DD2C41BFD200F0758B /* RuleVDSModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleVDSModel.swift; sourceTree = ""; }; EA41F4AB2787927100F5B377 /* DynamicRuleFormFieldEffectModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicRuleFormFieldEffectModel.swift; sourceTree = ""; }; EA5124FC243601600051A3A4 /* BGImageHeadlineBodyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGImageHeadlineBodyButton.swift; sourceTree = ""; }; EA5124FE2436018E0051A3A4 /* BGImageHeadlineBodyButtonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGImageHeadlineBodyButtonModel.swift; sourceTree = ""; }; + EA5DBDAA2C35B6C500290DF8 /* FormFieldModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormFieldModel.swift; sourceTree = ""; }; EA6642902BCDA97300D81DC4 /* TileContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileContainer.swift; sourceTree = ""; }; EA6642922BCDA97D00D81DC4 /* TileContainerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileContainerModel.swift; sourceTree = ""; }; EA6E8B942B504A43000139B4 /* ButtonGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroup.swift; sourceTree = ""; }; @@ -1300,6 +1304,7 @@ 011D95A0240453D0000E3791 /* RuleEqualsModel.swift */, 0A849EFD246F1775009F277F /* RuleEqualsIgnoreCaseModel.swift */, FD9912FF28E21E4900542CC3 /* RuleNotEqualsModel.swift */, + EA1B02DD2C41BFD200F0758B /* RuleVDSModel.swift */, ); name = Rules; path = Rules/Rules; @@ -2496,6 +2501,7 @@ D2BEFEF5248A954C00FAB3A9 /* FormFields */ = { isa = PBXGroup; children = ( + EA5DBDAA2C35B6C500290DF8 /* FormFieldModel.swift */, D2BEFEF6248A957A00FAB3A9 /* Tags */, D29DF22B21E6A0FA003B2FB9 /* TextFields */, ); @@ -2842,6 +2848,7 @@ D253BB8A24574CC5002DE544 /* StackModel.swift in Sources */, EAB14BC127D935F00012AB2C /* RuleCompareModelProtocol.swift in Sources */, 011D95A924057AC7000E3791 /* FormGroupWatcherFieldProtocol.swift in Sources */, + EA1B02DE2C41BFD200F0758B /* RuleVDSModel.swift in Sources */, EA985C892981AB7100F2FF2E /* VDS-TextStyle.swift in Sources */, BB2BF0EA2452A9BB001D0FC2 /* ListDeviceComplexButtonSmall.swift in Sources */, D20C700B250BFDE40095B21C /* NotificationContainerView.swift in Sources */, @@ -3304,6 +3311,7 @@ D28BA7432480284E00B75CB8 /* TabBar.swift in Sources */, AA3561AE24C96B9000452EB1 /* ListRightVariableRightCaretAllTextAndLinks.swift in Sources */, AA26850C244840AE00CE34CC /* HeadersH2TinyButton.swift in Sources */, + EA5DBDAB2C35B6C500290DF8 /* FormFieldModel.swift in Sources */, 011D95AB2405C553000E3791 /* FormItemProtocol.swift in Sources */, D21EE53C23AD3AD4003D1A30 /* NSLayoutConstraintAxis+Extension.swift in Sources */, 0A25209824645B76000FA9F6 /* TextViewEntryFieldModel.swift in Sources */, diff --git a/MVMCoreUI/Atomic/Atoms/FormFields/FormFieldModel.swift b/MVMCoreUI/Atomic/Atoms/FormFields/FormFieldModel.swift new file mode 100644 index 00000000..c96a8266 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/FormFields/FormFieldModel.swift @@ -0,0 +1,134 @@ +// +// FormFieldModel.swift +// MVMCoreUI +// +// Created by Matt Bruce on 7/3/24. +// Copyright © 2024 Verizon Wireless. All rights reserved. +// + +import Foundation +import VDS + +@objcMembers open class FormFieldModel: MoleculeModelProtocol, FormFieldProtocol, FormRuleWatcherFieldProtocol, UIUpdatableModelProtocol { + + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + public class var identifier: String { "" } + public var id: String = UUID().uuidString + + public var backgroundColor: Color? + public var accessibilityIdentifier: String? + + public var enabled: Bool = true + public var required: Bool = true + public var readOnly: Bool = false + public var showError: Bool? + public var errorMessage: String? + public var initialErrorMessage: String? + + public var fieldKey: String? + public var groupName: String = FormValidator.defaultGroupName + public var baseValue: AnyHashable? + + public var inverted: Bool = false + public var surface: Surface { inverted ? .dark : .light } + + public var dynamicErrorMessage: String? { + didSet { + isValid = dynamicErrorMessage?.isEmpty ?? true + updateUIDynamicError?() + } + } + + public var isValid: Bool? = true { + didSet { updateUI?() } + } + + /// Temporary binding mechanism for the view to update on enable changes. + public var updateUI: ActionBlock? + + // TODO: Remove once updateUI is fixed with isSelected + public var updateUIDynamicError: ActionBlock? + + //-------------------------------------------------- + // MARK: - Initializer + //-------------------------------------------------- + + public init() {} + + //-------------------------------------------------- + // MARK: - Keys + //-------------------------------------------------- + + private enum CodingKeys: String, CodingKey { + case id + case moleculeName + case accessibilityIdentifier + case errorMessage + case enabled + case readOnly + case required + case fieldKey + case groupName + case inverted + } + + //-------------------------------------------------- + // MARK: - Validation Methods + //-------------------------------------------------- + + open func formFieldValue() -> AnyHashable? { + fatalError("developer must implement") + } + + open func formFieldServerValue() -> AnyHashable? { + return formFieldValue() + } + + public func setValidity(_ valid: Bool, errorMessage: String?) { + + if let ruleErrorMessage = errorMessage, fieldKey != nil { + self.errorMessage = ruleErrorMessage + } else { + self.errorMessage = initialErrorMessage + } + + isValid = valid + updateUI?() + } + + //-------------------------------------------------- + // MARK: - Codable + //-------------------------------------------------- + required public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + accessibilityIdentifier = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityIdentifier) + errorMessage = try typeContainer.decodeIfPresent(String.self, forKey: .errorMessage) + initialErrorMessage = errorMessage + enabled = try typeContainer.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + required = try typeContainer.decodeIfPresent(Bool.self, forKey: .required) ?? true + readOnly = try typeContainer.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + fieldKey = try typeContainer.decodeIfPresent(String.self, forKey: .fieldKey) + groupName = try typeContainer.decodeIfPresent(String.self, forKey: .groupName) ?? FormValidator.defaultGroupName + + if let inverted = try typeContainer.decodeIfPresent(Bool.self, forKey: .inverted) { + self.inverted = inverted + } + } + + open func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(moleculeName, forKey: .moleculeName) + try container.encodeIfPresent(accessibilityIdentifier, forKey: .accessibilityIdentifier) + try container.encodeIfPresent(errorMessage, forKey: .errorMessage) + try container.encodeIfPresent(fieldKey, forKey: .fieldKey) + try container.encodeIfPresent(groupName, forKey: .groupName) + try container.encode(readOnly, forKey: .readOnly) + try container.encode(enabled, forKey: .enabled) + try container.encode(required, forKey: .required) + try container.encode(inverted, forKey: .inverted) + } +} diff --git a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryField.swift b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryField.swift index 6f69be43..0986a092 100644 --- a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryField.swift +++ b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryField.swift @@ -7,19 +7,66 @@ // import UIKit +import VDS +open class ItemDropdownEntryField: VDS.DropdownSelect, VDSMoleculeViewProtocol, ObservingTextFieldDelegate { + //------------------------------------------------------ + // MARK: - Properties + //------------------------------------------------------ + open var viewModel: ItemDropdownEntryFieldModel! + open var delegateObject: MVMCoreUIDelegateObject? + open var additionalData: [AnyHashable : Any]? + + // Form Validation + var fieldKey: String? + var fieldValue: JSONValue? + var groupName: String? + + open var pickerData: [String] = [] { + didSet { + options = pickerData.compactMap({ DropdownOptionModel(text: $0) }) + } + } + + private var isEditting: Bool = false -open class ItemDropdownEntryField: BaseItemPickerEntryField { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- + public var isValid: Bool = true + + /// Closure passed here will run as picker changes items. + public var observeDropdownChange: ((String?, String) -> ())? - open var pickerData: [String] = [] + /// Closure passed here will run upon dismissing the selection picker. + public var observeDropdownSelection: ((String) -> ())? - public var itemDropdownEntryFieldModel: ItemDropdownEntryFieldModel? { - model as? ItemDropdownEntryFieldModel + /// When selecting for first responder, allow initial selected value to appear in empty text field. + public var setInitialValueInTextField = true + + open override var errorText: String? { + get { + viewModel.dynamicErrorMessage ?? viewModel.errorMessage + } + set {} + } + //-------------------------------------------------- + // MARK: - Delegate Properties + //-------------------------------------------------- + + /// The delegate and block for validation. Validates if the text that the user has entered. + public weak var observingTextFieldDelegate: ObservingTextFieldDelegate? + + /// If you're using a ViewController, you must set this to it + open weak var uiTextFieldDelegate: UITextFieldDelegate? { + get { dropdownField.delegate } + set { dropdownField.delegate = newValue } } + @objc public func dismissFieldInput(_ sender: Any?) { + _ = resignFirstResponder() + } + //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- @@ -28,7 +75,7 @@ open class ItemDropdownEntryField: BaseItemPickerEntryField { super.init(frame: frame) } - @objc public convenience init() { + @objc public convenience required init() { self.init(frame: .zero) } @@ -40,76 +87,134 @@ open class ItemDropdownEntryField: BaseItemPickerEntryField { @objc required public init?(coder: NSCoder) { fatalError("ItemDropdownEntryField init(coder:) has not been implemented") } - - required public init(model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { - super.init(model: model, delegateObject, additionalData) - } - + //-------------------------------------------------- // MARK: - Methods //-------------------------------------------------- + open override func setup() { + super.setup() + useRequiredRule = false + + publisher(for: .valueChanged) + .sink { [weak self] control in + guard let self, let selectedItem else { return } + viewModel.selectedIndex = control.selectId + observeDropdownSelection?(selectedItem.text) + _ = FormValidator.validate(delegate: delegateObject?.formHolderDelegate) + }.store(in: &subscribers) + + dropdownField + .publisher(for: .editingDidBegin) + .sink { [weak self] textField in + guard let self else { return } + isEditting = true + setInitialValueFromPicker() + }.store(in: &subscribers) + + dropdownField + .publisher(for: .editingDidEnd) + .sink { [weak self] textField in + guard let self else { return } + isEditting = false + _ = FormValidator.validate(delegate: delegateObject?.formHolderDelegate) + if let valid = viewModel.isValid { + updateValidation(valid) + } + performDropdownAction() + }.store(in: &subscribers) + } + + public func viewModelDidUpdate() { + pickerData = viewModel.options + showInlineLabel = viewModel.showInlineLabel + helperTextPlacement = viewModel.feedbackTextPlacement + labelText = viewModel.title + helperText = viewModel.feedback + isEnabled = viewModel.enabled + isReadOnly = viewModel.readOnly + isRequired = viewModel.required + tooltipModel = viewModel.tooltip?.toVDSTooltipModel() + width = viewModel.width + transparentBackground = viewModel.transparentBackground + + if let index = viewModel.selectedIndex { + selectId = index + optionsPicker.selectRow(index, inComponent: 0, animated: false) + pickerView(optionsPicker, didSelectRow: index, inComponent: 0) + } + + if (viewModel.selected ?? false) && !viewModel.wasInitiallySelected { + + viewModel.wasInitiallySelected = true + isEditting = true + } + + FormValidator.setupValidation(for: viewModel, delegate: delegateObject?.formHolderDelegate) + if isEditting { + DispatchQueue.main.async { + _ = self.becomeFirstResponder() + } + } + + viewModel.updateUI = { + MVMCoreDispatchUtility.performBlock(onMainThread: { [weak self] in + guard let self = self else { return } + + if isEditting { + updateValidation(viewModel.isValid ?? true) + + } else if viewModel.isValid ?? true && showError { + showError = false + } + isEnabled = viewModel.enabled + }) + } + + viewModel.updateUIDynamicError = { + MVMCoreDispatchUtility.performBlock(onMainThread: { [weak self] in + guard let self = self else { return } + + let validState = viewModel.isValid ?? false + if !validState && viewModel.shouldClearText { + selectId = nil + viewModel.shouldClearText = false + } + updateValidation(validState) + }) + } + + } + + public func updateView(_ size: CGFloat) { } /// Sets the textField with the first value of the available picker data. - @objc private func setInitialValueFromPicker() { + private func setInitialValueFromPicker() { guard !pickerData.isEmpty else { return } if setInitialValueInTextField { - let pickerIndex = pickerView.selectedRow(inComponent: 0) - itemDropdownEntryFieldModel?.selectedIndex = pickerIndex - observeDropdownChange?(text, pickerData[pickerIndex]) - text = pickerData[pickerIndex] + let pickerIndex = optionsPicker.selectedRow(inComponent: 0) + viewModel.selectedIndex = pickerIndex + selectId = pickerIndex + observeDropdownChange?(selectedItem?.text, pickerData[pickerIndex]) } } - @objc override func startEditing() { - super.startEditing() - - setInitialValueFromPicker() + private func performDropdownAction() { + guard let actionModel = viewModel.action, + !dropdownField.isFirstResponder + else { return } + MVMCoreUIActionHandler.performActionUnstructured(with: actionModel, sourceModel: viewModel, additionalData: additionalData, delegateObject: delegateObject) } - @objc override func endInputing() { - super.endInputing() + private func updateValidation(_ isValid: Bool) { + let previousValidity = self.isValid + self.isValid = isValid - guard !pickerData.isEmpty else { return } - - observeDropdownSelection?(pickerData[pickerView.selectedRow(inComponent: 0)]) - } - - public override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { - super.set(with: model, delegateObject, additionalData) - - guard let model = model as? ItemDropdownEntryFieldModel else { return } - - pickerData = model.options - - if let index = model.selectedIndex { - self.pickerView.selectRow(index, inComponent: 0, animated: false) - self.pickerView(pickerView, didSelectRow: index, inComponent: 0) + if previousValidity && !isValid { + showError = true + } else if (!previousValidity && isValid) { + showError = false } } - - //-------------------------------------------------- - // MARK: - Picker Delegate - //-------------------------------------------------- - - @objc public override func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 } - - @objc public override func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - pickerData.count - } - - @objc public func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { - guard !pickerData.isEmpty else { return nil } - - return pickerData[row] - } - - @objc public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { - guard !pickerData.isEmpty else { return } - - itemDropdownEntryFieldModel?.selectedIndex = row - observeDropdownChange?(text, pickerData[row]) - text = pickerData[row] - } } diff --git a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryFieldModel.swift b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryFieldModel.swift index 1c88ddab..eb83a48a 100644 --- a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryFieldModel.swift +++ b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/Dropdown Fields/Item Dropdown/ItemDropdownEntryFieldModel.swift @@ -5,16 +5,22 @@ // Created by Kevin Christiano on 1/22/20. // Copyright © 2020 Verizon Wireless. All rights reserved. // +import VDS -@objcMembers open class ItemDropdownEntryFieldModel: BaseItemPickerEntryFieldModel { +@objcMembers open class ItemDropdownEntryFieldModel: TextEntryFieldModel { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- public override class var identifier: String { "dropDown" } - + public var action: ActionModelProtocol? public var options: [String] = [] public var selectedIndex: Int? + public var showInlineLabel: Bool = false + public var feedbackTextPlacement: VDS.EntryFieldBase.HelperTextPlacement = .bottom + public var tooltip: TooltipModel? + public var transparentBackground: Bool = false + public var width: CGFloat? public init(with options: [String], selectedIndex: Int? = nil) { self.options = options @@ -42,6 +48,12 @@ private enum CodingKeys: String, CodingKey { case options case selectedIndex + case action + case showInlineLabel + case feedbackTextPlacement + case tooltip + case transparentBackground + case width } //-------------------------------------------------- @@ -58,6 +70,12 @@ self.selectedIndex = selectedIndex baseValue = options.indices.contains(selectedIndex) ? options[selectedIndex] : nil } + showInlineLabel = try typeContainer.decodeIfPresent(Bool.self, forKey: .showInlineLabel) ?? false + feedbackTextPlacement = try typeContainer.decodeIfPresent(VDS.EntryFieldBase.HelperTextPlacement.self, forKey: .feedbackTextPlacement) ?? .bottom + action = try typeContainer.decodeModelIfPresent(codingKey: .action) + tooltip = try typeContainer.decodeIfPresent(TooltipModel.self, forKey: .tooltip) + transparentBackground = try typeContainer.decodeIfPresent(Bool.self, forKey: .transparentBackground) ?? false + width = try typeContainer.decodeIfPresent(CGFloat.self, forKey: .width) } public override func encode(to encoder: Encoder) throws { @@ -65,5 +83,11 @@ var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(options, forKey: .options) try container.encodeIfPresent(selectedIndex, forKey: .selectedIndex) + try container.encode(showInlineLabel, forKey: .showInlineLabel) + try container.encode(feedbackTextPlacement, forKey: .feedbackTextPlacement) + try container.encodeModelIfPresent(action, forKey: .action) + try container.encodeIfPresent(tooltip, forKey: .tooltip) + try container.encode(transparentBackground, forKey: .transparentBackground) + try container.encodeIfPresent(width, forKey: .width) } } diff --git a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/EntryFieldModel.swift b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/EntryFieldModel.swift index 6f58cf64..1ef00628 100644 --- a/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/EntryFieldModel.swift +++ b/MVMCoreUI/Atomic/Atoms/FormFields/TextFields/EntryFieldModel.swift @@ -9,79 +9,39 @@ import Foundation -@objcMembers open class EntryFieldModel: MoleculeModelProtocol, FormFieldProtocol, FormRuleWatcherFieldProtocol, UIUpdatableModelProtocol, ClearableModelProtocol { +@objcMembers open class EntryFieldModel: FormFieldModel, ClearableModelProtocol { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- - - public class var identifier: String { "" } - public var id: String = UUID().uuidString - - public var backgroundColor: Color? - public var accessibilityIdentifier: String? public var shouldClearText: Bool = false - public var dynamicErrorMessage: String? { - didSet { - isValid = dynamicErrorMessage?.isEmpty ?? true - updateUIDynamicError?() - } - } - public var errorMessage: String? public var errorTextColor: Color? - public var enabled: Bool = true - public var required: Bool = true - public var readOnly: Bool = false - public var showError: Bool? public var hideBorders = false public var locked: Bool? public var selected: Bool? - public var text: String? - public var fieldKey: String? - public var groupName: String = FormValidator.defaultGroupName - public var baseValue: AnyHashable? public var wasInitiallySelected: Bool = false + public var text: String? public var title: String? public var feedback: String? public var shouldMaskRecordedView: Bool? = true //used to drive the EntryFieldView UI - public var titleStateLabel: FormLabelModel - public var feedbackStateLabel: FormLabelModel - - public var isValid: Bool? = true { - didSet { updateUI?() } - } - - /// Temporary binding mechanism for the view to update on enable changes. - public var updateUI: ActionBlock? - - // TODO: Remove once updateUI is fixed with isSelected - public var updateUIDynamicError: ActionBlock? + public var titleStateLabel = FormLabelModel(text: "") + public var feedbackStateLabel = FormLabelModel(text: "") //-------------------------------------------------- // MARK: - Keys //-------------------------------------------------- private enum CodingKeys: String, CodingKey { - case id - case moleculeName case backgroundColor - case accessibilityIdentifier case title - case enabled - case readOnly case feedback - case errorMessage case errorTextColor case locked case selected - case showError case hideBorders case text - case fieldKey - case groupName - case required case shouldMaskRecordedView } @@ -92,7 +52,7 @@ import Foundation // MARK: - Validation Methods //-------------------------------------------------- - public func formFieldValue() -> AnyHashable? { + open override func formFieldValue() -> AnyHashable? { guard enabled else { return nil } if dynamicErrorMessage != nil { @@ -100,30 +60,15 @@ import Foundation } return text } - - open func formFieldServerValue() -> AnyHashable? { - return formFieldValue() - } - - public func setValidity(_ valid: Bool, errorMessage: String?) { - - if let ruleErrorMessage = errorMessage, fieldKey != nil { - self.errorMessage = ruleErrorMessage - } - - self.isValid = valid - updateUI?() - } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public init(with text: String) { + super.init() self.text = text baseValue = text - self.titleStateLabel = FormLabelModel(text: "") - self.feedbackStateLabel = FormLabelModel(text: "") setDefaults() } @@ -131,7 +76,7 @@ import Foundation // MARK: - Initializers //-------------------------------------------------- public func clear() { - self.text = "" + text = "" } //-------------------------------------------------- @@ -139,54 +84,35 @@ import Foundation //-------------------------------------------------- required public init(from decoder: Decoder) throws { + try super.init(from: decoder) let typeContainer = try decoder.container(keyedBy: CodingKeys.self) - id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor) - accessibilityIdentifier = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityIdentifier) title = try typeContainer.decodeIfPresent(String.self, forKey: .title) feedback = try typeContainer.decodeIfPresent(String.self, forKey: .feedback) - errorMessage = try typeContainer.decodeIfPresent(String.self, forKey: .errorMessage) errorTextColor = try typeContainer.decodeIfPresent(Color.self, forKey: .errorTextColor) - enabled = try typeContainer.decodeIfPresent(Bool.self, forKey: .enabled) ?? true - required = try typeContainer.decodeIfPresent(Bool.self, forKey: .required) ?? true - readOnly = try typeContainer.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false locked = try typeContainer.decodeIfPresent(Bool.self, forKey: .locked) selected = try typeContainer.decodeIfPresent(Bool.self, forKey: .selected) text = try typeContainer.decodeIfPresent(String.self, forKey: .text) hideBorders = try typeContainer.decodeIfPresent(Bool.self, forKey: .hideBorders) ?? false baseValue = text - fieldKey = try typeContainer.decodeIfPresent(String.self, forKey: .fieldKey) shouldMaskRecordedView = try typeContainer.decodeIfPresent(Bool.self, forKey: .shouldMaskRecordedView) ?? shouldMaskRecordedView - if let groupName = try typeContainer.decodeIfPresent(String.self, forKey: .groupName) { - self.groupName = groupName - } - self.titleStateLabel = FormLabelModel(text: title ?? "") - self.feedbackStateLabel = FormLabelModel(model: LabelModel(text: feedback ?? "", + titleStateLabel = FormLabelModel(text: title ?? "") + feedbackStateLabel = FormLabelModel(model: LabelModel(text: feedback ?? "", fontStyle: FormLabelModel.defaultFontStyle, textColor: Color(uiColor: .mvmCoolGray6))) setDefaults() } - public func encode(to encoder: Encoder) throws { + open override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encodeIfPresent(moleculeName, forKey: .moleculeName) try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor) - try container.encodeIfPresent(accessibilityIdentifier, forKey: .accessibilityIdentifier) try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(feedback, forKey: .feedback) try container.encodeIfPresent(text, forKey: .text) try container.encodeIfPresent(locked, forKey: .locked) - try container.encodeIfPresent(showError, forKey: .showError) try container.encodeIfPresent(selected, forKey: .selected) try container.encodeIfPresent(errorTextColor, forKey: .errorTextColor) - try container.encodeIfPresent(errorMessage, forKey: .errorMessage) - try container.encodeIfPresent(fieldKey, forKey: .fieldKey) - try container.encodeIfPresent(groupName, forKey: .groupName) - - try container.encode(readOnly, forKey: .readOnly) - try container.encode(enabled, forKey: .enabled) - try container.encode(required, forKey: .required) try container.encode(hideBorders, forKey: .hideBorders) try container.encode(shouldMaskRecordedView, forKey: .shouldMaskRecordedView) } diff --git a/MVMCoreUI/Atomic/Atoms/Views/TooltipModel.swift b/MVMCoreUI/Atomic/Atoms/Views/TooltipModel.swift index 2b12cead..974cca8c 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/TooltipModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/TooltipModel.swift @@ -96,3 +96,17 @@ open class TooltipModel: MoleculeModelProtocol { } } + +extension TooltipModel { + public func toVDSTooltipModel() -> Tooltip.TooltipModel { + var moleculeView: MoleculeViewProtocol? + if let molecule, let view = ModelRegistry.createMolecule(molecule) { + moleculeView = view + } + return .init(closeButtonText: closeButtonText, + title: title, + content: content, + contentView: moleculeView + ) + } +} diff --git a/MVMCoreUI/Atomic/Extensions/VDS-Enums+Codable.swift b/MVMCoreUI/Atomic/Extensions/VDS-Enums+Codable.swift index f4eb5500..8f565216 100644 --- a/MVMCoreUI/Atomic/Extensions/VDS-Enums+Codable.swift +++ b/MVMCoreUI/Atomic/Extensions/VDS-Enums+Codable.swift @@ -25,21 +25,31 @@ extension VDS.ButtonIcon.Size: Codable {} extension VDS.ButtonIcon.BadgeIndicatorModel.ExpandDirection: Codable {} extension VDS.ButtonIcon.SurfaceType: Codable {} extension VDS.ButtonGroup.Alignment: Codable {} +extension VDS.CarouselScrollbar.Layout: Codable {} +extension VDS.DatePicker.DateFormat: Codable {} +extension VDS.EntryFieldBase.HelperTextPlacement: Codable {} extension VDS.Icon.Name: Codable {} extension VDS.Icon.Size: Codable {} +extension VDS.InputField.CreditCardType: Codable {} +extension VDS.InputField.DateFormat: Codable {} +extension VDS.InputField.FieldType: Codable {} +extension VDS.Line.Style: Codable {} +extension VDS.Line.Orientation: Codable {} extension VDS.Tabs.Orientation: Codable {} extension VDS.Tabs.IndicatorPosition: Codable {} extension VDS.Tabs.Overflow: Codable {} extension VDS.Tabs.Size: Codable {} +extension VDS.TextArea.Height: Codable {} extension VDS.TextLink.Size: Codable {} extension VDS.TextLinkCaret.IconPosition: Codable {} extension VDS.TileContainerBase.AspectRatio: Codable {} extension VDS.Tilelet.Padding: Codable {} extension VDS.TitleLockup.TextAlignment: Codable {} +extension VDS.Toggle.TextSize: Codable {} +extension VDS.Toggle.TextPosition: Codable {} +extension VDS.Toggle.TextWeight: Codable {} extension VDS.Tooltip.FillColor: Codable {} extension VDS.Tooltip.Size: Codable {} -extension VDS.Line.Style: Codable {} -extension VDS.Line.Orientation: Codable {} extension VDS.Use: Codable {} extension VDS.Button.Size: RawRepresentableCodable { diff --git a/MVMCoreUI/FormUIHelpers/FormFieldProtocol.swift b/MVMCoreUI/FormUIHelpers/FormFieldProtocol.swift index 7bd05047..8490cd69 100644 --- a/MVMCoreUI/FormUIHelpers/FormFieldProtocol.swift +++ b/MVMCoreUI/FormUIHelpers/FormFieldProtocol.swift @@ -6,7 +6,7 @@ // Copyright © 2020 Verizon Wireless. All rights reserved. // // Form fields are items can be interacted with. They have value, and may need to be validated. - +import VDS public protocol FormFieldProtocol: FormItemProtocol { @@ -36,6 +36,21 @@ public extension FormFieldProtocol { } } +public protocol FormFieldInternalValidatableProtocol: FormFieldProtocol { + associatedtype ValueType = AnyHashable + var rules: [AnyRule]? { get set } + var internalRules: [RuleAnyModelProtocol]? { get } +} + +extension FormFieldInternalValidatableProtocol { + public var internalRules: [RuleAnyModelProtocol]? { + guard let fieldKey else { return nil } + return rules?.compactMap{ rule in + return RuleVDSModel(field: fieldKey, rule: rule) + } + } +} + public class FormFieldValidity{ public var fieldKey: String public var valid: Bool = true diff --git a/MVMCoreUI/FormUIHelpers/FormValidator.swift b/MVMCoreUI/FormUIHelpers/FormValidator.swift index 3aa50030..b01c7ad4 100644 --- a/MVMCoreUI/FormUIHelpers/FormValidator.swift +++ b/MVMCoreUI/FormUIHelpers/FormValidator.swift @@ -44,6 +44,29 @@ import MVMCore if let fieldKey = field.fieldKey { fields[fieldKey] = field } + // add internal validators if needed + if let field = field as? any FormFieldInternalValidatableProtocol { + addInternalRules(field) + } + } + + /// Adds additional Rules that are from another source + private func addInternalRules(_ field: any FormFieldInternalValidatableProtocol) { + if let internalRules = field.internalRules, !internalRules.isEmpty { + + //find the group + if let formGroup = formRules?.first(where: {$0.groupName == field.groupName}) { + formGroup.rules.append(contentsOf: internalRules) + } else { + //create the new group + let formGroup = FormGroupRule(field.groupName, internalRules, []) + if var formRules { + formRules.append(formGroup) + } else { + formRules = [formGroup] + } + } + } } /// Adds the form action to the validator. @@ -72,7 +95,6 @@ import MVMCore if let validator = delegate?.formValidator { validator.delegate = delegate validator.insert(item) - // TODO: Temporary hacks, rewrite architecture to support this. _ = validator.validate() } diff --git a/MVMCoreUI/FormUIHelpers/Rules/Rules/RuleVDSModel.swift b/MVMCoreUI/FormUIHelpers/Rules/Rules/RuleVDSModel.swift new file mode 100644 index 00000000..e4cfb2aa --- /dev/null +++ b/MVMCoreUI/FormUIHelpers/Rules/Rules/RuleVDSModel.swift @@ -0,0 +1,59 @@ +// +// RuleVDSModel.swift +// MVMCoreUI +// +// Created by Matt Bruce on 7/12/24. +// Copyright © 2024 Verizon Wireless. All rights reserved. +// + +import Foundation +import VDS + +open class VDSRuleBase: RuleAnyModelProtocol { + open var ruleId: String? + open var errorMessage: [String : String]? + open var fields = [String]() + public init(){} + + open func isValid(_ formField: any FormFieldProtocol) -> Bool { + fatalError() + } + public static var identifier: String = "AnyVDSRule" +} + +public class RuleVDSModel: VDSRuleBase { + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + public var rule: AnyRule + + //-------------------------------------------------- + // MARK: - Initializer + //-------------------------------------------------- + + public init(field: String, rule: AnyRule) { + self.rule = rule + super.init() + self.fields = [field] + self.ruleId = "\(rule.self)-\(Int.random(in: 1...1000))" + } + + required init(from decoder: any Decoder) throws { + fatalError("init(from:) has not been implemented") + } + + //-------------------------------------------------- + // MARK: - Validation + //-------------------------------------------------- + + public override func isValid(_ formField: FormFieldProtocol) -> Bool { + let value = formField.formFieldValue() as? ValueType + let valid = rule.isValid(value: value) + if let field = fields.first, !valid { + errorMessage = [field: rule.errorMessage] + } else { + errorMessage = nil + } + return valid + } +}