WIP: manage collections from rail
This commit is contained in:
@@ -18,7 +18,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
||||
- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
|
||||
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
|
||||
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
|
||||
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
|
||||
- Copy and paste actions with Accessibility permission fallback
|
||||
- Image thumbnail cache with byte and file-count pruning
|
||||
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
|
||||
|
||||
@@ -41,10 +41,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
||||
9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
|
||||
11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
12. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
13. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected.
|
||||
14. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||
11. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
12. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
13. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
14. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
||||
15. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||
|
||||
## Copy And Paste
|
||||
|
||||
|
||||
@@ -252,6 +252,77 @@ final class SettingsModel {
|
||||
}?.value
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) -> String? {
|
||||
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
|
||||
let normalizedNewName = ClipboardCollectionDefaults.normalizedName(newName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let oldIndex = customCollectionNames.firstIndex {
|
||||
$0.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame
|
||||
}
|
||||
let oldCanonicalName = oldIndex.map { customCollectionNames[$0] } ?? normalizedCurrentName
|
||||
let targetIndex = customCollectionNames.firstIndex {
|
||||
$0.caseInsensitiveCompare(normalizedNewName) == .orderedSame
|
||||
}
|
||||
let targetCanonicalName: String
|
||||
if let targetIndex, targetIndex != oldIndex {
|
||||
targetCanonicalName = customCollectionNames[targetIndex]
|
||||
} else {
|
||||
targetCanonicalName = normalizedNewName
|
||||
}
|
||||
var changed = false
|
||||
|
||||
if let oldIndex {
|
||||
if let targetIndex, targetIndex != oldIndex {
|
||||
customCollectionNames.remove(at: oldIndex)
|
||||
changed = true
|
||||
} else if customCollectionNames[oldIndex] != normalizedNewName {
|
||||
customCollectionNames[oldIndex] = normalizedNewName
|
||||
changed = true
|
||||
}
|
||||
} else if targetIndex == nil {
|
||||
customCollectionNames.append(normalizedNewName)
|
||||
changed = true
|
||||
}
|
||||
|
||||
let existingColor = collectionColorHex(forCollectionNamed: oldCanonicalName)
|
||||
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(oldCanonicalName) == .orderedSame {
|
||||
collectionColorHexes.removeValue(forKey: key)
|
||||
changed = true
|
||||
}
|
||||
let resolvedColor = Self.normalizedHexColor(colorHex) ?? existingColor
|
||||
if let resolvedColor,
|
||||
collectionColorHexes[targetCanonicalName] != resolvedColor {
|
||||
collectionColorHexes[targetCanonicalName] = resolvedColor
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
storeAndNotify(.collections)
|
||||
}
|
||||
return targetCanonicalName
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deleteCollection(named name: String) -> String? {
|
||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
||||
var changed = false
|
||||
if let index = customCollectionNames.firstIndex(where: { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }) {
|
||||
customCollectionNames.remove(at: index)
|
||||
changed = true
|
||||
}
|
||||
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(normalizedName) == .orderedSame {
|
||||
collectionColorHexes.removeValue(forKey: key)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
storeAndNotify(.collections)
|
||||
}
|
||||
return normalizedName
|
||||
}
|
||||
|
||||
private static func readShortcut(from value: String?) -> ShortcutBinding? {
|
||||
guard let value else { return nil }
|
||||
return ShortcutBinding(encoded: value)
|
||||
|
||||
@@ -557,6 +557,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
chip.onDropItem = { [weak self] itemID in
|
||||
self?.viewModel.assignItem(withID: itemID, to: collectionName)
|
||||
}
|
||||
chip.onEdit = { [weak self] in
|
||||
self?.editCollection(named: collectionName)
|
||||
}
|
||||
chip.onDelete = { [weak self] in
|
||||
self?.deleteCollection(named: collectionName)
|
||||
}
|
||||
customCollectionButtons[collectionName] = chip
|
||||
collectionStack.addArrangedSubview(chip)
|
||||
}
|
||||
@@ -1347,6 +1353,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
createCollectionFromToolbar()
|
||||
}
|
||||
|
||||
func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] {
|
||||
customCollectionButtons[collectionName]?.debugMenuTitles ?? []
|
||||
}
|
||||
|
||||
func debugEditCollection(named collectionName: String, to newName: String, colorHex: String) {
|
||||
viewModel.updateCollection(named: collectionName, to: newName, colorHex: colorHex)
|
||||
}
|
||||
|
||||
func debugDeleteCollection(named collectionName: String) {
|
||||
viewModel.deleteCollection(named: collectionName)
|
||||
}
|
||||
|
||||
func debugShowFirstCardInClipboard() {
|
||||
showSelectedInClipboard(at: 0)
|
||||
}
|
||||
@@ -1432,6 +1450,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
|
||||
}
|
||||
|
||||
private func editCollection(named collectionName: String) {
|
||||
guard let request = requestCollectionEdit(named: collectionName) else { return }
|
||||
viewModel.updateCollection(named: collectionName, to: request.name, colorHex: request.colorHex)
|
||||
}
|
||||
|
||||
private func deleteCollection(named collectionName: String) {
|
||||
let count = viewModel.collectionCount(named: collectionName)
|
||||
guard confirmDeleteCollection(named: collectionName, count: count) else { return }
|
||||
viewModel.deleteCollection(named: collectionName)
|
||||
}
|
||||
|
||||
private func requestCollectionCreation() -> CollectionCreationRequest? {
|
||||
#if DEBUG
|
||||
if let collectionNameProviderForTesting {
|
||||
@@ -1445,12 +1474,38 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
return requestCollectionDetails(
|
||||
title: "New Collection",
|
||||
message: "Name this collection and choose its color.",
|
||||
actionTitle: "Create",
|
||||
initialName: "",
|
||||
initialColor: ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCollectionEdit(named collectionName: String) -> CollectionCreationRequest? {
|
||||
requestCollectionDetails(
|
||||
title: "Edit Collection",
|
||||
message: "Update this collection's name and color.",
|
||||
actionTitle: "Save",
|
||||
initialName: collectionName,
|
||||
initialColor: collectionColor(forCollectionNamed: collectionName)
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCollectionDetails(
|
||||
title: String,
|
||||
message: String,
|
||||
actionTitle: String,
|
||||
initialName: String,
|
||||
initialColor: NSColor
|
||||
) -> CollectionCreationRequest? {
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||
input.placeholderString = "Collection name"
|
||||
input.stringValue = ""
|
||||
input.stringValue = initialName
|
||||
|
||||
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
|
||||
colorWell.color = ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
|
||||
colorWell.color = initialColor
|
||||
|
||||
let colorLabel = NSTextField(labelWithString: "Color")
|
||||
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
||||
@@ -1468,10 +1523,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "New Collection"
|
||||
alert.informativeText = "Name this collection and choose its color."
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.accessoryView = stack
|
||||
alert.addButton(withTitle: "Create")
|
||||
alert.addButton(withTitle: actionTitle)
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.window.initialFirstResponder = input
|
||||
|
||||
@@ -1484,6 +1539,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
|
||||
)
|
||||
}
|
||||
|
||||
private func confirmDeleteCollection(named collectionName: String, count: Int) -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Delete \(collectionName)?"
|
||||
let noun = count == 1 ? "clip" : "clips"
|
||||
alert.informativeText = count > 0
|
||||
? "This removes \(count) \(noun) in this collection from clipboard history."
|
||||
: "This removes the empty collection."
|
||||
alert.addButton(withTitle: "Delete")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
return alert.runModal() == .alertFirstButtonReturn
|
||||
}
|
||||
}
|
||||
|
||||
private enum ClipboardItemDragPasteboard {
|
||||
@@ -1516,6 +1584,8 @@ private final class CollectionChipView: NSView {
|
||||
private var isDropTargeted = false
|
||||
var onPress: () -> Void = {}
|
||||
var onDropItem: ((UUID) -> Void)?
|
||||
var onEdit: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
init(title: String, color: NSColor) {
|
||||
self.titleText = title
|
||||
@@ -1630,6 +1700,38 @@ private final class CollectionChipView: NSView {
|
||||
onPress()
|
||||
}
|
||||
|
||||
override func menu(for event: NSEvent) -> NSMenu? {
|
||||
guard onEdit != nil || onDelete != nil else { return nil }
|
||||
return contextMenu()
|
||||
}
|
||||
|
||||
private func contextMenu() -> NSMenu {
|
||||
let menu = NSMenu(title: titleText)
|
||||
menu.autoenablesItems = false
|
||||
if onEdit != nil {
|
||||
let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "")
|
||||
item.target = self
|
||||
menu.addItem(item)
|
||||
}
|
||||
if onDelete != nil {
|
||||
if !menu.items.isEmpty {
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
}
|
||||
let item = NSMenuItem(title: "Delete Collection", action: #selector(deleteFromMenu), keyEquivalent: "")
|
||||
item.target = self
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
@objc private func editFromMenu() {
|
||||
onEdit?()
|
||||
}
|
||||
|
||||
@objc private func deleteFromMenu() {
|
||||
onDelete?()
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
|
||||
setDropTargeted(true)
|
||||
@@ -1682,6 +1784,10 @@ private final class CollectionChipView: NSView {
|
||||
func debugDropItem(_ itemID: UUID) {
|
||||
onDropItem?(itemID)
|
||||
}
|
||||
|
||||
var debugMenuTitles: [String] {
|
||||
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -503,6 +503,39 @@ final class ClipboardPanelViewModel {
|
||||
settings.collectionColorHex(forCollectionNamed: name)
|
||||
}
|
||||
|
||||
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) {
|
||||
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
|
||||
let normalizedNewName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) else {
|
||||
return
|
||||
}
|
||||
|
||||
for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
|
||||
store.setCollection(item.id, name: normalizedNewName)
|
||||
}
|
||||
if selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
|
||||
selectedCollectionName = normalizedNewName
|
||||
} else {
|
||||
recomputeVisibleItems()
|
||||
}
|
||||
statusMessage = "Updated \(normalizedNewName)"
|
||||
}
|
||||
|
||||
func deleteCollection(named name: String) {
|
||||
guard let normalizedName = settings.deleteCollection(named: name) else { return }
|
||||
let matchingIDs = items
|
||||
.filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame }
|
||||
.map(\.id)
|
||||
for id in matchingIDs {
|
||||
store.remove(id)
|
||||
}
|
||||
if selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame {
|
||||
selectedCollectionName = nil
|
||||
} else {
|
||||
recomputeVisibleItems()
|
||||
}
|
||||
statusMessage = "Deleted \(normalizedName)"
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
@@ -273,6 +273,57 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8")
|
||||
}
|
||||
|
||||
func testUpdateCollectionRenamesAssignedItemsAndColor() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
var research = makeTextItem("research note", createdAt: Date(timeIntervalSince1970: 100))
|
||||
research.collectionName = "Research Stack"
|
||||
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
|
||||
store.upsert(research)
|
||||
store.upsert(outside)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||
|
||||
viewModel.updateCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(viewModel.collectionNames, ["Product Research"])
|
||||
XCTAssertEqual(viewModel.collectionColorHex(named: "Product Research"), "#3366FF")
|
||||
XCTAssertEqual(viewModel.selectedCollectionName, "Product Research")
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["research note"])
|
||||
XCTAssertEqual(viewModel.statusMessage, "Updated Product Research")
|
||||
XCTAssertEqual(store.items.first(where: { $0.payload == "research note" })?.collectionName, "Product Research")
|
||||
}
|
||||
|
||||
func testDeleteCollectionRemovesCollectionItemsFromHistory() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
|
||||
client.collectionName = "Client Work"
|
||||
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
|
||||
store.upsert(client)
|
||||
store.upsert(outside)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8")
|
||||
|
||||
viewModel.deleteCollection(named: "Client Work")
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(viewModel.collectionNames, [])
|
||||
XCTAssertNil(viewModel.selectedCollectionName)
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note"])
|
||||
XCTAssertEqual(store.items.map(\.payload), ["outside note"])
|
||||
XCTAssertEqual(viewModel.statusMessage, "Deleted Client Work")
|
||||
}
|
||||
|
||||
func testSearchTextRecomputesVisibleItemsImmediately() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
|
||||
@@ -221,6 +221,42 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
|
||||
}
|
||||
|
||||
func testCollectionChipsExposeManagementMenuActions() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionMenuTitles(named: "Research Stack"), ["Edit Collection...", "-", "Delete Collection"])
|
||||
}
|
||||
|
||||
func testCollectionChipManagementRenamesAndDeletesCollections() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||
var item = makeTextItem("Collect this note", store: fixture.store)
|
||||
item.collectionName = "Research Stack"
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
fixture.view.debugEditCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Product Research"])
|
||||
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Product Research")
|
||||
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Product Research")
|
||||
XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF")
|
||||
|
||||
fixture.view.debugDeleteCollection(named: "Product Research")
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, [])
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), [])
|
||||
XCTAssertEqual(fixture.store.items.map(\.payload), [])
|
||||
}
|
||||
|
||||
func testSelectedCardActionsRespectSelectedKind() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
|
||||
|
||||
@@ -49,4 +49,32 @@ final class SettingsModelTests: XCTestCase {
|
||||
XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"])
|
||||
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355")
|
||||
}
|
||||
|
||||
func testCustomCollectionsCanBeUpdatedAndDeleted() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
|
||||
settings.ensureCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||
settings.ensureCollection(named: "Client Work", colorHex: "#FF3355")
|
||||
|
||||
let updatedName = settings.updateCollection(named: "research stack", to: "Product Research", colorHex: "#3366FF")
|
||||
|
||||
XCTAssertEqual(updatedName, "Product Research")
|
||||
XCTAssertEqual(settings.customCollectionNames, ["Product Research", "Client Work"])
|
||||
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Research Stack"))
|
||||
XCTAssertEqual(settings.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||
|
||||
settings.deleteCollection(named: "client work")
|
||||
|
||||
XCTAssertEqual(settings.customCollectionNames, ["Product Research"])
|
||||
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Client Work"))
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
|
||||
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user