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
24 changes: 24 additions & 0 deletions App/CoreDataDabbi/Editing/EditingSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ final class EditingSession {
}
}

/// Unlinks `objects` from `object`'s relationship `relationship`: out of a to-many, or a to-one emptied. The
/// objects themselves stay (EDT-3).
func unlink(_ objects: [PendingObjectID], from object: PendingObjectID, through relationship: String) {
guard !objects.isEmpty else { return }
let name = String(localized: "Unlink \(relationship)")
stage { try await $0.unlink(objects, from: object, through: relationship, actionName: name) }
}

/// Stages a new object of `entity` already linked to `object` through `relationship`, as one edit, and hands
/// `inserted` the identity it is staged under — once it is, and only if this session is still the one
/// attached.
func insertRelatedObject(
of entity: String, to object: PendingObjectID, through relationship: String,
inserted: (@MainActor (PendingObjectID) -> Void)? = nil
) {
let name = String(localized: "New \(entity)")
stage { [weak self] session in
let (created, changes) = try await session.insertRelatedObject(
to: object, through: relationship, entity: entity, actionName: name)
if self?.session === session { inserted?(created) }
return changes
}
}

/// Throws away everything staged. The file is not touched.
func discard() {
undoManager.removeAllActions(withTarget: self)
Expand Down
90 changes: 73 additions & 17 deletions App/CoreDataDabbi/Relationships/RelationshipsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,20 @@ final class RelationshipsModel {
/// What names the selected object in a breadcrumb (REL-3).
private(set) var sourceLabel: String?

/// An object and one of its relationships: what a list of related objects was read for.
/// An object and one of its relationships, and the staged edits they were read under: what a list of related
/// objects was read for.
private struct Followed: Equatable {
var object: ObjectRef
var relationship: String
var revision: Int
}

@ObservationIgnored private var rowsTask: Task<Void, Never>?
@ObservationIgnored private var itemsTask: Task<Void, Never>?
@ObservationIgnored private var loadedObject: ObjectRef?
/// The staged edits the object was read under: a new revision reads it again, so that what is linked and
/// unlinked shows here as it is staged (EDT-3, EDT-8).
@ObservationIgnored private var loadedRevision = 0
@ObservationIgnored private var followed: Followed?
@ObservationIgnored private var loadedFrom: ObjectIdentifier?

Expand All @@ -79,10 +84,16 @@ final class RelationshipsModel {
var source: ObjectRef? { context.navigation.current?.focus }
var sessionIdentity: ObjectIdentifier? { context.session.map(ObjectIdentifier.init) }

/// The related object being looked at (REL-1). It is not held here but read back from what the inspector and
/// the content viewer are showing, so that a click back in the grid takes the highlight off it by itself.
var selectedItem: ObjectRef? {
guard let source, let inspected = context.inspectedRef, inspected != source else { return nil }
/// Bumped whenever what is staged may have changed.
var editRevision: Int { context.editing.revision }

/// The related object being looked at (REL-1): a saved one, or one only inserted and linked here. It is not
/// held here but read back from what the inspector and the content viewer are showing, so that a click back
/// in the grid takes the highlight off it by itself.
var selectedItem: PendingObjectID? {
guard let source, let inspected = context.inspectedObject, inspected != PendingObjectID(source) else {
return nil
}
return inspected
}

Expand Down Expand Up @@ -113,7 +124,11 @@ final class RelationshipsModel {
forget()
return
}
if source != loadedObject { loadRows(of: source, from: session) }
let revision = context.editing.revision
if source != loadedObject || revision != loadedRevision {
// The same object read again for what is staged keeps showing what it had until the new read is in.
loadRows(of: source, from: session, revision: revision, keepShowing: source == loadedObject)
}
loadItems(from: session)
}

Expand All @@ -124,12 +139,15 @@ final class RelationshipsModel {
sourceLabel = nil
}

private func loadRows(of ref: ObjectRef, from session: StoreSession) {
private func loadRows(of ref: ObjectRef, from session: StoreSession, revision: Int, keepShowing: Bool) {
loadedObject = ref
loadedRevision = revision
rowsTask?.cancel()
state = .loading(ref)
related = nil
relatedError = nil
if !keepShowing {
state = .loading(ref)
related = nil
relatedError = nil
}
rowsTask = Task { [weak self] in
let result: Result<ObjectSnapshot, DabbiError>
do {
Expand Down Expand Up @@ -199,12 +217,16 @@ final class RelationshipsModel {
relatedError = nil
return
}
let wanted = Followed(object: ref, relationship: name)
let wanted = Followed(object: ref, relationship: name, revision: loadedRevision)
guard wanted != followed else { return }
// The same relationship read again for what is staged keeps its list up until the new one is in.
let isAnotherList = followed.map { $0.object != ref || $0.relationship != name } ?? true
followed = wanted
itemsTask?.cancel()
related = nil
relatedError = nil
if isAnotherList {
related = nil
relatedError = nil
}
itemsTask = Task { [weak self] in
let result: Result<RelatedObjects, DabbiError>
do {
Expand Down Expand Up @@ -242,16 +264,17 @@ final class RelationshipsModel {

/// Looks at one of the related objects: the inspector and the content viewer follow it, the grid does not
/// (REL-1). `nil` gives them the grid's row back.
func selectItem(_ ref: ObjectRef?) {
context.inspect((ref ?? source).map(PendingObjectID.init))
func selectItem(_ object: PendingObjectID?) {
context.inspect(object ?? source.map(PendingObjectID.init))
}

/// Whether there is a related object to jump to — what the Reveal button and the menu item go by (REL-3).
var canReveal: Bool { selected != nil && selectedItem != nil }
/// One only inserted is in no grid until it is committed.
var canReveal: Bool { selected != nil && selectedItem?.ref != nil }

/// Reveals what is picked, for a command that comes from the menu rather than from a row (§8.4).
func revealSelected() {
guard let ref = selectedItem else { return }
guard let ref = selectedItem?.ref else { return }
reveal(ref)
}

Expand All @@ -262,6 +285,39 @@ final class RelationshipsModel {
ref, from: source, labelled: sourceLabel ?? source?.description ?? ref.entity, through: relationship)
}

// MARK: Editing (EDT-3)

/// Whether the relationship being followed can be changed from here: the store is open for editing.
var canEdit: Bool { context.editing.isEditable && source != nil && selectedRow != nil }

/// What a new related object can be: the followed to-many's destination and its sub-entities, leaving out
/// the abstract ones. Empty for a to-one, whose new object is made with the picker that sets it.
var insertableEntities: [String] {
guard let relationship = selectedRow?.relationship, relationship.isToMany, let model = context.model else {
return []
}
return model.entityAndDescendants(of: relationship.destinationEntity).filter { !$0.isAbstract }.map(\.name)
}

/// Stages a new object of `entity`, linked to the selected object through the followed relationship, and
/// shows it in the inspector to be filled in.
func insertRelated(_ entity: String) {
guard canEdit, insertableEntities.contains(entity), let source, let name = selected else { return }
context.editing.insertRelatedObject(of: entity, to: PendingObjectID(source), through: name) {
[weak self] object in self?.context.inspect(object)
}
}

/// Takes `item` out of the followed relationship; a to-one is emptied. The object itself stays. Only what the
/// list shows is taken out: the inspector may be showing an object from somewhere else.
func unlink(_ item: PendingObjectID) {
guard canEdit, let source, let name = selected, related?.items.contains(where: { $0.object == item }) == true
else { return }
// It is no longer on this side to be looked at.
if selectedItem == item { selectItem(nil) }
context.editing.unlink([item], from: PendingObjectID(source), through: name)
}

/// Returns once the panel has read what it is showing. For the tests.
func whenSettled() async {
await rowsTask?.value
Expand Down
71 changes: 59 additions & 12 deletions App/CoreDataDabbi/Relationships/RelationshipsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ struct RelationshipsView: View {
private struct Trigger: Equatable {
var source: ObjectRef?
var session: ObjectIdentifier?
var edits: Int

@MainActor
init(model: RelationshipsModel) {
source = model.source
session = model.sessionIdentity
edits = model.editRevision
}
}

Expand Down Expand Up @@ -184,6 +186,7 @@ struct RelationshipsView: View {
.foregroundStyle(.secondary)
.help(String(localized: "The first \(RelationshipsModel.pageLimit) are listed."))
}
if model.canEdit { newRelated }
Button(String(localized: "Reveal"), action: model.revealSelected)
.disabled(!model.canReveal)
.controlSize(.small)
Expand All @@ -193,12 +196,36 @@ struct RelationshipsView: View {
.padding(.vertical, 6)
}

private var itemSelection: Binding<ObjectRef?> {
/// A new object on the far side, linked already (EDT-3): one button for a destination that is one entity, a
/// menu for one with sub-entities to choose from.
@ViewBuilder
private var newRelated: some View {
let entities = model.insertableEntities
if entities.count == 1, let entity = entities.first {
Button(String(localized: "New Related Object"), systemImage: "plus") { model.insertRelated(entity) }
.labelStyle(.iconOnly)
.controlSize(.small)
.help(String(localized: "New \(entity), linked to this object"))
} else if entities.count > 1 {
Menu(String(localized: "New Related Object"), systemImage: "plus") {
ForEach(entities, id: \.self) { entity in
Button(entity) { model.insertRelated(entity) }
}
}
.labelStyle(.iconOnly)
.menuIndicator(.hidden)
.fixedSize()
.controlSize(.small)
.help(String(localized: "New related object, linked to this object"))
}
}

private var itemSelection: Binding<PendingObjectID?> {
Binding(get: { model.selectedItem }, set: { model.selectItem($0) })
}

private func items(of related: RelatedObjects) -> some View {
List(Array(related.items.enumerated()), id: \.element.ref, selection: itemSelection) { index, item in
List(Array(related.items.enumerated()), id: \.element.object, selection: itemSelection) { index, item in
HStack(spacing: 6) {
// An ordered relationship keeps the order it was given, and the position is part of the data
// (REL-2).
Expand All @@ -211,29 +238,49 @@ struct RelationshipsView: View {
Text(item.label)
.lineLimit(1)
Spacer(minLength: 4)
// Only inserted: in no grid, and with no URI worth copying, until it is committed.
if item.ref == nil {
Text(String(localized: "New"))
.font(.caption)
.foregroundStyle(.secondary)
}
// Which sub-entity it turned out to be, where that is not the destination itself.
if item.ref.entity != related.destinationEntity {
Text(item.ref.entity)
if item.object.entity != related.destinationEntity {
Text(item.object.entity)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tag(item.ref)
.help(item.ref.description)
.tag(item.object)
.help(item.object.description)
.contentShape(Rectangle())
// The list's own click keeps working; this only adds the second one.
.simultaneousGesture(TapGesture(count: 2).onEnded { model.reveal(item.ref) })
.simultaneousGesture(TapGesture(count: 2).onEnded { if let ref = item.ref { model.reveal(ref) } })
.contextMenu {
Button(String(localized: "Reveal in Entity")) { model.reveal(item.ref) }
Button(String(localized: "Copy Object ID URI")) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(item.ref.uri.absoluteString, forType: .string)
if let ref = item.ref {
Button(String(localized: "Reveal in Entity")) { model.reveal(ref) }
Button(String(localized: "Copy Object ID URI")) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(ref.uri.absoluteString, forType: .string)
}
}
if model.canEdit {
Divider()
Button(String(localized: "Unlink")) { model.unlink(item.object) }
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.label), \(item.ref.entity)")
.accessibilityLabel("\(item.label), \(item.object.entity)")
.accessibilityActions {
if model.canEdit { Button(String(localized: "Unlink")) { model.unlink(item.object) } }
}
}
.listStyle(.inset)
// Delete takes the selected object out of the relationship, as Delete Rows does in the grid; the object
// itself stays.
.onDeleteCommand {
if let item = model.selectedItem { model.unlink(item) }
}
.accessibilityLabel(String(localized: "Related objects"))
}
}
15 changes: 15 additions & 0 deletions App/CoreDataDabbi/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -779,12 +779,21 @@
},
"New %@" : {

},
"New %@, linked to this object" : {

},
"New Object" : {

},
"New Predicate" : {

},
"New Related Object" : {

},
"New related object, linked to this object" : {

},
"New — not in the store until it is committed" : {

Expand Down Expand Up @@ -1418,6 +1427,12 @@
},
"Unknown App" : {

},
"Unlink" : {

},
"Unlink %@" : {

},
"Unlinked, by Nullify rules: %@" : {

Expand Down
48 changes: 48 additions & 0 deletions App/CoreDataDabbiTests/EditingSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,54 @@ import Testing
context.shutDown()
}

@Test func theRelationshipsPanelMakesRelatedObjectsAndUnlinksThem() async throws {
let recorder = Recorder()
let (context, _) = try await editableContext(recorder, fixture: .company)
context.select(entity: "Department")
let session = try #require(context.session)
let department = try #require(try await session.references(FetchSpec(entity: "Department"), limit: 1).first)
context.focus(on: department)
let panel = RelationshipsModel(context: context)
panel.refresh()
await panel.whenSettled()
panel.select("employees")
await panel.whenSettled()
let before = try #require(panel.related).items.map(\.object)
#expect(panel.canEdit)
#expect(panel.insertableEntities == ["Employee", "Manager"])

// A new object on the far side, linked already, and shown in the inspector to be filled in (EDT-3).
panel.insertRelated("Employee")
await context.whenSettled()
let created = try #require(context.inspectedObject)
#expect(created.isInserted && created.entity == "Employee")
#expect(context.editing.undoManager.undoActionName == "New Employee")
panel.refresh()
await panel.whenSettled()
#expect(panel.related?.items.map(\.object) == before + [created])
#expect(panel.selectedItem == created)
// No grid lists it until it is committed.
#expect(!panel.canReveal)

// Unlinking takes it out of the relationship, not out of what is staged; the inspector goes back to the
// grid's row.
panel.unlink(created)
await context.whenSettled()
#expect(context.inspectedObject == PendingObjectID(department))
#expect(context.editing.undoManager.undoActionName == "Unlink employees")
#expect(context.editing.changes.change(for: created)?.kind == .inserted)
panel.refresh()
await panel.whenSettled()
#expect(panel.related?.items.map(\.object) == before)

// A to-one's object is chosen with the picker, not made here.
panel.select("head")
await panel.whenSettled()
#expect(panel.insertableEntities.isEmpty)
#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