Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions App/CoreDataDabbi/Editing/ValueEditing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<ObjectRef>,
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<ObjectRef>, 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)
}
}
7 changes: 4 additions & 3 deletions App/CoreDataDabbi/Inspector/DetailsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
36 changes: 35 additions & 1 deletion App/CoreDataDabbi/Inspector/FieldRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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()
}
}
5 changes: 5 additions & 0 deletions App/CoreDataDabbi/Inspector/InspectorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
14 changes: 4 additions & 10 deletions App/CoreDataDabbi/Relationships/RelationshipsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
3 changes: 3 additions & 0 deletions App/CoreDataDabbi/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@
},
"Choose Model…" : {

},
"Choose Object…" : {

},
"Choose Store…" : {

Expand Down
53 changes: 53 additions & 0 deletions App/CoreDataDabbiTests/EditingSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading