diff --git a/Example-Swift/Tests/FeaturesInteractorSpec.swift b/Example-Swift/Tests/FeaturesInteractorSpec.swift index 221d7ac..6b1c70b 100644 --- a/Example-Swift/Tests/FeaturesInteractorSpec.swift +++ b/Example-Swift/Tests/FeaturesInteractorSpec.swift @@ -38,7 +38,8 @@ class FeatureInteractorSpec: QuickSpec { describe("Feature Registry") { it("Initializes properly") { - let presenter = FeaturesPresenter(withFeatures: []) + let presenter = FeaturesPresenter(withFeatures: [], + colorProvider: DefaultFeatureStateColorProvider()) let interactor = FeaturesInteractor(withPresenter: presenter) expect(interactor).to(be(interactor)) @@ -48,7 +49,8 @@ class FeatureInteractorSpec: QuickSpec { let registry = TestRegistry(withFeatureStore: nil) context("shouldHighlightRowAt") { - let presenter = FeaturesPresenter(withFeatures: registry.featureItems) + let presenter = FeaturesPresenter(withFeatures: registry.featureItems, + colorProvider: DefaultFeatureStateColorProvider()) let interactor = FeaturesInteractor(withPresenter: presenter) it("false for cells") { @@ -70,7 +72,8 @@ class FeatureInteractorSpec: QuickSpec { var output: UIViewController! beforeEach { - presenter = FeaturesPresenter(withFeatures: registry.featureItems) + presenter = FeaturesPresenter(withFeatures: registry.featureItems, + colorProvider: DefaultFeatureStateColorProvider()) interactor = FeaturesInteractor(withPresenter: presenter) testTableView = TestTableView(frame: .zero, style: .plain) } @@ -137,7 +140,8 @@ class FeatureInteractorIosSpec: QuickSpec { let registry = TestRegistry(withFeatureStore: nil) var features = registry.featureItems features.append(CustomFeature()) - let presenter = FeaturesPresenter(withFeatures: features) + let presenter = FeaturesPresenter(withFeatures: features, + colorProvider: DefaultFeatureStateColorProvider()) let interactor = FeaturesInteractor(withPresenter: presenter) it("does not configure swipe for groups") { diff --git a/README.md b/README.md index 01628a1..b91bbbf 100644 --- a/README.md +++ b/README.md @@ -222,14 +222,82 @@ Now using `FConfigFeature` in our feature registry is just more of the same: Override ships with a simple table view controller – `FeaturesViewController` – that provides a generic user interface for managing feature flags. The view controller shows a list of available features from a given `FeatureRegistry`. It also allows you to find features using text search. Each feature state is depicted visually, and swipe gestures are installed that allow for convienant feature control. Feature state is conveyed visually: -- Overridden *enabled* features are in green -- Overridden *disabled* features are shown in red -- Underlined features – which are either green or red – are overriding the defaults +- Overridden *enabled* features are in green (or custom enabled color if a color provider is specified) +- Overridden *disabled* features are shown in red (or custom disabled color if a color provider is specified) +- Underlined features – which are either green or red – are overriding the defaults Feature state is controlled by gesture: - Slide left to reveal the "on" and "off" force overrides - Slide right to restore the default state of the feature +#### Customizing Feature State Colors + +You can customize the colors used for enabled and disabled feature states by providing a `FeatureStateColorProvider`. This is particularly useful for visually distinguishing between different types of feature flags (e.g., server-side vs client-side flags). + +**Using a Closure Provider (Swift):** + +```swift +let colorProvider = ClosureFeatureStateColorProvider { feature in + // Distinguish server-side vs client-side flags + if feature.key?.hasPrefix("server_") == true { + return FeatureStateColors(enabledColor: .blue, disabledColor: .orange) + } else if feature.key?.hasPrefix("client_") == true { + return FeatureStateColors(enabledColor: .green, disabledColor: .red) + } else { + return FeatureStateColors.defaultStateColors + } +} + +let viewController = FeaturesTableViewController( + features: registry.features, + colorProvider: colorProvider +) +``` + +**Custom Implementation (Swift):** + +```swift +class MyColorProvider: NSObject, FeatureStateColorProvider { + func colors(for feature: AnyFeature) -> FeatureStateColors { + // Custom logic based on feature properties + if feature.requiresRestart { + return FeatureStateColors(enabledColor: .systemYellow, disabledColor: .systemRed) + } + return FeatureStateColors.defaultStateColors + } +} + +let viewController = FeaturesTableViewController( + features: registry.features, + colorProvider: MyColorProvider() +) +``` + +**Objective-C:** + +```objc +@interface MyColorProvider : NSObject +@end + +@implementation MyColorProvider +- (FeatureStateColors *)colorsForFeature:(id)feature { + if ([feature.key hasPrefix:@"server_"]) { + return [[FeatureStateColors alloc] initWithEnabledColor:[UIColor blueColor] + disabledColor:[UIColor orangeColor]]; + } + return FeatureStateColors.defaultStateColors; +} +@end + +MyColorProvider *provider = [[MyColorProvider alloc] init]; +FeaturesTableViewController *vc = [[FeaturesTableViewController alloc] + initWithFeatures:registry.features + colorProvider:provider]; +``` + + +If no color provider is specified, the default colors (green for enabled, red for disabled) are used. + #### Support "Restart Required" Features Sometimes enabling or disabling a feature is unsafe or impractical to do after the app has finished loading. diff --git a/Source/UI/Cells/FeatureSwitchCell.swift b/Source/UI/Cells/FeatureSwitchCell.swift index ca1c739..d3a6c7b 100644 --- a/Source/UI/Cells/FeatureSwitchCell.swift +++ b/Source/UI/Cells/FeatureSwitchCell.swift @@ -15,33 +15,7 @@ class FeatureSwitchCell: FeatureTableViewCell { let underlineStyleSingle = NSUnderlineStyle.styleSingle #endif - var labeledFeature: LabeledFeatureItem? { - didSet { - guard let feature = labeledFeature?.feature, - let label = labeledFeature?.label, - let textLabel = textLabel - else { return } - - let labelColor = feature.enabled ? UIColor.mulah : UIColor.swedishFish - - let labelString = NSMutableAttributedString(string: label.unCamelCased) - var attrs: [AttributedStringKey: Any] = [ - AttributedStringKey.font: textLabel.font as UIFont, - AttributedStringKey.foregroundColor: labelColor - ] - - // add emphasis if this is locally overridden by underlining the label - if feature.override != .featureDefault { - attrs[AttributedStringKey.underlineStyle] = underlineStyleSingle.rawValue - attrs[AttributedStringKey.underlineColor] = labelColor - } - labelString.addAttributes(attrs, range: NSRange(location: 0, length: labelString.length)) - - textLabel.attributedText = labelString - - self.setNeedsLayout() - } - } + private var labeledFeature: LabeledFeatureItem? var featurePath: [LabeledGroupItem]? { didSet { @@ -78,4 +52,32 @@ class FeatureSwitchCell: FeatureTableViewCell { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(with labeledFeature: LabeledFeatureItem?, + colorProvider: FeatureStateColorProvider) { + guard let feature = labeledFeature?.feature, + let label = labeledFeature?.label, + let textLabel = textLabel + else { return } + + let colors = colorProvider.colors(for: feature) + let labelColor = feature.enabled ? colors.enabledColor : colors.disabledColor + + let labelString = NSMutableAttributedString(string: label.unCamelCased) + var attrs: [AttributedStringKey: Any] = [ + AttributedStringKey.font: textLabel.font as UIFont, + AttributedStringKey.foregroundColor: labelColor + ] + + // add emphasis if this is locally overridden by underlining the label + if feature.override != .featureDefault { + attrs[AttributedStringKey.underlineStyle] = underlineStyleSingle.rawValue + attrs[AttributedStringKey.underlineColor] = labelColor + } + labelString.addAttributes(attrs, range: NSRange(location: 0, length: labelString.length)) + + textLabel.attributedText = labelString + + self.setNeedsLayout() + } } diff --git a/Source/UI/FeatureStateColorProvider.swift b/Source/UI/FeatureStateColorProvider.swift new file mode 100644 index 0000000..b43f65b --- /dev/null +++ b/Source/UI/FeatureStateColorProvider.swift @@ -0,0 +1,56 @@ +// Copyright 2019, Oath Inc. +// Licensed under the terms of the MIT license. See LICENSE file in https://github.com/yahoo/Override for terms. + +import Foundation +import UIKit + +/// A container for feature state colors +@objc public class FeatureStateColors: NSObject { + @objc public let enabledColor: UIColor + @objc public let disabledColor: UIColor + + @objc public init(enabledColor: UIColor, disabledColor: UIColor) { + self.enabledColor = enabledColor + self.disabledColor = disabledColor + super.init() + } + + @objc public static let defaultStateColors: FeatureStateColors = { + FeatureStateColors(enabledColor: .mulah, disabledColor: .swedishFish) + }() +} + +/// A protocol for providing colors based on feature state. +/// Implement this protocol to customize colors for enabled and disabled feature states. +@objc public protocol FeatureStateColorProvider: NSObjectProtocol { + /// Returns the colors to use for a given feature's enabled and disabled states. + /// + /// - Parameter feature: The feature to resolve colors for + /// - Returns: A FeatureStateColors object containing the enabled color and disabled color + func colors(for feature: AnyFeature) -> FeatureStateColors +} + +/// Default implementation that uses the standard mulah (green) and swedishFish (red) colors. +@objc public class DefaultFeatureStateColorProvider: NSObject, FeatureStateColorProvider { + @objc public override init() { + super.init() + } + + @objc public func colors(for feature: AnyFeature) -> FeatureStateColors { + FeatureStateColors.defaultStateColors + } +} + +/// Adapter that allows using a closure as a FeatureStateColorProvider +public class ClosureFeatureStateColorProvider: NSObject, FeatureStateColorProvider { + private let resolver: (AnyFeature) -> FeatureStateColors + + public init(_ resolver: @escaping (AnyFeature) -> FeatureStateColors) { + self.resolver = resolver + super.init() + } + + public func colors(for feature: AnyFeature) -> FeatureStateColors { + return resolver(feature) + } +} diff --git a/Source/UI/FeaturesInteractor_iOS.swift b/Source/UI/FeaturesInteractor_iOS.swift index 7415c10..f0a4d9b 100644 --- a/Source/UI/FeaturesInteractor_iOS.swift +++ b/Source/UI/FeaturesInteractor_iOS.swift @@ -33,7 +33,9 @@ extension FeaturesInteractor { /* UITableViewDelegate */ completion: callback) } - action.backgroundColor = labeledFeature.feature.defaultState ? UIColor.mulah : UIColor.swedishFish + let colors = presenter.colorProvider.colors(for: labeledFeature.feature) + let backgroundColor = labeledFeature.feature.defaultState ? colors.enabledColor : colors.disabledColor + action.backgroundColor = backgroundColor return UISwipeActionsConfiguration(actions: [action]) } @@ -64,8 +66,9 @@ extension FeaturesInteractor { /* UITableViewDelegate */ completion: callback) } - enableAction.backgroundColor = UIColor.mulah - disableAction.backgroundColor = UIColor.swedishFish + let colors = presenter.colorProvider.colors(for: labeledFeature.feature) + enableAction.backgroundColor = colors.enabledColor + disableAction.backgroundColor = colors.disabledColor var actions: [UIContextualAction] switch labeledFeature.feature.override { diff --git a/Source/UI/FeaturesPresenter.swift b/Source/UI/FeaturesPresenter.swift index 4891b00..75a52b4 100644 --- a/Source/UI/FeaturesPresenter.swift +++ b/Source/UI/FeaturesPresenter.swift @@ -27,11 +27,15 @@ class FeaturesPresenter: NSObject, UITableViewDataSource { var features: [LabeledItem] { filteredFeatures ?? allFeatures } + let colorProvider: FeatureStateColorProvider + private let allFeatures: [LabeledItem] private var filteredFeatures: [LabeledSearchResultItem]? - init(withFeatures features: [LabeledItem]) { + init(withFeatures features: [LabeledItem], + colorProvider: FeatureStateColorProvider) { self.allFeatures = features + self.colorProvider = colorProvider } /// If the feature require restart (see `featuresRequiringRestart`), presents @@ -77,7 +81,8 @@ extension FeaturesPresenter { /* UITableViewDataSource */ let cellID = FeaturesTableViewController.FeatureCellIdentifier.switchCell.rawValue let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) if let cell = cell as? FeatureSwitchCell { - cell.labeledFeature = item.result + cell.configure(with: item.result, + colorProvider: colorProvider) cell.featurePath = item.groupStack } return cell @@ -87,7 +92,8 @@ extension FeaturesPresenter { /* UITableViewDataSource */ let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) if let cell = cell as? FeatureSwitchCell { - cell.labeledFeature = labeledItem as? LabeledFeatureItem + cell.configure(with: labeledItem as? LabeledFeatureItem, + colorProvider: colorProvider) } return cell } @@ -140,7 +146,8 @@ extension FeaturesPresenter { /* UITableViewController Support Methods */ func present(_ tableView: UITableView, groupAtIndexPath indexPath: IndexPath) { guard let labeledGroup = features[indexPath.row] as? LabeledGroupItem else { return } - let groupTableViewController = FeaturesTableViewController(features: labeledGroup) + let groupTableViewController = FeaturesTableViewController(features: labeledGroup, + colorProvider: colorProvider) if let navController = output?.navigationController { navController.pushViewController(groupTableViewController, animated: true) diff --git a/Source/UI/FeaturesTableViewController.swift b/Source/UI/FeaturesTableViewController.swift index b748996..299fc9b 100644 --- a/Source/UI/FeaturesTableViewController.swift +++ b/Source/UI/FeaturesTableViewController.swift @@ -15,10 +15,15 @@ import UIKit let interactor: FeaturesInteractor - init(features: C) where C.Element == LabeledItem { + let colorProvider: FeatureStateColorProvider + + init(features: C, + colorProvider: FeatureStateColorProvider? = nil) where C.Element == LabeledItem { let arrayFeatures = Array(features) - presenter = FeaturesPresenter(withFeatures: arrayFeatures) + self.colorProvider = colorProvider ?? DefaultFeatureStateColorProvider() + presenter = FeaturesPresenter(withFeatures: arrayFeatures, + colorProvider: self.colorProvider) interactor = FeaturesInteractor(withPresenter: presenter) super.init(style: UITableView.Style.plain) @@ -91,9 +96,11 @@ extension FeaturesTableViewController { /// allows use of this table view controller without the navigation /// controller provided by FeaturesViewController. /// - /// - Parameter featureRegistry: The feature registry to use - convenience init(featureRegistry: FeatureRegistry) { - self.init(features: featureRegistry.features) + /// - Parameters: + /// - featureRegistry: The feature registry to use + /// - colorProvider: Optional color provider for customizing feature state colors + convenience init(featureRegistry: FeatureRegistry, colorProvider: FeatureStateColorProvider? = nil) { + self.init(features: featureRegistry.features, colorProvider: colorProvider) } } @@ -103,8 +110,10 @@ public extension FeaturesTableViewController { /// allows use of this table view controller without the navigation /// controller provided by FeaturesViewController. /// - /// - Parameter featureRegistry: The feature registry to use - convenience init(registry: FeatureRegistry) { - self.init(features: registry.features) + /// - Parameters: + /// - registry: The feature registry to use + /// - colorProvider: Optional color provider for customizing feature state colors + convenience init(registry: FeatureRegistry, colorProvider: FeatureStateColorProvider? = nil) { + self.init(features: registry.features, colorProvider: colorProvider) } }