Skip to content

Commit b018a7c

Browse files
committed
feat(datagrid): add visible add and remove buttons to the table structure editor (#1319)
1 parent 2db1a23 commit b018a7c

5 files changed

Lines changed: 264 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425)
1616
- Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421)
1717
- Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON.
18+
- Add and remove buttons in the table structure editor for columns, indexes, and foreign keys, alongside the existing Cmd+Shift+N shortcut. An empty Indexes or Foreign Keys tab also shows a labelled add button. (#1319)
1819

1920
### Changed
2021

TablePro/Views/Structure/TableStructureView.swift

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ struct TableStructureView: View {
8484
toolbar
8585
Divider()
8686
contentArea
87+
structureFooter
8788
}
8889
.task(loadInitialData)
8990
.onChange(of: selectedRows) { _, newRows in selectionState.indices = newRows }
@@ -171,6 +172,94 @@ struct TableStructureView: View {
171172
.padding()
172173
}
173174

175+
// MARK: - Footer (Add / Remove)
176+
177+
@ViewBuilder
178+
private var structureFooter: some View {
179+
if isEditableTab {
180+
VStack(spacing: 0) {
181+
Divider()
182+
HStack(spacing: 0) {
183+
addButton
184+
removeButton
185+
Spacer()
186+
}
187+
.padding(.horizontal, 8)
188+
.frame(height: 28)
189+
.background(.bar)
190+
}
191+
}
192+
}
193+
194+
private var addButton: some View {
195+
Button(action: { gridDelegate.dataGridAddRow() }) {
196+
Label(addLabel(for: selectedTab), systemImage: "plus")
197+
.labelStyle(.iconOnly)
198+
.frame(width: 22, height: 22)
199+
}
200+
.buttonStyle(.borderless)
201+
.help(addLabel(for: selectedTab))
202+
.disabled(!canAdd(for: selectedTab))
203+
}
204+
205+
private var removeButton: some View {
206+
Button(action: { gridDelegate.dataGridDeleteRows(selectedRows) }) {
207+
Label(removeLabel(for: selectedTab), systemImage: "minus")
208+
.labelStyle(.iconOnly)
209+
.frame(width: 22, height: 22)
210+
}
211+
.buttonStyle(.borderless)
212+
.help(removeLabel(for: selectedTab))
213+
.disabled(!canRemove(for: selectedTab))
214+
}
215+
216+
private var isEditableTab: Bool {
217+
guard connection.type.supportsSchemaEditing else { return false }
218+
switch selectedTab {
219+
case .columns, .indexes, .foreignKeys:
220+
return true
221+
case .ddl, .parts:
222+
return false
223+
}
224+
}
225+
226+
private func canAdd(for tab: StructureTab) -> Bool {
227+
switch tab {
228+
case .columns: return connection.type.supportsAddColumn
229+
case .indexes: return connection.type.supportsAddIndex
230+
case .foreignKeys: return connection.type.supportsForeignKeys
231+
case .ddl, .parts: return false
232+
}
233+
}
234+
235+
private func canRemove(for tab: StructureTab) -> Bool {
236+
guard !selectedRows.isEmpty else { return false }
237+
switch tab {
238+
case .columns: return connection.type.supportsDropColumn
239+
case .indexes: return connection.type.supportsDropIndex
240+
case .foreignKeys: return connection.type.supportsForeignKeys
241+
case .ddl, .parts: return false
242+
}
243+
}
244+
245+
private func addLabel(for tab: StructureTab) -> String {
246+
switch tab {
247+
case .columns: return String(localized: "Add Column")
248+
case .indexes: return String(localized: "Add Index")
249+
case .foreignKeys: return String(localized: "Add Foreign Key")
250+
case .ddl, .parts: return String(localized: "Add Row")
251+
}
252+
}
253+
254+
private func removeLabel(for tab: StructureTab) -> String {
255+
switch tab {
256+
case .columns: return String(localized: "Remove Column")
257+
case .indexes: return String(localized: "Remove Index")
258+
case .foreignKeys: return String(localized: "Remove Foreign Key")
259+
case .ddl, .parts: return String(localized: "Remove Row")
260+
}
261+
}
262+
174263
// MARK: - Tab Label with Count Badge
175264

176265
private func tabLabel(for tab: StructureTab) -> String {
@@ -206,15 +295,39 @@ struct TableStructureView: View {
206295
@ViewBuilder
207296
private var tabContent: some View {
208297
switch selectedTab {
209-
case .columns, .indexes, .foreignKeys:
298+
case .columns:
210299
structureGrid
300+
case .indexes:
301+
if shouldShowIndexesEmptyState {
302+
EmptyStateView.indexes { gridDelegate.dataGridAddRow() }
303+
} else {
304+
structureGrid
305+
}
306+
case .foreignKeys:
307+
if shouldShowForeignKeysEmptyState {
308+
EmptyStateView.foreignKeys { gridDelegate.dataGridAddRow() }
309+
} else {
310+
structureGrid
311+
}
211312
case .ddl:
212313
ddlView
213314
case .parts:
214315
ClickHousePartsView(tableName: tableName, connectionId: connection.id)
215316
}
216317
}
217318

319+
private var shouldShowIndexesEmptyState: Bool {
320+
loadedTabs.contains(.indexes)
321+
&& structureChangeManager.workingIndexes.isEmpty
322+
&& connection.type.supportsAddIndex
323+
}
324+
325+
private var shouldShowForeignKeysEmptyState: Bool {
326+
loadedTabs.contains(.foreignKeys)
327+
&& structureChangeManager.workingForeignKeys.isEmpty
328+
&& connection.type.supportsForeignKeys
329+
}
330+
218331
// MARK: - Structure Grid (DataGridView)
219332

220333
private func makeCurrentProvider() -> StructureRowProvider {

TableProTests/Views/Main/CommandActionsDispatchTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,25 @@ struct CommandActionsDispatchTests {
131131

132132
#expect(pasteRowsCalled)
133133
}
134+
135+
// MARK: - addNewRow (structure mode)
136+
137+
@Test("addNewRow in structure mode calls structureActions.addRow")
138+
func addNewRow_structureMode_callsStructureActions() {
139+
let (actions, coordinator) = makeSUT()
140+
coordinator.tabManager.addTab(databaseName: "testdb")
141+
142+
if let idx = coordinator.tabManager.selectedTabIndex {
143+
coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure
144+
}
145+
146+
let handler = StructureViewActionHandler()
147+
var addRowCalled = false
148+
handler.addRow = { addRowCalled = true }
149+
coordinator.structureActions = handler
150+
151+
actions.addNewRow()
152+
153+
#expect(addRowCalled)
154+
}
134155
}

TableProTests/Views/Main/StructureActionHandlerTests.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,20 @@ struct StructureActionHandlerTests {
8888
#expect(count == 1)
8989
}
9090

91-
// MARK: - All Six Closures Fire Independently
91+
@Test("addRow closure fires when invoked")
92+
func addRow_fires() {
93+
let handler = StructureViewActionHandler()
94+
var count = 0
95+
handler.addRow = { count += 1 }
96+
97+
handler.addRow?()
98+
99+
#expect(count == 1)
100+
}
101+
102+
// MARK: - All Closures Fire Independently
92103

93-
@Test("all six closures fire independently without cross-talk")
104+
@Test("all closures fire independently without cross-talk")
94105
func allClosures_fireIndependently() {
95106
let handler = StructureViewActionHandler()
96107
var counts = [String: Int]()
@@ -101,20 +112,23 @@ struct StructureActionHandlerTests {
101112
handler.pasteRows = { counts["pasteRows", default: 0] += 1 }
102113
handler.undo = { counts["undo", default: 0] += 1 }
103114
handler.redo = { counts["redo", default: 0] += 1 }
115+
handler.addRow = { counts["addRow", default: 0] += 1 }
104116

105117
handler.saveChanges?()
106118
handler.previewSQL?()
107119
handler.copyRows?()
108120
handler.pasteRows?()
109121
handler.undo?()
110122
handler.redo?()
123+
handler.addRow?()
111124

112125
#expect(counts["saveChanges"] == 1)
113126
#expect(counts["previewSQL"] == 1)
114127
#expect(counts["copyRows"] == 1)
115128
#expect(counts["pasteRows"] == 1)
116129
#expect(counts["undo"] == 1)
117130
#expect(counts["redo"] == 1)
131+
#expect(counts["addRow"] == 1)
118132
}
119133

120134
// MARK: - Nil Closures Are Safe
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//
2+
// StructureGridDelegateAddRowTests.swift
3+
// TableProTests
4+
//
5+
// Tests for StructureGridDelegate.dataGridAddRow() / dataGridDeleteRows(_:)
6+
// routing per active sub-tab. These cover the contract the new structure
7+
// footer +/- buttons depend on.
8+
//
9+
10+
import Foundation
11+
@testable import TablePro
12+
import TableProPluginKit
13+
import Testing
14+
15+
@MainActor @Suite("StructureGridDelegate add and delete row routing")
16+
struct StructureGridDelegateAddRowTests {
17+
private func makeDelegate(
18+
selectedTab: StructureTab = .columns,
19+
type: DatabaseType = .mysql
20+
) -> (StructureGridDelegate, StructureChangeManager) {
21+
let manager = StructureChangeManager()
22+
let connection = TestFixtures.makeConnection(type: type)
23+
let delegate = StructureGridDelegate(
24+
structureChangeManager: manager,
25+
selectedTab: selectedTab,
26+
connection: connection,
27+
tableName: "t",
28+
coordinator: nil
29+
)
30+
return (delegate, manager)
31+
}
32+
33+
@Test("Columns sub-tab: dataGridAddRow appends a placeholder column")
34+
func columnsTab_addsColumn() {
35+
let (delegate, manager) = makeDelegate(selectedTab: .columns)
36+
let before = manager.workingColumns.count
37+
delegate.dataGridAddRow()
38+
#expect(manager.workingColumns.count == before + 1)
39+
}
40+
41+
@Test("Indexes sub-tab: dataGridAddRow appends a placeholder index")
42+
func indexesTab_addsIndex() {
43+
let (delegate, manager) = makeDelegate(selectedTab: .indexes)
44+
let before = manager.workingIndexes.count
45+
delegate.dataGridAddRow()
46+
#expect(manager.workingIndexes.count == before + 1)
47+
}
48+
49+
@Test("Foreign keys sub-tab: dataGridAddRow appends a placeholder foreign key")
50+
func foreignKeysTab_addsForeignKey() {
51+
let (delegate, manager) = makeDelegate(selectedTab: .foreignKeys)
52+
let before = manager.workingForeignKeys.count
53+
delegate.dataGridAddRow()
54+
#expect(manager.workingForeignKeys.count == before + 1)
55+
}
56+
57+
@Test("DDL sub-tab: dataGridAddRow is a no-op")
58+
func ddlTab_isNoOp() {
59+
let (delegate, manager) = makeDelegate(selectedTab: .ddl)
60+
let columnsBefore = manager.workingColumns.count
61+
let indexesBefore = manager.workingIndexes.count
62+
let fksBefore = manager.workingForeignKeys.count
63+
delegate.dataGridAddRow()
64+
#expect(manager.workingColumns.count == columnsBefore)
65+
#expect(manager.workingIndexes.count == indexesBefore)
66+
#expect(manager.workingForeignKeys.count == fksBefore)
67+
}
68+
69+
@Test("Parts sub-tab: dataGridAddRow is a no-op")
70+
func partsTab_isNoOp() {
71+
let (delegate, manager) = makeDelegate(selectedTab: .parts)
72+
let columnsBefore = manager.workingColumns.count
73+
let indexesBefore = manager.workingIndexes.count
74+
let fksBefore = manager.workingForeignKeys.count
75+
delegate.dataGridAddRow()
76+
#expect(manager.workingColumns.count == columnsBefore)
77+
#expect(manager.workingIndexes.count == indexesBefore)
78+
#expect(manager.workingForeignKeys.count == fksBefore)
79+
}
80+
81+
@Test("Indexes sub-tab on SQLite: dataGridAddRow is a no-op (supportsAddIndex == false)")
82+
func sqliteIndexes_isNoOp() {
83+
let (delegate, manager) = makeDelegate(selectedTab: .indexes, type: .sqlite)
84+
let before = manager.workingIndexes.count
85+
delegate.dataGridAddRow()
86+
#expect(manager.workingIndexes.count == before)
87+
}
88+
89+
@Test("Delete: ddl sub-tab is a no-op")
90+
func ddlTab_deleteIsNoOp() {
91+
let (delegate, manager) = makeDelegate(selectedTab: .ddl)
92+
let columnsBefore = manager.workingColumns.count
93+
let indexesBefore = manager.workingIndexes.count
94+
let fksBefore = manager.workingForeignKeys.count
95+
delegate.dataGridDeleteRows([0])
96+
#expect(manager.workingColumns.count == columnsBefore)
97+
#expect(manager.workingIndexes.count == indexesBefore)
98+
#expect(manager.workingForeignKeys.count == fksBefore)
99+
}
100+
101+
@Test("Delete: parts sub-tab is a no-op")
102+
func partsTab_deleteIsNoOp() {
103+
let (delegate, manager) = makeDelegate(selectedTab: .parts)
104+
let columnsBefore = manager.workingColumns.count
105+
let indexesBefore = manager.workingIndexes.count
106+
let fksBefore = manager.workingForeignKeys.count
107+
delegate.dataGridDeleteRows([0])
108+
#expect(manager.workingColumns.count == columnsBefore)
109+
#expect(manager.workingIndexes.count == indexesBefore)
110+
#expect(manager.workingForeignKeys.count == fksBefore)
111+
}
112+
}

0 commit comments

Comments
 (0)