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
27 changes: 27 additions & 0 deletions App/Features/Lists/ListColumn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// ListColumn
//
// One rendered column of a list's row table: the row-data **key** it reads and
// the **label** it shows.
//
// These are separate on the server (`propertyKey` / `propertyName`) and were
// collapsed into a single `name` throughout the lists UI. That was harmless only
// because the client could not create a schema at all — the server rejected the
// DSL string it sent — so every schema in the wild had key == label. Fixing the
// wire shape (GitHub #85) makes the distinction load-bearing: a column labelled
// "Publication Year" over a key of `year` renders nothing if the label is used
// as the subscript.
//
// Per Decision 0003 this type consumes only `InterlinedDomain`.

import Foundation

struct ListColumn: Identifiable, Hashable, Sendable {

/// The `ListRow.fields` key this column reads. The identity.
let key: String

/// The header text.
let label: String

var id: String { key }
}
7 changes: 6 additions & 1 deletion App/Features/Lists/ListDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,19 @@ final class ListDetailViewModel {
/// is not exposed by the API today (see
/// `/API-backend-prompts-to-build.md` — no documented public-list
/// clone endpoint), so this is a deliberate degradation.
///
/// The schema is not copied, and now says so rather than passing a value
/// that was always `nil`: `GET /api/users/[username]/lists/[id]` returns a
/// light projection with no columns at all (GitHub #85), so there was never
/// a schema here to carry across.
func saveToMyLists(suggestedName: String) async {
guard let detail else { return }
saveState = .saving
do {
let created = try await lists.create(
title: suggestedName,
description: detail.description,
schema: detail.schemaDescription,
schema: nil,
parentId: nil,
isPublic: false
)
Expand Down
38 changes: 22 additions & 16 deletions App/Features/Lists/ListRowsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,10 @@ struct ListRowsView: View {
let columns = effectiveColumns(viewModel)
VStack(spacing: 0) {
Table(viewModel.rows, selection: $selection) {
TableColumnForEach(columns, id: \.self) { column in
TableColumn(column) { (row: ListRow) in
Text(row.fields[column]?.displayText ?? "")
TableColumnForEach(columns) { column in
// Header from `label`, cell lookup by `key` — see `ListColumn`.
TableColumn(column.label) { (row: ListRow) in
Text(row.fields[column.key]?.displayText ?? "")
.lineLimit(2)
}
}
Expand Down Expand Up @@ -192,14 +193,15 @@ struct ListRowsView: View {
/// Ordered column set for the table: the schema-derived columns when
/// present, else the sorted union of keys across loaded rows so a
/// schemaless list still renders a sensible grid.
private func effectiveColumns(_ viewModel: ListRowsViewModel) -> [String] {
private func effectiveColumns(_ viewModel: ListRowsViewModel) -> [ListColumn] {
if !viewModel.columns.isEmpty { return viewModel.columns }
var seen = Set<String>()
var ordered: [String] = []
var ordered: [ListColumn] = []
for row in viewModel.rows {
for key in row.fields.keys.sorted() where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
// No schema means no separate label; the key is the header.
ordered.append(ListColumn(key: key, label: key))
}
}
return ordered
Expand Down Expand Up @@ -332,15 +334,17 @@ struct ListRowsView: View {
}

@ViewBuilder
private func rowCard(row: ListRow, columns: [String]) -> some View {
let keys = columns.isEmpty ? row.fields.keys.sorted() : columns
private func rowCard(row: ListRow, columns: [ListColumn]) -> some View {
let keys = columns.isEmpty
? row.fields.keys.sorted().map { ListColumn(key: $0, label: $0) }
: columns
VStack(alignment: .leading, spacing: 4) {
ForEach(keys, id: \.self) { key in
ForEach(keys) { column in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(key)
Text(column.label)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(row.fields[key]?.displayText ?? "")
Text(row.fields[column.key]?.displayText ?? "")
.font(.ilBody())
.lineLimit(2)
Spacer()
Expand All @@ -351,11 +355,13 @@ struct ListRowsView: View {
.background(ILColor.surface2, in: RoundedRectangle(cornerRadius: ILMetric.radiusMd))
}

private func rowAccessibilityLabel(row: ListRow, columns: [String]) -> String {
let keys = columns.isEmpty ? row.fields.keys.sorted() : columns
let pairs = keys.compactMap { key -> String? in
guard let value = row.fields[key]?.displayText, !value.isEmpty else { return nil }
return "\(key): \(value)"
private func rowAccessibilityLabel(row: ListRow, columns: [ListColumn]) -> String {
let keys = columns.isEmpty
? row.fields.keys.sorted().map { ListColumn(key: $0, label: $0) }
: columns
let pairs = keys.compactMap { column -> String? in
guard let value = row.fields[column.key]?.displayText, !value.isEmpty else { return nil }
return "\(column.label): \(value)"
}
return pairs.isEmpty ? "Row" : pairs.joined(separator: ", ")
}
Expand Down
21 changes: 17 additions & 4 deletions App/Features/Lists/ListRowsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ final class ListRowsViewModel {
var entityFields: [SchemaEntityField] {
schema.fields.map { field in
SchemaEntityField(
name: field.name,
name: field.label,
typeToken: field.type.dslToken,
options: field.type.carriesOptions ? (field.enumValues ?? []) : [],
nullable: field.nullable
Expand Down Expand Up @@ -101,16 +101,29 @@ final class ListRowsViewModel {
/// non-empty; falls back to the union of observed row keys when
/// the list has no schema yet (so the table still shows something
/// useful for schema-less lists).
var columns: [String] {
if !schema.fields.isEmpty { return schema.fields.map(\.name) }
///
/// Each column carries its **key** and its **label** separately. This used
/// to be a `[String]` of `field.name` used both as the header text and as
/// the `row.fields[…]` subscript — correct only while the two were the same
/// token, which they are in the client's DSL and are not on the server
/// (`propertyKey` vs `propertyName`). With the schema wire shape fixed
/// (GitHub #85), a column labelled "Publication Year" over a key of `year`
/// would have rendered every cell empty.
var columns: [ListColumn] {
if !schema.fields.isEmpty {
return schema.orderedFields
.filter { $0.isVisible != false }
.map { ListColumn(key: $0.key, label: $0.label) }
}
var seen: Set<String> = []
var ordered: [String] = []
for row in rows {
for key in row.fields.keys where seen.insert(key).inserted {
ordered.append(key)
}
}
return ordered.sorted()
// With no schema the key is all there is, so it doubles as the label.
return ordered.sorted().map { ListColumn(key: $0, label: $0) }
}

/// Currently selected row, if any.
Expand Down
21 changes: 20 additions & 1 deletion App/Features/Lists/NewListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,30 @@ final class NewListViewModel {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSchema = schemaDSL.trimmingCharacters(in: .whitespacesAndNewlines)

// The DSL the user typed is parsed here, client-side, and the *columns*
// go on the wire. It used to be sent as a string, which the server
// rejects outright — so creating a list with columns from macOS never
// worked (GitHub #85). A malformed DSL is now caught before the network
// call and reported against the field the user typed it into, rather
// than coming back as an opaque 400.
let parsedSchema: ListSchema?
if trimmedSchema.isEmpty {
parsedSchema = nil
} else {
do {
parsedSchema = try SchemaDSL.parse(trimmedSchema)
} catch {
self.error = error
return
}
}

do {
let created = try await lists.create(
title: trimmedTitle,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
schema: trimmedSchema.isEmpty ? nil : trimmedSchema,
schema: parsedSchema,
parentId: parentID,
isPublic: visibility == .public
)
Expand Down
34 changes: 28 additions & 6 deletions App/Features/Lists/RowInspectorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ struct RowInspectorView: View {
ForEach(row.fields.keys.sorted(), id: \.self) { key in
cellEditor(
key: key,
label: key,
helpText: nil,
placeholder: nil,
type: .text,
options: [],
current: row.fields[key] ?? .null,
Expand All @@ -49,12 +52,19 @@ struct RowInspectorView: View {
)
}
} else {
ForEach(viewModel.schema.fields) { field in
// `key` reads and writes the cell; `label` is what the user
// sees. They are the same token for a DSL-authored schema
// and differ for one authored on the web (GitHub #85), so
// both are passed rather than one standing in for the other.
ForEach(viewModel.schema.orderedFields) { field in
cellEditor(
key: field.name,
key: field.key,
label: field.label,
helpText: field.helpText,
placeholder: field.placeholder,
type: field.type,
options: field.enumValues ?? [],
current: row.fields[field.name] ?? .null,
current: row.fields[field.key] ?? .null,
row: row,
viewModel: viewModel
)
Expand All @@ -68,19 +78,24 @@ struct RowInspectorView: View {
@ViewBuilder
private func cellEditor(
key: String,
label: String,
helpText: String?,
placeholder: String?,
type: SchemaFieldType,
options: [String],
current: ListCellValue,
row: ListRow,
viewModel: ListRowsViewModel
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(key)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
switch type {
case .text, .url, .email, .date, .number:
TextField(label(for: type), text: Binding(
// The column's own placeholder when it has one, else the
// type-derived hint the editor has always shown.
TextField(placeholder ?? self.label(for: type), text: Binding(
get: { editingValues[key] ?? current.displayText },
set: { editingValues[key] = $0 }
))
Expand All @@ -102,7 +117,7 @@ struct RowInspectorView: View {
commitChange(row: row, key: key, type: type, viewModel: viewModel)
}
))
.accessibilityLabel(key)
.accessibilityLabel(label)
case .select:
selectEditor(
key: key,
Expand All @@ -119,6 +134,13 @@ struct RowInspectorView: View {
viewModel: viewModel
)
}
// The server has always stored per-column help text; nothing ever
// read it (GitHub #85, and the blocked half of #50).
if let helpText, !helpText.isEmpty {
Text(helpText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}

Expand Down
40 changes: 39 additions & 1 deletion App/Features/Lists/SchemaEditorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ final class SchemaEditorViewModel {
/// Surfaced error from the most recent failed save.
private(set) var error: Error?

/// The schema the server refused as destructive, held so the user can
/// confirm it. Non-nil is what the view binds a confirmation dialog to.
private(set) var pendingDestructiveSave: ListSchema?

/// Set to `true` after a successful save; the view dismisses.
private(set) var didFinish: Bool = false

Expand Down Expand Up @@ -222,7 +226,41 @@ final class SchemaEditorViewModel {
)
})
do {
let saved = try await lists.updateSchema(of: listId, schema: schema)
let saved = try await lists.updateSchema(
of: listId,
schema: schema,
// Never force on the first attempt. Dropping a column that still
// holds data is a question for the user, not a default — the
// server asks it, and `confirmDestructiveSave()` is how the
// answer gets back.
force: false
)
eventBus.post(.schemaChanged(listId: listId, schema: saved))
didFinish = true
} catch let listsError as ListsError {
if case .schemaChangeWouldLoseData = listsError {
pendingDestructiveSave = schema
}
self.error = listsError
} catch {
self.error = error
}
}

/// Re-submits the schema the server refused, confirming the data loss.
///
/// Only reachable after `save()` has surfaced
/// `ListsError.schemaChangeWouldLoseData`, so there is no path that forces a
/// destructive change without the server having asked first.
func confirmDestructiveSave() async {
guard let schema = pendingDestructiveSave, !isSaving else { return }
isSaving = true
error = nil
pendingDestructiveSave = nil
defer { isSaving = false }

do {
let saved = try await lists.updateSchema(of: listId, schema: schema, force: true)
eventBus.post(.schemaChanged(listId: listId, schema: saved))
didFinish = true
} catch {
Expand Down
Loading