From 5e02bb57c3b77fa4bfb2d1b7faeadc2dbabf252d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:34:31 +0000 Subject: [PATCH] M3-06 slice 4d: choose a to-one from the inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an editable store, a to-one field on the inspector's Details tab can now be changed in place (EDT-3). - A link button, "Choose Object…" in the context menu and a VoiceOver action open the object picker. The object the field leads to now is marked Linked. What is chosen replaces it, staged as an edit of the field ("Edit head") - "Set to Nil" empties an optional to-one that leads somewhere - The picker is built in one place, ProjectContext.objectPicker(for:linked:choose:), which the relationships panel now uses too. toOneChoosing(_:value:of:) describes how a field is chosen, the way fieldEditing describes how one is typed, so the grid's text editor never gets a to-one - Tests: EditingSessionTests +1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AT9wkNZ97Eo3MvLLtP7JHP Signed-off-by: Claude --- App/CoreDataDabbi/Editing/ValueEditing.swift | 59 ++++++++++++++++++- App/CoreDataDabbi/Inspector/DetailsTab.swift | 7 ++- App/CoreDataDabbi/Inspector/FieldRow.swift | 36 ++++++++++- .../Inspector/InspectorModel.swift | 5 ++ .../Relationships/RelationshipsModel.swift | 14 ++--- .../Resources/Localizable.xcstrings | 3 + .../EditingSessionTests.swift | 53 +++++++++++++++++ docs/IMPLEMENTATION_PLAN.md | 3 +- 8 files changed, 163 insertions(+), 17 deletions(-) diff --git a/App/CoreDataDabbi/Editing/ValueEditing.swift b/App/CoreDataDabbi/Editing/ValueEditing.swift index 302f88f..105eb51 100644 --- a/App/CoreDataDabbi/Editing/ValueEditing.swift +++ b/App/CoreDataDabbi/Editing/ValueEditing.swift @@ -2,8 +2,9 @@ import DabbiKit import Foundation /// How a value is edited where it is shown (EDT-3): what can be typed into, what the typing becomes, and how it -/// is staged. The inspector's fields and the grid's cells both ask here, so that the rules are the same wherever -/// a value is edited. +/// is staged; and which object a to-one leads to, which is picked rather than typed. The inspector's fields, the +/// grid's cells and the relationships panel all ask here, so that the rules are the same wherever a value is +/// edited. extension ProjectContext { /// The attribute `name` of `object`'s own entity, when the store is open for editing and the attribute is one /// a person can type: stored, not derived, and of a type `ValueText` reads. @@ -46,4 +47,58 @@ extension ProjectContext { text: ValueText.text(for: value, timeZone: timeZone), stage: { [weak self] in self?.stage($0, for: attribute, of: object) }, clear: clear) } + + // MARK: To-ones + + /// The to-one relationship `name` of `object`'s own entity, when the store is open for editing: stored, and + /// changed by choosing the object it leads to. + func editableToOne(_ name: String, of object: PendingObjectID) -> RelationshipDescription? { + guard accessMode == .editable, + let relationship = model?.entity(named: object.entity)?.relationship(named: name), + !relationship.isToMany, !relationship.isTransient + else { return nil } + return relationship + } + + /// A picker of the saved objects `relationship` can lead to — its destination's and the sub-entities' — + /// labelled by the display attribute, the project's choice before the model's. Those in `linked` are marked + /// and not offered again; what is chosen goes to `choose`. + func objectPicker( + for relationship: RelationshipDescription, linked: Set, + choose: @escaping @MainActor ([ObjectRef]) -> Void + ) -> ObjectPicker? { + guard accessMode == .editable, let session, let model else { return nil } + let destination = relationship.destinationEntity + let displayAttribute = + layout(of: destination).displayAttribute ?? model.entity(named: destination)?.displayAttributeName + return ObjectPicker( + entity: destination, relationship: relationship.name, isToMany: relationship.isToMany, linked: linked, + session: session, model: model, displayAttribute: displayAttribute, onChoose: choose) + } + + /// How the to-one `name` of `object`, which holds `value` now, is changed — `nil` when it cannot be. The + /// object chosen replaces the one it leads to, as an edit of the field. + func toOneChoosing(_ name: String, value: Value, of object: PendingObjectID) -> ToOneChoosing? { + guard let relationship = editableToOne(name, of: object) else { return nil } + // The saved object it leads to is marked in the picker; one only inserted is not listed there at all. + let (linked, leadsSomewhere): (Set, Bool) = + switch value { + case .toOne(let ref?, _): ([ref], true) + case .toOneInserted: ([], true) + default: ([], false) + } + var clear: (@MainActor () -> Void)? + if relationship.isOptional, leadsSomewhere { + clear = { [weak self] in self?.editing.setValue(.null, for: name, of: object) } + } + return ToOneChoosing( + pick: { [weak self] in + guard let self else { return nil } + return self.objectPicker(for: relationship, linked: linked) { [weak self] refs in + guard let ref = refs.first else { return } + self?.editing.setValue(.toOne(ref, display: nil), for: name, of: object) + } + }, + clear: clear) + } } diff --git a/App/CoreDataDabbi/Inspector/DetailsTab.swift b/App/CoreDataDabbi/Inspector/DetailsTab.swift index 0f45256..74a83f6 100644 --- a/App/CoreDataDabbi/Inspector/DetailsTab.swift +++ b/App/CoreDataDabbi/Inspector/DetailsTab.swift @@ -3,8 +3,8 @@ import SwiftUI /// Every stored property of the selected object, in one column (BRW-7). /// -/// When the store is open for editing, attribute values are edited in place (EDT-3): unlocking a store changes -/// what the rows do and not where anything is. +/// When the store is open for editing, attribute values are edited in place and a to-one's object is chosen +/// from the row (EDT-3): unlocking a store changes what the rows do and not where anything is. struct DetailsTab: View { let model: InspectorModel @@ -54,7 +54,8 @@ struct DetailsTab: View { name: property.name, type: property.type, rendered: GridValue.render(property.value, timeZone: model.timeZone), issue: issues.first { $0.property == property.name }, - editing: model.fieldEditing(property.name, value: property.value, of: object)) + editing: model.fieldEditing(property.name, value: property.value, of: object), + choosing: model.toOneChoosing(property.name, value: property.value, of: object)) } } .padding(.vertical, 10) diff --git a/App/CoreDataDabbi/Inspector/FieldRow.swift b/App/CoreDataDabbi/Inspector/FieldRow.swift index 99a7751..ab17e8b 100644 --- a/App/CoreDataDabbi/Inspector/FieldRow.swift +++ b/App/CoreDataDabbi/Inspector/FieldRow.swift @@ -11,19 +11,30 @@ struct FieldEditing { let clear: (@MainActor () -> Void)? } +/// How a to-one field is changed, when it can be (EDT-3): by picking the object it leads to, not by typing. +struct ToOneChoosing { + /// A picker of the saved objects the to-one can lead to, the one it leads to now marked; `nil` once the store + /// is no longer open for editing. + let pick: @MainActor () -> ObjectPicker? + /// Empties the to-one; `nil` when it has to lead somewhere, or leads nowhere already. + let clear: (@MainActor () -> Void)? +} + /// One field of the Details tab: its name and type, its value, and the rule of the model its staged value breaks /// (EDT-2). /// /// When the store is open for editing the value is edited in place (EDT-3). The pencil, *Edit Value* in the /// context menu or VoiceOver's action open a text field; Return stages what was typed, Escape leaves the value /// as it was, and leaving the field stages it too. Text that cannot be a value of the attribute's type stays in -/// the field, with the reason under it. +/// the field, with the reason under it. A to-one's object is chosen instead: the link button, *Choose Object…* +/// or VoiceOver's action open the object picker, and what is chosen replaces what it led to. struct FieldRow: View { let name: String let type: String? let rendered: GridValue let issue: ValidationIssue? let editing: FieldEditing? + let choosing: ToOneChoosing? /// Whether the value is being typed rather than only shown. @State private var isEditing = false @@ -32,6 +43,8 @@ struct FieldRow: View { /// Why `draft` cannot be staged. @State private var problem: String? @FocusState private var isFocused: Bool + /// The picker a to-one's object is being chosen with. + @State private var picker: ObjectPicker? var body: some View { VStack(alignment: .leading, spacing: 1) { @@ -51,6 +64,14 @@ struct FieldRow: View { .buttonStyle(.borderless) .help(String(localized: "Edit Value")) .accessibilityHidden(true) + } else if choosing != nil { + Spacer(minLength: 4) + Button(action: choose) { + Image(systemName: "link") + } + .buttonStyle(.borderless) + .help(String(localized: "Choose Object…")) + .accessibilityHidden(true) } } if isEditing { @@ -73,14 +94,22 @@ struct FieldRow: View { if let clear = editing.clear { Button(String(localized: "Set to Nil")) { clear() } } + } else if let choosing { + Button(String(localized: "Choose Object…"), action: choose) + if let clear = choosing.clear { + Button(String(localized: "Set to Nil")) { clear() } + } } } + .sheet(item: $picker) { ObjectPickerView(picker: $0) } // Read as one line while it is shown; while it is edited, the text field has to be reachable on its own. .accessibilityElement(children: isEditing ? .contain : .combine) .accessibilityLabel(DetailsTab.spoken(name, rendered.text, issue: issue)) .accessibilityActions { if editing != nil, !isEditing { Button(String(localized: "Edit Value"), action: begin) + } else if choosing != nil { + Button(String(localized: "Choose Object…"), action: choose) } } } @@ -150,4 +179,9 @@ struct FieldRow: View { isEditing = false problem = nil } + + /// Opens the picker a to-one's object is chosen with. + private func choose() { + picker = choosing?.pick() + } } diff --git a/App/CoreDataDabbi/Inspector/InspectorModel.swift b/App/CoreDataDabbi/Inspector/InspectorModel.swift index 5cfface..ca4736b 100644 --- a/App/CoreDataDabbi/Inspector/InspectorModel.swift +++ b/App/CoreDataDabbi/Inspector/InspectorModel.swift @@ -118,6 +118,11 @@ final class InspectorModel { context.fieldEditing(name, value: value, of: object) } + /// How the to-one `name` of `object`, which holds `value` now, is chosen — `nil` when it cannot be. + func toOneChoosing(_ name: String, value: Value, of object: PendingObjectID) -> ToOneChoosing? { + context.toOneChoosing(name, value: value, of: object) + } + /// Reads whatever the current tab needs. Called from the view's `task`, so that a tab nobody looks at costs /// nothing — the Structure tab in particular runs four `PRAGMA`s the Details tab has no use for. func refresh() { diff --git a/App/CoreDataDabbi/Relationships/RelationshipsModel.swift b/App/CoreDataDabbi/Relationships/RelationshipsModel.swift index e7b08c0..90ad373 100644 --- a/App/CoreDataDabbi/Relationships/RelationshipsModel.swift +++ b/App/CoreDataDabbi/Relationships/RelationshipsModel.swift @@ -312,17 +312,11 @@ final class RelationshipsModel { /// A picker of saved objects to link into the followed relationship: any number for a to-many, one for a /// to-one, which it replaces. What the picker chooses is staged as one edit. func makePicker() -> ObjectPicker? { - guard canEdit, let session = context.session, let model = context.model, let source, let name = selected, - let relationship = selectedRow?.relationship - else { return nil } - let destination = relationship.destinationEntity + guard canEdit, let source, let name = selected, let relationship = selectedRow?.relationship else { + return nil + } let linked = Set(related?.items.compactMap(\.ref) ?? []) - let displayAttribute = - context.layout(of: destination).displayAttribute ?? model.entity(named: destination)?.displayAttributeName - return ObjectPicker( - entity: destination, relationship: name, isToMany: relationship.isToMany, linked: linked, - session: session, model: model, displayAttribute: displayAttribute - ) { [weak self] refs in + return context.objectPicker(for: relationship, linked: linked) { [weak self] refs in self?.context.editing.link(refs.map(PendingObjectID.init), to: PendingObjectID(source), through: name) } } diff --git a/App/CoreDataDabbi/Resources/Localizable.xcstrings b/App/CoreDataDabbi/Resources/Localizable.xcstrings index 60879fc..e1cf225 100644 --- a/App/CoreDataDabbi/Resources/Localizable.xcstrings +++ b/App/CoreDataDabbi/Resources/Localizable.xcstrings @@ -359,6 +359,9 @@ }, "Choose Model…" : { + }, + "Choose Object…" : { + }, "Choose Store…" : { diff --git a/App/CoreDataDabbiTests/EditingSessionTests.swift b/App/CoreDataDabbiTests/EditingSessionTests.swift index a301568..929d7e1 100644 --- a/App/CoreDataDabbiTests/EditingSessionTests.swift +++ b/App/CoreDataDabbiTests/EditingSessionTests.swift @@ -446,6 +446,59 @@ import Testing context.shutDown() } + @Test func theInspectorChoosesAToOneWithThePickerAndEmptiesIt() async throws { + let recorder = Recorder() + let (context, _) = try await editableContext(recorder, fixture: .company) + let session = try #require(context.session) + let department = PendingObjectID( + try #require(try await session.references(FetchSpec(entity: "Department"), limit: 1).first)) + let inspector = InspectorModel(context: context) + let value = try #require(try await session.stagedObject(department)["head"]) + guard case .toOne(let head?, _) = value else { + Issue.record("the department has no head: \(value)") + context.shutDown() + return + } + + // Attributes are typed and to-manys are linked in the relationships panel; a to-one is chosen (EDT-3). + #expect(inspector.toOneChoosing("name", value: .string("Sales"), of: department) == nil) + #expect(inspector.toOneChoosing("employees", value: .toMany(count: 3), of: department) == nil) + #expect(inspector.fieldEditing("head", value: value, of: department) == nil) + let choosing = try #require(inspector.toOneChoosing("head", value: value, of: department)) + #expect(choosing.clear != nil) + + // The picker lists the managers with the one it leads to marked, and what is chosen replaces it as an + // edit of the field. + let picker = try #require(choosing.pick()) + #expect(!picker.isToMany && picker.entity == "Manager" && picker.relationship == "head") + picker.search() + await picker.whenSettled() + #expect(picker.matching == 5) + #expect(picker.items.filter(\.isLinked).map(\.ref) == [head]) + let other = try #require(picker.items.first { !$0.isLinked }) + picker.selection = [other.ref] + picker.choose() + await context.whenSettled() + #expect(context.editing.undoManager.undoActionName == "Edit head") + let chosen = try #require(try await session.stagedObject(department)["head"]) + guard case .toOne(let ref?, _) = chosen else { + Issue.record("nothing was chosen: \(chosen)") + context.shutDown() + return + } + #expect(ref == other.ref) + + // Set to Nil empties it; an empty to-one has nothing left to empty. + let clear = try #require(inspector.toOneChoosing("head", value: chosen, of: department)?.clear) + clear() + await context.whenSettled() + let emptied = try #require(try await session.stagedObject(department)["head"]) + #expect(emptied == .toOne(nil, display: nil)) + #expect(inspector.toOneChoosing("head", value: emptied, of: department)?.clear == nil) + #expect(recorder.errors.isEmpty) + context.shutDown() + } + @Test func anAbstractEntityHasNoNewObject() async throws { let recorder = Recorder() let (context, _) = try await editableContext(recorder, fixture: .company) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e695d5a..d7c4e31 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -338,7 +338,8 @@ Every spike ends as either a permanent *format canary* test or an ADR amendment. - Slice 4a — relationship editing in the relationships panel. The engine links and unlinks: `StoreSession.link(_:to:through:)` adds to a to-many (at the end of an ordered one) or sets a to-one, replacing what it held; `unlink(_:from:through:)` takes objects out, or empties a to-one that holds one of them; `insertRelatedObject(to:through:entity:)` makes an object of the destination (or one of its sub-entities) already linked, as one edit. Each is one undoable edit; what is already linked, or not linked, is passed over and leaves no edit behind; Core Data keeps the inverse; either side may be an object only inserted. `RelatedObjects.Item` now names its object by `PendingObjectID`, so a linked object that is only inserted is listed too (after the saved ones, marked *New*). In the app, the panel re-reads whenever what is staged changes. When the store is editable, the followed to-many has a **+** (a menu of the concrete entities when the destination has sub-entities) that makes a related object and shows it in the inspector, and every listed object has *Unlink* in its context menu, as a VoiceOver action and on the Delete key; the object itself stays. A to-one's new object is left for the picker (next slice), since a to-one pointing at an object only inserted reads as empty in the grid and the inspector until the commit. Tests — engine: `StagedLinksTests` (5: a to-many linked and unlinked with the inverse following and the file untouched; an ordered to-many taking new objects at the end and keeping the order of the rest; a to-one replaced, refused two objects, emptied only by what it holds; a related object inserted and linked as one edit, sub-entity allowed, another entity refused; refusals before anything is staged, and a read-only session). App: `EditingSessionTests` +1 (the panel makes a related object, shows it, and unlinks it). - Slice 4b — the object picker. In an editable store the relationships panel has *Link…* (*Choose…* for a to-one), which opens `ObjectPicker` as a sheet: the destination's saved objects, sub-entities included, narrowed by what is typed exactly as the grid's quick filter narrows it (`QuickFilter`, PRD-6), sorted and labelled by the display attribute (the project's choice, else the model's), the first 200 listed with how many match. Objects already on the far side are listed, marked *Linked*, and cannot be picked again. A to-many takes any number, linked as one edit (`EditingSession.link`, over the engine's `link`); a to-one takes exactly one, which replaces what it held. Only the label column is read, and the pager is closed after each search; typing waits a moment for the next key before reading. Tests — app: `EditingSessionTests` +1 (the picker lists every employee and marks the department's own, narrows by what is typed, refuses what is linked already, links what is chosen; a to-one takes one and replaces its object). - Slice 4c — a to-one's new object. A to-one that leads to an object only inserted used to read as empty, the object having no reference until the commit; it now reads as `Value.toOneInserted(PendingObjectID, display:)`, named by the identity it was staged under, and as `.toOne` again from the commit on. `.toOne(nil, _)` keeps meaning empty everywhere. `setValue` takes it, checked against the destination entity like a saved object, and refuses one whose insert was undone (`objectNotFound`, as `stagedObject` does); every edit also refuses an object whose identity resolves to another entity than the one it names (`invalidValue`), rather than handing Core Data a value it raises on; its JSON form is `{"$inserted": , "$entity": …, "display": …}`, not a `$ref`, since the temporary URI names nothing outside the session. The grid shows it as a reference labelled by its display attribute, else *New* and the entity, with a tooltip and a spoken form that say it is not committed yet; the inspector and the relationships panel count it and list it. The panel's **+** now works for a to-one too: the new object replaces what it held and opens in the inspector. Tests — engine: `ValueTests` (display string, JSON, Codable), `StagedLinksTests` +1 (a department's head made new, set back and forth by value, another entity, an identity posing as a manager and an undone insert refused, and the reference the commit gives it read back from the file). App: `GridValueTests` +1, `EditingSessionTests` (the panel's head made new and listed). - - Next: choosing a to-one from the inspector's field as well as the panel; the detail window; the grid does not list inserted objects until they are committed, and a fetch with a limit (`references(limit:)`, a pager's window) counts inserted objects against it and then drops them, having no reference to return. A keyboard way into the grid's editor (the inspector's pencil and VoiceOver action are the keyboard path meanwhile). + - Slice 4d — a to-one chosen from the inspector. In an editable store a to-one field of the Details tab has a link button (and *Choose Object…* in its context menu and as a VoiceOver action) that opens the object picker, the one it leads to now marked *Linked*; what is chosen replaces it, staged as an edit of the field (*Edit head*), and *Set to Nil* empties an optional one that leads somewhere. The picker is made in one place, `ProjectContext.objectPicker(for:linked:choose:)` beside the value-editing rules, which the relationships panel uses too; `toOneChoosing(_:value:of:)` says how a field is chosen, as `fieldEditing` says how one is typed, so the grid's text editor never gets a to-one. Tests — app: `EditingSessionTests` +1 (a department's head: not typed, not for attributes or to-manys; the picker lists the managers with the current one marked, what is chosen replaces it as *Edit head*, and *Set to Nil* empties it and is then offered no more). + - Next: the detail window; the grid does not list inserted objects until they are committed, and a fetch with a limit (`references(limit:)`, a pager's window) counts inserted objects against it and then drops them, having no reference to return. A keyboard way into the grid's editor (the inspector's pencil and VoiceOver action are the keyboard path meanwhile). ---