Skip to content
32 changes: 27 additions & 5 deletions App/Features/Lists/ListRowsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ struct ListRowsView: View {
let list: OwnedList
let viewModel: ListRowsViewModel

@Environment(\.appEnvironment) private var environment
@State private var selection: Set<String> = []
@State private var deletePending: Bool = false
/// Presents the GitHub issue browser/composer for a GitHub-backed list —
/// the row-creation route for lists whose rows sync from GitHub.
@State private var showsIssues: Bool = false

var body: some View {
content(viewModel: viewModel)
Expand Down Expand Up @@ -52,23 +56,41 @@ struct ListRowsView: View {
}
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showsIssues) {
if let environment, let repo = viewModel.gitHubRepo {
GitHubIssuesView(repo: repo, environment: environment)
}
}
}

@ViewBuilder
private func toolbar(viewModel: ListRowsViewModel) -> some View {
HStack(spacing: 8) {
Button {
Task { await viewModel.addRow() }
} label: {
Label("Add Row", systemImage: "plus")
// A GitHub-backed list's rows sync from GitHub issues, so the add
// action becomes "New Issue" (opening the issue composer/browser)
// rather than a native empty-row create, which wouldn't survive the
// next sync. Detection is row-derived (`viewModel.isGitHubBacked`).
if viewModel.isGitHubBacked {
Button {
showsIssues = true
} label: {
Label("New Issue", systemImage: "ladybug")
}
.help("This list syncs from GitHub — add a GitHub issue instead of a row")
} else {
Button {
Task { await viewModel.addRow() }
} label: {
Label("Add Row", systemImage: "plus")
}
}

Button {
deletePending = true
} label: {
Label("Delete", systemImage: "minus")
}
.disabled(selection.isEmpty)
.disabled(selection.isEmpty || viewModel.isGitHubBacked)

Spacer()

Expand Down
29 changes: 29 additions & 0 deletions App/Features/Lists/ListRowsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,28 @@ final class ListRowsViewModel {
return rows.first { $0.id == selectedRowID }
}

// MARK: - GitHub-backing (derived from rows)

/// Whether this list is GitHub-backed, derived from its loaded rows. True
/// when any loaded row is GitHub-synced.
///
/// Detection is row-level on purpose: there is no stable list-level
/// `githubSource` on the wire yet (work-consolidation.md P3-C), but synced
/// rows carry `source`/`githubRepo`. The trade-off is that a GitHub-backed
/// list with **no rows loaded yet** (empty, or before the first page
/// arrives) reads as not-backed until a synced row appears — acceptable
/// because there is no earlier signal to key off.
var isGitHubBacked: Bool {
rows.contains(where: \.isGitHubBacked)
}

/// The `"owner/repo"` slug this list syncs from, taken from the first
/// GitHub-synced row that names one. `nil` for a plain list (or a backed
/// list whose rows haven't loaded). Drives the issue-browser route.
var gitHubRepo: String? {
rows.lazy.compactMap(\.githubRepo).first
}

// MARK: - Init

init(lists: ListsServicing, eventBus: ListsEventBus, listId: String) {
Expand Down Expand Up @@ -175,7 +197,14 @@ final class ListRowsViewModel {

/// Adds a new empty row. Optimistic-insert with a placeholder id,
/// then replace with the server's row.
///
/// No-op on a GitHub-backed list: its rows sync from GitHub issues, so a
/// native empty-row create doesn't belong there and would create an orphan
/// the next sync discards. The rows-pane toolbar routes such lists to the
/// issue composer instead; this guard is defence-in-depth for any other
/// caller (menu command, keyboard shortcut).
func addRow() async {
guard !isGitHubBacked else { return }
let placeholderID = "tmp-" + UUID().uuidString
let snapshot = rows
let optimistic = ListRow(id: placeholderID, listID: listId, fields: [:])
Expand Down
8 changes: 6 additions & 2 deletions App/Features/Lists/OwnedListsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ struct OwnedListsRootView: View {
} label: {
Label("Issues", systemImage: "ladybug")
}
.disabled(viewModel.selectedListGitHubRepo == nil)
// Prefer the row-derived repo (the authoritative backing signal
// today) over the list-level field, which stays nil until the
// backend surfaces `githubSource` on lists (P3-C). This is what
// finally makes the already-built issue browser reachable.
.disabled((rowsViewModel?.gitHubRepo ?? viewModel.selectedListGitHubRepo) == nil)
.help("Browse and create GitHub issues for this list")
}
}
Expand Down Expand Up @@ -245,7 +249,7 @@ struct OwnedListsRootView: View {
}
}
.sheet(isPresented: $showsIssues) {
if let environment, let repo = viewModel.selectedListGitHubRepo {
if let environment, let repo = rowsViewModel?.gitHubRepo ?? viewModel.selectedListGitHubRepo {
GitHubIssuesView(repo: repo, environment: environment)
}
}
Expand Down
55 changes: 55 additions & 0 deletions AppTests/ListRowsViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,61 @@ final class ListRowsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.error as? TestError, .upstream("denied"))
}

// MARK: - GitHub-backing detection + Add Row guard

func test_givenRowWithGithubRepo_whenLoaded_thenIsGitHubBackedWithRepo() async {
// Happy path: a synced row makes the list GitHub-backed and exposes the
// repo the issue browser routes to.
let stub = StubListsService()
await stub.enqueueSchema(success: .empty)
let row = ListsFixtures.row(
id: "R1", listId: "L1", fields: ["Title": .string("Bug")],
source: "github", githubRepo: "CompositeCode/interlinedlist"
)
await stub.enqueueRows(success: RowsPage(rows: [row], hasMore: false, nextOffset: nil))
let viewModel = ListRowsViewModel(lists: stub, eventBus: ListsEventBus(), listId: "L1")

await viewModel.initialLoad()

XCTAssertTrue(viewModel.isGitHubBacked)
XCTAssertEqual(viewModel.gitHubRepo, "CompositeCode/interlinedlist")
}

func test_givenPlainRows_whenLoaded_thenNotGitHubBacked() async {
// Boundary: native rows leave the list plain, so Add Row stays active.
let stub = StubListsService()
await stub.enqueueSchema(success: .empty)
let row = ListsFixtures.row(id: "R1", listId: "L1", fields: ["A": .string("v")])
await stub.enqueueRows(success: RowsPage(rows: [row], hasMore: false, nextOffset: nil))
let viewModel = ListRowsViewModel(lists: stub, eventBus: ListsEventBus(), listId: "L1")

await viewModel.initialLoad()

XCTAssertFalse(viewModel.isGitHubBacked)
XCTAssertNil(viewModel.gitHubRepo)
}

func test_givenGitHubBackedList_whenAddRow_thenNoNativeRowCreated() async {
// The guard: Add Row must not POST a native empty row on a GitHub-backed
// list. It's a no-op — no createRow call, no optimistic row left behind.
let stub = StubListsService()
await stub.enqueueSchema(success: .empty)
let row = ListsFixtures.row(
id: "R1", listId: "L1", fields: [:], githubRepo: "acme/widgets"
)
await stub.enqueueRows(success: RowsPage(rows: [row], hasMore: false, nextOffset: nil))
// Enqueue a createRow outcome that must NOT be consumed.
await stub.enqueueCreateRow(success: ListsFixtures.row(id: "SHOULD-NOT-APPEAR"))
let viewModel = ListRowsViewModel(lists: stub, eventBus: ListsEventBus(), listId: "L1")
await viewModel.initialLoad()

await viewModel.addRow()

XCTAssertEqual(viewModel.rows.map(\.id), ["R1"], "rows unchanged; no optimistic row")
let created = await stub.recorded.contains { if case .createRow = $0.kind { return true }; return false }
XCTAssertFalse(created, "createRow must not be called on a GitHub-backed list")
}

// MARK: - updateRow optimistic + rollback

func test_givenUpdateSuccess_whenUpdating_thenReplacesWithServerCopy() async {
Expand Down
6 changes: 5 additions & 1 deletion AppTests/Support/StubListsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,16 @@ enum ListsFixtures {
static func row(
id: String,
listId: String = "L1",
fields: [String: ListCellValue] = [:]
fields: [String: ListCellValue] = [:],
source: String? = nil,
githubRepo: String? = nil
) -> ListRow {
ListRow(
id: id,
listID: listId,
fields: fields,
source: source,
githubRepo: githubRepo,
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ extension ListRow {
id: dto.id,
listID: dto.listId,
fields: dto.rowData.mapValues(ListCellValue.init(from:)),
// Thread the row-level GitHub-backing markers through so the UI can
// detect a GitHub-backed list from its rows (work-consolidation.md
// P3-C). Both stay nil for native rows.
source: dto.source,
githubRepo: dto.githubRepo,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,41 @@ public struct ListRow: Sendable, Equatable, Identifiable {
/// Field-name → cell value, in no defined order. The schema string on the
/// owning `ListDetail` defines the canonical column order for rendering.
public let fields: [String: ListCellValue]
/// Origin marker when this row was synced from an external source rather
/// than authored in-app (e.g. `"github"`). `nil` for native rows. Projected
/// from `ListRowDTO.source`.
public let source: String?
/// The `"owner/repo"` slug when this row was synced from GitHub. `nil` for
/// native rows. Projected from `ListRowDTO.githubRepo`.
///
/// This is the row-level backing signal the UI uses to recognise a
/// GitHub-backed list and route row-creation to the issue flow — there is no
/// stable list-level `githubSource` on the wire yet (work-consolidation.md
/// P3-C), so backing is derived from the rows.
public let githubRepo: String?
public let createdAt: Date?
public let updatedAt: Date?

/// Whether this row originates from a GitHub sync. True when it carries a
/// `githubRepo` (the authoritative marker) or a `source` of `"github"`.
public var isGitHubBacked: Bool {
githubRepo != nil || source?.lowercased() == "github"
}

public init(
id: String,
listID: String? = nil,
fields: [String: ListCellValue],
source: String? = nil,
githubRepo: String? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.listID = listID
self.fields = fields
self.source = source
self.githubRepo = githubRepo
self.createdAt = createdAt
self.updatedAt = updatedAt
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,49 @@ final class OwnedListsServiceTests: XCTestCase {
}
}

// MARK: - row GitHub-backing projection (ListRowDTO → ListRow)

func test_givenGitHubSyncedRowDTO_whenMapped_thenCarriesSourceRepoAndIsBacked() {
// Happy path: a synced row carries source + repo; both project through
// and `isGitHubBacked` is true so the UI can route to the issue flow.
let dto = ListRowDTO(
id: "r1",
listId: "L1",
rowData: [:],
source: "github",
githubRepo: "CompositeCode/interlinedlist"
)

let row = ListRow(from: dto)

XCTAssertEqual(row.source, "github")
XCTAssertEqual(row.githubRepo, "CompositeCode/interlinedlist")
XCTAssertTrue(row.isGitHubBacked)
}

func test_givenNativeRowDTO_whenMapped_thenNotGitHubBacked() {
// Boundary: a native row omits both markers; projection keeps them nil
// and `isGitHubBacked` is false so native Add Row stays available.
let dto = ListRowDTO(id: "r1", listId: "L1", rowData: ["Title": .string("Dune")])

let row = ListRow(from: dto)

XCTAssertNil(row.source)
XCTAssertNil(row.githubRepo)
XCTAssertFalse(row.isGitHubBacked)
}

func test_givenSourceGithubButNoRepo_whenMapped_thenStillBacked() {
// Either marker alone suffices: a `source: "github"` with no repo slug
// is still recognised as backed (case-insensitive).
let dto = ListRowDTO(id: "r1", listId: "L1", rowData: [:], source: "GitHub")

let row = ListRow(from: dto)

XCTAssertTrue(row.isGitHubBacked)
XCTAssertNil(row.githubRepo)
}

// MARK: - row CRUD

func test_givenRowData_whenCreatingRow_thenPostsAndMapsResponse() async throws {
Expand Down
20 changes: 20 additions & 0 deletions Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,39 @@ public struct ListRowDTO: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let listId: String?
public let rowData: [String: ListJSONValue]
/// The row's origin marker when the row was synced from an external source
/// rather than authored in-app (e.g. `"github"`). `nil` for native rows.
///
/// A list becomes "GitHub-backed" at the row level — the live API attaches
/// `source`/`githubRepo` to synced rows (see `work-consolidation.md` P3-C:
/// "rows carry `source`/`githubRepo` live") while a stable **list-level**
/// `githubSource` object on create/read is still unconfirmed upstream. Both
/// fields are optional so a native row (and every existing fixture) decodes
/// unchanged; a row that carries them lets the client recognise the backing
/// and route row-creation to the GitHub issue flow instead of a native row.
public let source: String?
/// The `"owner/repo"` slug a GitHub-synced row belongs to. `nil` for native
/// rows. Paired with `source`; either may appear alone depending on the
/// route, so the client treats a non-nil `githubRepo` as the authoritative
/// backing signal.
public let githubRepo: String?
public let createdAt: Date?
public let updatedAt: Date?

public init(
id: String,
listId: String? = nil,
rowData: [String: ListJSONValue],
source: String? = nil,
githubRepo: String? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.listId = listId
self.rowData = rowData
self.source = source
self.githubRepo = githubRepo
self.createdAt = createdAt
self.updatedAt = updatedAt
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ final class ListsEndpointTests: XCTestCase {
XCTAssertEqual(received[0].url?.path, "/api/lists/7/data/r1")
}

// A GitHub-synced row carries `source`/`githubRepo` alongside `rowData`
// (work-consolidation.md P3-C). Both must decode so the client can detect
// the backing and route row-creation to the issue flow.
func test_givenGitHubSyncedRow_whenRowSent_thenDecodesSourceAndRepo() async throws {
let (client, transport) = makeClient()
await transport.enqueue(.json(#"""
{"id":"r1","listId":"7","rowData":{"Title":"Fix crash"},
"source":"github","githubRepo":"CompositeCode/interlinedlist"}
"""#))

let row = try await client.send(Lists.row(listId: "7", rowId: "r1"))

XCTAssertEqual(row.source, "github")
XCTAssertEqual(row.githubRepo, "CompositeCode/interlinedlist")
}

// Boundary: a native row omits both fields — they decode as nil, not a
// failure, so existing (non-GitHub) rows keep decoding unchanged.
func test_givenNativeRow_whenRowSent_thenSourceAndRepoAreNil() async throws {
let (client, transport) = makeClient()
await transport.enqueue(.json(#"{"id":"r1","listId":"7","rowData":{"Title":"Dune"}}"#))

let row = try await client.send(Lists.row(listId: "7", rowId: "r1"))

XCTAssertNil(row.source)
XCTAssertNil(row.githubRepo)
}

func test_givenRowData_whenCreateRowSent_thenEncodesRowDataEnvelope() async throws {
let (client, transport) = makeClient()
await transport.enqueue(.json(#"{"id":"r9","rowData":{"Title":"New"}}"#))
Expand Down
Loading