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
2 changes: 1 addition & 1 deletion App/Features/AI/AIDocumentSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct AIDocumentSheet: View {
/// populated without the host having to hold lists it does not otherwise need.
@State private var lists: [OwnedList] = []
/// Documents the user owns, offered when deriving from an article.
var documents: [Document] = []
var documents: [InterlinedDomain.Document] = []
/// Called after a drafted document is created, so the host can reload it.
var onCreated: (() async -> Void)?

Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/ConflictBannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import InterlinedDomain
struct ConflictBannerView: View {

let pending: ConflictBannerViewModel.Pending
let onOpenLocalCopy: (Document.ID) -> Void
let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void
let onDismiss: () -> Void

var body: some View {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/DocumentEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Textual
struct DocumentEditorView: View {

let viewModel: DocumentEditorViewModel
let onOpenLocalCopy: (Document.ID) -> Void
let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void

var body: some View {
VStack(spacing: 0) {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/DocumentTemplatePickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct DocumentTemplatePickerView: View {

/// Called with the created document on success so the caller (the root
/// view) can bind the editor to it. Not called on failure.
let onCreated: (Document) -> Void
let onCreated: (InterlinedDomain.Document) -> Void

/// The built-in catalog to present. Defaults to the bundled built-ins;
/// injectable so previews can substitute a list.
Expand Down
23 changes: 20 additions & 3 deletions App/Features/Documents/DocumentsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@
import SwiftUI
import InterlinedDomain

// `Document` is written as `InterlinedDomain.Document` throughout the SwiftUI
// files in this feature, and that qualification is load-bearing.
//
// The macOS 27 SDK added a `Document` **protocol** to SwiftUI
// (`protocol Document: ReadableDocument, WritableDocument`), which collides with
// the domain's `Document` **struct** in any file importing both — which is every
// documents view. Before Xcode 27 the bare name resolved; after it, the same
// source stopped compiling with `'Document' is ambiguous for type lookup`
// (GitHub #98).
//
// The domain type is not renamed: `Document` is the right name for it, it is
// correct across Kit, Domain, Persistence and their tests, and renaming a core
// model to dodge a collision in one consumer is the tail wagging the dog. A
// `typealias` would shorten the use sites at the cost of giving one concept two
// names. Only the SwiftUI-importing files need this; the view models import
// Foundation and Observation, not SwiftUI, and are unaffected.

struct DocumentsListView: View {

let viewModel: DocumentsListViewModel
let onSelect: (Document.ID?) -> Void
let onSelect: (InterlinedDomain.Document.ID?) -> Void

/// Source of the **Move to folder** destinations. Optional so the column
/// still renders in isolation (previews, and any future host that has no
Expand All @@ -21,7 +38,7 @@ struct DocumentsListView: View {

/// Called with the document that was moved, so the host can rebind an open
/// editor to the server's relocated copy.
var onMoved: ((Document) -> Void)? = nil
var onMoved: ((InterlinedDomain.Document) -> Void)? = nil

var body: some View {
List(selection: Binding(
Expand Down Expand Up @@ -103,7 +120,7 @@ struct DocumentsListView: View {
// MARK: - DocumentRowView

private struct DocumentRowView: View {
let document: Document
let document: InterlinedDomain.Document

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Expand Down
4 changes: 2 additions & 2 deletions App/Features/Documents/DocumentsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ struct DocumentsRootView: View {
/// was started from the editor, so there is exactly one optimistic-rollback
/// implementation rather than two that can disagree.
private func handleMove(
documentID: Document.ID,
documentID: InterlinedDomain.Document.ID,
to destination: FolderNode.ID?,
folderTree: FolderTreeViewModel,
documentsList: DocumentsListViewModel,
Expand All @@ -449,7 +449,7 @@ struct DocumentsRootView: View {
}

private func handleOpenLocalCopy(
_ id: Document.ID,
_ id: InterlinedDomain.Document.ID,
documentsList: DocumentsListViewModel,
editor: DocumentEditorViewModel
) {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/PublicUserDocumentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ struct PublicUserDocumentsView: View {

private struct PublicDocumentRow: View {

let document: Document
let document: InterlinedDomain.Document

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Expand Down
198 changes: 198 additions & 0 deletions App/Features/Lists/AddRowSheetView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// AddRowSheetView
//
// The Add Row form (GitHub #50). macOS had none — rows were created empty and
// filled in through the inspector afterwards, which is why the per-column help
// text, placeholders and validation rules had nowhere to appear.
//
// The "Add another after saving" checkbox is the keyboard-ergonomics win the web
// documents: saving keeps you on the form, empties it, counts up, and returns
// focus to the first field, so bulk entry never needs the mouse between rows.
// Its state is persisted across lists and visits, as the help page specifies.
//
// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`.

import SwiftUI
import InterlinedDomain

struct AddRowSheetView: View {

let listId: String
let schema: ListSchema
/// Called after each successful save so the host can fold the new row in.
var onSaved: () -> Void = {}

@Environment(\.appEnvironment) private var environment
@Environment(\.dismiss) private var dismiss

/// Remembered across lists and across visits, as the web documents.
@AppStorage("lists.addRow.addAnotherAfterSaving") private var addAnother = false

@State private var viewModel: AddRowViewModel?
@FocusState private var focusedKey: String?

var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
if let viewModel {
form(viewModel)
Divider()
footer(viewModel)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: 460, height: 520)
.task {
guard viewModel == nil, let environment else { return }
viewModel = AddRowViewModel(
lists: environment.lists,
listId: listId,
schema: schema,
addAnotherAfterSaving: addAnother
)
focusedKey = schema.orderedFields.first?.key
}
}

private var header: some View {
HStack {
Text("Add Row").font(.ilTitle(18))
Spacer()
if let count = viewModel?.savedCount, count > 0 {
// The running confirmation. With the sheet staying open, this is
// the only signal that a row actually went in.
Text("^[\(count) row](inflect: true) added")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
.padding(12)
}

@ViewBuilder
private func form(_ viewModel: AddRowViewModel) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if schema.fields.isEmpty {
Text("This list has no columns yet. Add some in Edit Schema first.")
.font(.ilBody())
.foregroundStyle(.secondary)
} else {
ForEach(schema.orderedFields) { field in
fieldEditor(field, viewModel: viewModel)
}
}
}
.padding(12)
}
.onChange(of: viewModel.shouldRefocusFirstField) { _, shouldRefocus in
// "the cursor returns to the first field" — the difference between
// bulk entry that needs the mouse between rows and bulk entry that
// does not.
guard shouldRefocus else { return }
focusedKey = schema.orderedFields.first?.key
viewModel.consumeRefocusRequest()
onSaved()
}
.onChange(of: viewModel.didFinish) { _, finished in
if finished { onSaved(); dismiss() }
}
}

@ViewBuilder
private func fieldEditor(_ field: SchemaField, viewModel: AddRowViewModel) -> some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(field.label.isEmpty ? field.key : field.label)
.font(.caption.weight(.semibold))
if field.isRequired == true {
Text("required")
.font(.ilMono(9))
.foregroundStyle(.secondary)
}
}

switch field.type {
case .boolean:
Toggle("", isOn: Binding(
get: { if case .bool(let v) = viewModel.value(forKey: field.key) { return v } else { return false } },
set: { viewModel.setValue(.bool($0), forKey: field.key) }
))
.toggleStyle(.checkbox)
.labelsHidden()
.accessibilityLabel(field.label)

case .select:
Picker("", selection: Binding(
get: { viewModel.value(forKey: field.key).displayText },
set: { viewModel.setValue($0.isEmpty ? .null : .string($0), forKey: field.key) }
)) {
// A leading empty tag so an optional select can be left unset
// — without it the picker would silently pick the first
// option for the user.
Text("—").tag("")
ForEach(field.enumValues ?? [], id: \.self) { option in
Text(option).tag(option)
}
}
.labelsHidden()
.accessibilityLabel(field.label)

default:
TextField(
field.placeholder ?? "",
text: Binding(
get: { viewModel.value(forKey: field.key).displayText },
set: { viewModel.setValue($0.isEmpty ? .null : .string($0), forKey: field.key) }
)
)
.textFieldStyle(.roundedBorder)
.focused($focusedKey, equals: field.key)
}

if let helpText = field.helpText, !helpText.isEmpty {
Text(helpText)
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
if let failure = viewModel.failures[field.key] {
Text(failure)
.font(.ilMono(10))
.foregroundStyle(.red)
}
}
}

@ViewBuilder
private func footer(_ viewModel: AddRowViewModel) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let error = viewModel.error {
Text(error.localizedDescription)
.font(.ilMono(10))
.foregroundStyle(.orange)
}
HStack {
Toggle("Add another after saving", isOn: Binding(
get: { addAnother },
set: { newValue in
addAnother = newValue
viewModel.addAnotherAfterSaving = newValue
}
))
.toggleStyle(.checkbox)
Spacer()
Button("Cancel") { dismiss() }
Button("Add Row") {
Task { await viewModel.save() }
}
.keyboardShortcut(.defaultAction)
.disabled(viewModel.isSaving || schema.fields.isEmpty)
if viewModel.isSaving {
ProgressView().controlSize(.small)
}
}
}
.padding(12)
}
}
Loading