Public: anyone can read this; the crew can change it.
A card moved under a filter lands where the hidden cards are: a visible index passed as an absolute one #63
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/33-a-card-moved-under-a-filter-lands-where → main 1 commit fork
2 files
changed +78
−19
src/keys.ts +36 −12
| @@ -85,6 +85,20 @@ export function visibleCards(board: BoardStore, state: State, columnId: string): | ||
| 85 | 85 | .filter((c) => q === "" || c.title.toLowerCase().includes(q)); |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | /** | |
| 89 | * Where `gap` — a position among a column's *visible* cards, 0 meaning | |
| 90 | * before all of them — lands among every card in that column, the index | |
| 91 | * `db.moveCard` expects. `all` and `visible` both exclude the card being | |
| 92 | * moved, `visible` being `all` filtered the same way `visibleCards` does, | |
| 93 | * so a hidden card between two visible ones never becomes the anchor. | |
| 94 | */ | |
| 95 | function absoluteIndex(all: Card[], visible: Card[], gap: number): number { | |
| 96 | const clamped = Math.max(0, Math.min(gap, visible.length)); | |
| 97 | if (clamped === 0) return visible.length === 0 ? 0 : all.findIndex((c) => c.id === visible[0].id); | |
| 98 | const before = visible[clamped - 1]; | |
| 99 | return all.findIndex((c) => c.id === before.id) + 1; | |
| 100 | } | |
| 101 | ||
| 88 | 102 | export function handleKey(state: State, key: string, board: BoardStore): Outcome { |
| 89 | 103 | if (state.prompt) return handlePromptKey(state, key, board); |
| 90 | 104 | if (state.mode === "boards") return handleBoardsKey(state, key, board); |
| @@ -196,24 +210,34 @@ function handleBoardKey(state: State, key: string, board: BoardStore): Outcome { | ||
| 196 | 210 | next.prompt = { kind: "editCard", label: "Edit card", value: card.title, cardId: card.id }; |
| 197 | 211 | } else if (key === "d" && card) { |
| 198 | 212 | board.deleteCard(state.boardId!, card.id); |
| 199 | } else if (key === "K" && card) { | |
| 200 | board.moveCard(state.boardId!, card.id, column.id, Math.max(0, state.cardCursor - 1)); | |
| 201 | next.cardCursor = Math.max(0, state.cardCursor - 1); | |
| 202 | } else if (key === "J" && card) { | |
| 203 | board.moveCard(state.boardId!, card.id, column.id, state.cardCursor + 1); | |
| 204 | next.cardCursor = Math.min(cards.length - 1, state.cardCursor + 1); | |
| 213 | } else if (key === "K" && card && state.cardCursor > 0) { | |
| 214 | const all = board.listCards(state.boardId!).filter((c) => c.column_id === column.id && c.id !== card.id); | |
| 215 | const visible = cards.filter((c) => c.id !== card.id); | |
| 216 | const gap = state.cardCursor - 1; | |
| 217 | board.moveCard(state.boardId!, card.id, column.id, absoluteIndex(all, visible, gap)); | |
| 218 | next.cardCursor = gap; | |
| 219 | } else if (key === "J" && card && state.cardCursor < cards.length - 1) { | |
| 220 | const all = board.listCards(state.boardId!).filter((c) => c.column_id === column.id && c.id !== card.id); | |
| 221 | const visible = cards.filter((c) => c.id !== card.id); | |
| 222 | const gap = state.cardCursor + 1; | |
| 223 | board.moveCard(state.boardId!, card.id, column.id, absoluteIndex(all, visible, gap)); | |
| 224 | next.cardCursor = gap; | |
| 205 | 225 | } else if (key === "H" && card && state.columnCursor > 0) { |
| 206 | 226 | const target = columns[state.columnCursor - 1]; |
| 207 | const index = Math.min(state.cardCursor, visibleCards(board, state, target.id).length); | |
| 208 | board.moveCard(state.boardId!, card.id, target.id, index); | |
| 227 | const all = board.listCards(state.boardId!).filter((c) => c.column_id === target.id); | |
| 228 | const visible = visibleCards(board, state, target.id); | |
| 229 | const gap = Math.min(state.cardCursor, visible.length); | |
| 230 | board.moveCard(state.boardId!, card.id, target.id, absoluteIndex(all, visible, gap)); | |
| 209 | 231 | next.columnCursor = state.columnCursor - 1; |
| 210 | next.cardCursor = index; | |
| 232 | next.cardCursor = gap; | |
| 211 | 233 | } else if (key === "L" && card && state.columnCursor < columns.length - 1) { |
| 212 | 234 | const target = columns[state.columnCursor + 1]; |
| 213 | const index = Math.min(state.cardCursor, visibleCards(board, state, target.id).length); | |
| 214 | board.moveCard(state.boardId!, card.id, target.id, index); | |
| 235 | const all = board.listCards(state.boardId!).filter((c) => c.column_id === target.id); | |
| 236 | const visible = visibleCards(board, state, target.id); | |
| 237 | const gap = Math.min(state.cardCursor, visible.length); | |
| 238 | board.moveCard(state.boardId!, card.id, target.id, absoluteIndex(all, visible, gap)); | |
| 215 | 239 | next.columnCursor = state.columnCursor + 1; |
| 216 | next.cardCursor = index; | |
| 240 | next.cardCursor = gap; | |
| 217 | 241 | } else if (key === "c") { |
| 218 | 242 | next.prompt = { kind: "newColumn", label: "New column", value: "" }; |
| 219 | 243 | } else if (key === "r" && column) { |
src/tty.test.ts +42 −7
| @@ -327,12 +327,7 @@ test("f sets the filter and the card cursor stays inside the filtered list", () | ||
| 327 | 327 | expect(visibleCards(db, cleared, todo.id).map((c) => c.title)).toEqual(["alpha", "beta", "gamma"]); |
| 328 | 328 | }); |
| 329 | 329 | |
| 330 | test("J with a filter on counts the index among all the cards, not the visible ones", () => { | |
| 331 | // Today's behaviour, recorded rather than fixed, and a bug: the cursor | |
| 332 | // index comes from the filtered list but db.moveCard reads it as an index | |
| 333 | // among every card in the column, so a hidden card absorbs the move and | |
| 334 | // the visible order does not change. #33 fixes it and rewrites this test; | |
| 335 | // until then this is a marker, not a contract. | |
| 330 | test("J and K move a card past its visible neighbour, not the hidden ones in between", () => { | |
| 336 | 331 | const { state, boardId } = openBoard(); |
| 337 | 332 | const [todo] = db.listColumns(boardId); |
| 338 | 333 | for (const title of ["x-one", "hidden", "x-two"]) db.createCard(boardId, todo.id, title); |
| @@ -340,10 +335,50 @@ test("J with a filter on counts the index among all the cards, not the visible o | ||
| 340 | 335 | const filtered = { ...state, filter: "x-" }; |
| 341 | 336 | expect(visibleCards(db, filtered, todo.id).map((c) => c.title)).toEqual(["x-one", "x-two"]); |
| 342 | 337 | |
| 338 | // J on x-one lands it after the card visibly below it (x-two), and the | |
| 339 | // hidden card in between does not move. | |
| 343 | 340 | const moved = press(filtered, "J"); |
| 344 | 341 | expect(moved.cardCursor).toBe(1); |
| 342 | expect(titlesIn(boardId, 0)).toEqual(["hidden", "x-two", "x-one"]); | |
| 343 | expect(visibleCards(db, moved, todo.id).map((c) => c.title)).toEqual(["x-two", "x-one"]); | |
| 344 | ||
| 345 | // K undoes it: x-one lands back before x-two, hidden still untouched. | |
| 346 | const back = press(moved, "K"); | |
| 347 | expect(back.cardCursor).toBe(0); | |
| 345 | 348 | expect(titlesIn(boardId, 0)).toEqual(["hidden", "x-one", "x-two"]); |
| 346 | expect(visibleCards(db, moved, todo.id).map((c) => c.title)).toEqual(["x-one", "x-two"]); | |
| 349 | expect(visibleCards(db, back, todo.id).map((c) => c.title)).toEqual(["x-one", "x-two"]); | |
| 350 | ||
| 351 | // Clamped at the ends of the *visible* list: K at the top and J at the | |
| 352 | // bottom leave the order, and the hidden card, alone. | |
| 353 | expect(press(back, "K").cardCursor).toBe(0); | |
| 354 | expect(titlesIn(boardId, 0)).toEqual(["hidden", "x-one", "x-two"]); | |
| 355 | const atBottom = { ...back, cardCursor: 1 }; | |
| 356 | expect(press(atBottom, "J").cardCursor).toBe(1); | |
| 357 | expect(titlesIn(boardId, 0)).toEqual(["hidden", "x-one", "x-two"]); | |
| 358 | }); | |
| 359 | ||
| 360 | test("H and L land a card among the target's visible cards, not counting its hidden ones", () => { | |
| 361 | const { state, boardId } = openBoard(); | |
| 362 | const [todo, doing] = db.listColumns(boardId); | |
| 363 | for (const title of ["s-one", "s-two"]) db.createCard(boardId, todo.id, title); | |
| 364 | for (const title of ["x-first", "hidden", "x-second"]) db.createCard(boardId, doing.id, title); | |
| 365 | ||
| 366 | const filtered = { ...state, filter: "-", cardCursor: 1 }; | |
| 367 | expect(visibleCards(db, filtered, todo.id).map((c) => c.title)).toEqual(["s-one", "s-two"]); | |
| 368 | expect(visibleCards(db, filtered, doing.id).map((c) => c.title)).toEqual(["x-first", "x-second"]); | |
| 369 | ||
| 370 | // s-two, second in the visible source list, lands second in the visible | |
| 371 | // target list too: after x-first, before x-second — the hidden card | |
| 372 | // between them does not move and is not counted. | |
| 373 | const right = press(filtered, "L"); | |
| 374 | expect(titlesIn(boardId, 1)).toEqual(["x-first", "s-two", "hidden", "x-second"]); | |
| 375 | expect(visibleCards(db, right, doing.id).map((c) => c.title)).toEqual(["x-first", "s-two", "x-second"]); | |
| 376 | expect([right.columnCursor, right.cardCursor]).toEqual([1, 1]); | |
| 377 | ||
| 378 | const back = press(right, "H"); | |
| 379 | expect(titlesIn(boardId, 0)).toEqual(["s-one", "s-two"]); | |
| 380 | expect(titlesIn(boardId, 1)).toEqual(["x-first", "hidden", "x-second"]); | |
| 381 | expect([back.columnCursor, back.cardCursor]).toEqual([0, 1]); | |
| 347 | 382 | }); |
| 348 | 383 | |
| 349 | 384 | test("u undoes the last change", () => { |
new above
offline