Skip to content

Commit 6c704ba

Browse files
author
Prachi Gauriar
committed
Add editor types for JSON, arrays, and case iterable strings and ints
1 parent 4c309ca commit 6c704ba

29 files changed

Lines changed: 1846 additions & 211 deletions

App/Sources/App/ContentViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ final class ContentViewModel {
3838
let jsonVariable = ConfigVariable(
3939
key: "complexConfig",
4040
defaultValue: ComplexConfiguration(field1: "a", field2: 1),
41-
content: .json(representation: .data)
41+
content: .json(representation: .string())
4242
).metadata(\.displayName, "Complex Config")
4343

4444
let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades, isSecret: true)
@@ -96,15 +96,15 @@ struct ComplexConfiguration: Codable, Hashable, Sendable {
9696
}
9797

9898

99-
enum Beatle: String, Codable, Hashable, Sendable {
99+
enum Beatle: String, CaseIterable, Codable, Hashable, Sendable {
100100
case john = "John"
101101
case paul = "Paul"
102102
case george = "George"
103103
case ringo = "Ringo"
104104
}
105105

106106

107-
enum CardSuit: Int, Codable, Hashable, Sendable {
107+
enum CardSuit: Int, CaseIterable, Codable, Hashable, Sendable {
108108
case spades
109109
case hearts
110110
case clubs
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Editor Validation Implementation Plan
2+
3+
This document tracks the implementation of input validation and enhanced editor controls
4+
for the config variable detail view.
5+
6+
7+
## Completed Work
8+
9+
### Phase 1: `validate` Closure on `ConfigVariableContent`
10+
11+
- Added `let validate: (@Sendable (_ content: ConfigContent) -> Bool)?` property
12+
- All 10 primitive factories pass `validate: nil`
13+
- `rawRepresentableString()` validates `Value(rawValue:) != nil`
14+
- `rawRepresentableInt()` validates `Value(rawValue:) != nil`
15+
- `expressibleByConfigString()` validates `Value(configString:) != nil`
16+
- `expressibleByConfigInt()` validates `Value(configInt:) != nil`
17+
- `codable()` attempts decode via `representation.data(from:)` then decoder
18+
19+
### Phase 2: `EditorControl` Extensions + `RegisteredConfigVariable`
20+
21+
- Added `EditorControl.PickerOption` nested struct (label + content)
22+
- Added `.textEditor` and `.picker(options:)` cases to `EditorControl.Kind`
23+
- Added `pickerOptions` computed property on `EditorControl`
24+
- Added `validate` property to `RegisteredConfigVariable`, threaded through init
25+
- Updated `ConfigVariableReader.register()` to pass `validate`
26+
- Updated all test call sites (RegisteredConfigVariableTests, random helper)
27+
28+
### Phase 3: CaseIterable Content Factories
29+
30+
- `rawRepresentableCaseIterableString()` — picker with `rawValue` labels
31+
- `rawRepresentableCaseIterableInt()` — picker with `String(describing:)` labels
32+
- Both use `parse: nil, validate: nil` (picker constrains input)
33+
34+
### Phase 4: Codable JSON Editing
35+
36+
- `json()` passes `.textEditor` + parse `{ .string($0) }` for string representations
37+
- `propertyList()` always passes `.none` + `nil` (not text-editable)
38+
- `codable()` private method accepts `editorControl` and `parse` parameters
39+
- Added `CodableValueRepresentation.supportsTextEditing` computed property
40+
- Added `CodableValueRepresentation.data(from:)` for extracting Data from
41+
ConfigContent
42+
43+
### Phase 5: Array Editing (Newline-Separated)
44+
45+
- All 8 array factories now use `.textEditor`
46+
- Primitive arrays: parse splits by newlines, no validate
47+
- RawRepresentable arrays: parse + validate checks each element
48+
- ExpressibleByConfig arrays: parse + validate checks each element
49+
- Added `String.nonEmptyTrimmedLines` helper extension
50+
- Added `ConfigContent.editableString` — newline-separated for arrays
51+
- View model uses `editableString` instead of `displayString` for `overrideText`
52+
53+
### Phase 6: `ConfigVariable` Initializers for CaseIterable
54+
55+
- Added init for `RawRepresentable<String> & CaseIterable`
56+
- Added init for `RawRepresentable<Int> & CaseIterable`
57+
- More specific constraints win overload resolution over existing inits
58+
59+
### Phase 7: View Model Protocol + Implementation
60+
61+
- Added `overridePickerSelection: ConfigContent { get set }` to protocol
62+
- Added `isOverrideTextValid: Bool { get }` to protocol
63+
- `ConfigVariableDetailViewModel.isOverrideTextValid` runs parse then validate
64+
- `commitOverrideText()` checks validate before committing
65+
- `overridePickerSelection` gets/sets override content directly
66+
67+
### Phase 8: View Updates
68+
69+
- **Toggle**: Unchanged
70+
- **Picker**: `Picker` bound to `overridePickerSelection`, iterates `pickerOptions`
71+
- **TextEditor**: Monospaced, `.autocorrectionDisabled()`,
72+
iOS `.textInputAutocapitalization(.never)` + `.keyboardType(.asciiCapable)`,
73+
red border on invalid, `@FocusState` for dismiss
74+
- TextEditor has "Apply" button (`.buttonStyle(.borderless)`, disabled when invalid,
75+
dismisses focus)
76+
- **TextField**: Red `foregroundStyle` when invalid
77+
- Added localized strings: `valuePicker`, `applyButton`
78+
79+
80+
## Files Modified (Source)
81+
82+
- `Sources/DevConfiguration/Core/ConfigVariableContent.swift`
83+
- `Sources/DevConfiguration/Core/ConfigVariable.swift`
84+
- `Sources/DevConfiguration/Core/EditorControl.swift`
85+
- `Sources/DevConfiguration/Core/RegisteredConfigVariable.swift`
86+
- `Sources/DevConfiguration/Core/ConfigVariableReader.swift`
87+
- `Sources/DevConfiguration/Core/CodableValueRepresentation.swift`
88+
- `Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift`
89+
- `Sources/DevConfiguration/Extensions/String+NonEmptyTrimmedLines.swift` (new)
90+
- `Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift`
91+
- `Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift`
92+
- `Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift`
93+
- `Sources/DevConfiguration/Resources/Localizable.xcstrings`
94+
95+
96+
## Files Modified (Tests)
97+
98+
- `Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift`
99+
- `Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift`
100+
- `Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift`
101+
102+
103+
## Remaining: Phase 9 — Tests
104+
105+
### 1. `String+NonEmptyTrimmedLines` Tests
106+
107+
New file:
108+
`Tests/DevConfigurationTests/Unit Tests/Extensions/String+NonEmptyTrimmedLinesTests.swift`
109+
110+
- Empty string returns empty array
111+
- Single line returns one trimmed element
112+
- Multiple lines returns trimmed elements
113+
- Blank/whitespace-only lines are filtered out
114+
- Leading/trailing whitespace on lines is trimmed
115+
116+
### 2. `ConfigContent.editableString` Tests
117+
118+
Add to existing:
119+
`Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift`
120+
121+
- Scalar values (bool, int, double, string) return same as `displayString`
122+
- `boolArray` returns newline-separated (e.g., `"true\nfalse"`)
123+
- `intArray` returns newline-separated (e.g., `"1\n2\n3"`)
124+
- `doubleArray` returns newline-separated
125+
- `stringArray` returns newline-separated
126+
- `byteChunkArray` returns same as `displayString`
127+
128+
### 3. `CodableValueRepresentation` Tests
129+
130+
New file:
131+
`Tests/DevConfigurationTests/Unit Tests/Core/CodableValueRepresentationTests.swift`
132+
133+
- `data(from:)` with string representation extracts string as UTF-8 data
134+
- `data(from:)` with string representation returns nil for non-string content
135+
- `data(from:)` with data representation extracts bytes as Data
136+
- `data(from:)` with data representation returns nil for non-bytes content
137+
- `supportsTextEditing` returns true for `.string()`
138+
- `supportsTextEditing` returns false for `.data`
139+
140+
### 4. `EditorControl.pickerOptions` Tests
141+
142+
New file: `Tests/DevConfigurationTests/Unit Tests/Core/EditorControlTests.swift`
143+
144+
- `.picker(options:)` returns the options
145+
- `.toggle`, `.textField`, `.numberField`, `.decimalField`, `.textEditor`, `.none`
146+
all return nil
147+
148+
### 5. `ConfigVariableDetailViewModel` Tests
149+
150+
Add to existing:
151+
`Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift`
152+
153+
- `isOverrideTextValid` returns true when `validate` is nil and parse succeeds
154+
- `isOverrideTextValid` returns true when both parse and validate succeed
155+
- `isOverrideTextValid` returns false when parse fails
156+
- `isOverrideTextValid` returns false when parse succeeds but validate fails
157+
- `commitOverrideText` sets override when valid
158+
- `commitOverrideText` does not set override when validation fails
159+
- `overridePickerSelection` getter returns override or default content
160+
- `overridePickerSelection` setter sets override on document
161+
- `editableString` is used for initial `overrideText` (verify array format)
162+
163+
### 6. Registration Tests
164+
165+
Update existing:
166+
`Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift`
167+
168+
- Verify `validate` is captured for registered variables (e.g., register a
169+
`RawRepresentable<String>` variable and check `validate` is non-nil)

Sources/DevConfiguration/Core/CodableValueRepresentation.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ public struct CodableValueRepresentation: Sendable {
4747
CodableValueRepresentation(kind: .data)
4848
}
4949

50+
/// Whether this representation supports text-based editing in the editor UI.
51+
///
52+
/// String-backed representations can be edited as text, while data-backed representations cannot.
53+
var supportsTextEditing: Bool {
54+
switch kind {
55+
case .string: true
56+
case .data: false
57+
}
58+
}
59+
5060

5161
/// Reads raw data synchronously from the reader based on this representation.
5262
///
@@ -129,6 +139,30 @@ public struct CodableValueRepresentation: Sendable {
129139
}
130140

131141

142+
/// Extracts raw `Data` from a ``ConfigContent`` based on this representation.
143+
///
144+
/// This is the reverse of ``encodeToContent(_:)``. For string-backed representations, this extracts the string and
145+
/// converts it to `Data` using the representation's encoding. For data-backed representations, this extracts the
146+
/// byte array and wraps it in `Data`.
147+
///
148+
/// - Parameter content: The content to extract data from.
149+
/// - Returns: The raw data, or `nil` if the content doesn't match this representation's expected case.
150+
func data(from content: ConfigContent) -> Data? {
151+
switch kind {
152+
case .string(let encoding):
153+
guard case .string(let string) = content else {
154+
return nil
155+
}
156+
return string.data(using: encoding)
157+
case .data:
158+
guard case .bytes(let bytes) = content else {
159+
return nil
160+
}
161+
return Data(bytes)
162+
}
163+
}
164+
165+
132166
/// Watches for raw data changes from the reader based on this representation.
133167
///
134168
/// Each time the underlying configuration value changes, `onUpdate` is called with the new raw data (or `nil` if the

Sources/DevConfiguration/Core/ConfigVariable.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,28 @@ extension ConfigVariable {
280280
}
281281

282282

283+
extension ConfigVariable {
284+
/// Creates a `RawRepresentable<String> & CaseIterable` configuration variable.
285+
///
286+
/// Content is set to ``ConfigVariableContent/rawRepresentableCaseIterableString()`` automatically. Uses a picker
287+
/// control populated with all cases instead of a free-text field.
288+
///
289+
/// - Parameters:
290+
/// - key: The configuration key.
291+
/// - defaultValue: The default value to use when variable resolution fails.
292+
/// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`.
293+
public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false)
294+
where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == String {
295+
self.init(
296+
key: key,
297+
defaultValue: defaultValue,
298+
content: .rawRepresentableCaseIterableString(),
299+
isSecret: isSecret
300+
)
301+
}
302+
}
303+
304+
283305
extension ConfigVariable {
284306
/// Creates a `[RawRepresentable<String>]` configuration variable.
285307
///
@@ -348,6 +370,23 @@ extension ConfigVariable {
348370
}
349371

350372

373+
extension ConfigVariable {
374+
/// Creates a `RawRepresentable<Int> & CaseIterable` configuration variable.
375+
///
376+
/// Content is set to ``ConfigVariableContent/rawRepresentableCaseIterableInt()`` automatically. Uses a picker
377+
/// control populated with all cases instead of a free-text number field.
378+
///
379+
/// - Parameters:
380+
/// - key: The configuration key.
381+
/// - defaultValue: The default value to use when variable resolution fails.
382+
/// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`.
383+
public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false)
384+
where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == Int {
385+
self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableCaseIterableInt(), isSecret: isSecret)
386+
}
387+
}
388+
389+
351390
extension ConfigVariable {
352391
/// Creates a `[RawRepresentable<Int>]` configuration variable.
353392
///

0 commit comments

Comments
 (0)