diff --git a/e2e/specs/07-checklists.e2e.ts b/e2e/specs/07-checklists.e2e.ts
index dc5d8c8..64e619f 100644
--- a/e2e/specs/07-checklists.e2e.ts
+++ b/e2e/specs/07-checklists.e2e.ts
@@ -2,7 +2,7 @@ import { browser, expect } from '@wdio/globals';
import { canvas } from '../pageobjects/canvas.page.js';
import { editor } from '../pageobjects/editor.page.js';
-import { clipboardText, reloadCanvas, testid } from '../support/app.js';
+import { clipboardText, eventually, reloadCanvas, testid } from '../support/app.js';
import { bridge, draft, homeSpaceId, query } from '../support/bridge.js';
/**
@@ -56,11 +56,15 @@ describe('Todo lists', () => {
// ⚠️ The items sit on a layer above the card button, which is why they can be clicked
// at all — a `
` inside a `
` would be invalid HTML.
const card = await canvas.cardWithTitle(title);
- await card.$('[data-testid="note-card-item"]').click();
- await browser.pause(600);
-
- // Toggled back off: the editor ticked this same item a moment ago.
- expect((await reread())?.items?.[0]?.done).toBe(false);
+ // The rows are what is left to do, so the first one is not the item the editor ticked.
+ await card.$(testid('note-card-item')).click();
+
+ const done = await eventually(
+ async () => ((await reread())?.items ?? []).map((item) => item.done),
+ (state) => state[1] === true,
+ 'the row the card drew to come back ticked',
+ );
+ expect(done).toEqual([true, true, false]);
});
it('offers a drag handle that says what it moves', async () => {
@@ -99,7 +103,7 @@ describe('Todo lists', () => {
it('carries the Markdown Rust rendered, not one the front end rebuilt', async () => {
await canvas.openNote(title);
- await editor.toggleItem(0);
+ await editor.toggleItem(1);
await editor.close();
await browser.pause(400);
@@ -179,7 +183,83 @@ describe('Todo lists', () => {
expect(progress).toContain('2');
});
- /** A card shows two items of a list; a search slides the window to the matching one. */
+ /**
+ * A card has two rows to say what a list is about, and what it is about is what is left.
+ * ⚠️ The progress bar already says how much is done, so a ticked row costs a seat and
+ * pays nothing back.
+ */
+ describe('a list with some of its items already done', () => {
+ const partly = 'Partly done';
+ const finished = 'Nothing left';
+ const seeded: string[] = [];
+
+ async function rows(of: string): Promise {
+ const card = await canvas.cardWithTitle(of);
+ return card.$$(`${testid('note-card-item')} .item-text`).map((item) => item.getText());
+ }
+
+ before(async () => {
+ const spaceId = await homeSpaceId();
+ const first = await bridge.createNote(
+ draft({
+ spaceId,
+ title: partly,
+ kind: 'checklist',
+ items: [
+ { text: 'unpack the crate', done: true },
+ { text: 'wire the relay', done: false },
+ { text: 'seal the panel', done: false },
+ { text: 'call it a day', done: false },
+ ],
+ }),
+ );
+ const second = await bridge.createNote(
+ draft({
+ spaceId,
+ title: finished,
+ kind: 'checklist',
+ items: ['drain it', 'flush it', 'refill it'].map((text) => ({ text, done: true })),
+ }),
+ );
+ seeded.push(first.id, second.id);
+ await reloadCanvas();
+ await canvas.waitForCard(partly);
+ });
+
+ // ⚠️ The canvas is shared with every spec file that runs after this one.
+ after(async () => {
+ await bridge.deleteNotes(seeded);
+ await reloadCanvas();
+ });
+
+ it('spends its two rows on what is still to do', async () => {
+ expect(await rows(partly)).toEqual(['wire the relay', 'seal the panel']);
+ });
+
+ it('counts the ones it left out, ticked or not', async () => {
+ const card = await canvas.cardWithTitle(partly);
+
+ expect(await card.$(testid('note-card-more')).getText()).toContain('2');
+ });
+
+ it('gives the row up as soon as it is ticked', async () => {
+ const boxes = await (await canvas.cardWithTitle(partly)).$$(testid('note-card-item')).getElements();
+ await boxes[0]!.click();
+
+ const left = await eventually(
+ () => rows(partly),
+ (texts) => !texts.includes('wire the relay'),
+ 'the ticked row to leave the preview',
+ );
+ expect(left).toEqual(['seal the panel', 'call it a day']);
+ });
+
+ it('shows its last items rather than nothing when everything is done', async () => {
+ expect(await rows(finished)).toEqual(['flush it', 'refill it']);
+ });
+ });
+
+ /** A card shows two items of a list; a search keeps the matching one among them. */
describe('found by an item the card does not show', () => {
const long = 'Deep list';
@@ -204,18 +284,18 @@ describe('Todo lists', () => {
await canvas.clearSearch();
});
- it('slides its window to the item that matched', async () => {
+ it('keeps the item that matched among the two it shows', async () => {
await canvas.search('kubeconfig');
const card = await canvas.cardWithTitle(long);
const texts = await card.$$('[data-testid="note-card-item"]').map((item) => item.getText());
expect(texts.join(' ')).toContain('rotate the kubeconfig');
- expect(texts.join(' ')).not.toContain('first step');
+ expect(texts.join(' ')).not.toContain('third step');
});
/**
- * ⚠️ The template counts within the window, the position in the note is what gets
- * written: ticking the first visible box must not tick the first box of the list.
+ * ⚠️ A row is not at the place it holds in the note: ticking the second visible box
+ * must not tick the second box of the list.
*/
it('ticks the box it shows, not the one at the same place in the list', async () => {
await canvas.search('kubeconfig');
diff --git a/src/app/notes/canvas/note-card/note-card.component.html b/src/app/notes/canvas/note-card/note-card.component.html
index 2843b12..6727321 100644
--- a/src/app/notes/canvas/note-card/note-card.component.html
+++ b/src/app/notes/canvas/note-card/note-card.component.html
@@ -82,7 +82,7 @@
- @for (item of visibleItems(); track $index) {
+ @for (item of visibleItems(); track item.at) {
{{ item.done ? '☑' : '☐' }}
{{ item.text }}
diff --git a/src/app/notes/canvas/note-card/note-card.component.spec.ts b/src/app/notes/canvas/note-card/note-card.component.spec.ts
index 52a5121..509724c 100644
--- a/src/app/notes/canvas/note-card/note-card.component.spec.ts
+++ b/src/app/notes/canvas/note-card/note-card.component.spec.ts
@@ -193,7 +193,7 @@ describe('NoteCardComponent', () => {
.map((node) => node.nativeElement.textContent.trim());
}
- it('slides its window to the item that matched', async () => {
+ it('keeps the item that matched, and spends the other row on what is left', async () => {
fixture.componentRef.setInput(
'note',
createNote({
@@ -204,17 +204,17 @@ describe('NoteCardComponent', () => {
);
await fixture.whenStable();
- expect(itemTexts()).toEqual(['Dry run of the release workflow', 'Tag pushed']);
+ expect(itemTexts()).toEqual(['Changelog written', 'Dry run of the release workflow']);
});
- it('leaves the window at the head when the match is already in it', async () => {
+ it('keeps a matched item that is already ticked, which nothing else would show', async () => {
fixture.componentRef.setInput(
'note',
createNote({ kind: 'checklist', items, searchHit: { field: 'item', excerpt: 'Version bumped' } }),
);
await fixture.whenStable();
- expect(itemTexts()).toEqual(['Version bumped', 'Lockfiles agree']);
+ expect(itemTexts()).toEqual(['Version bumped', 'Changelog written']);
});
/** ⚠️ The excerpt is clipped at 160 characters and never equals its own text. */
@@ -230,13 +230,10 @@ describe('NoteCardComponent', () => {
);
await fixture.whenStable();
- expect(itemTexts()).toEqual(['Tag pushed', long]);
+ expect(itemTexts()).toEqual(['Changelog written', long]);
});
- /**
- * ⚠️ The template counts within the window; the position in the note is what gets
- * written. Without the offset a card silently edits the wrong line.
- */
+ /** ⚠️ A row is not at the place it holds in the note; it is what it carries that gets written. */
it('ticks the item it shows, not the one at the same place in the list', async () => {
const store = TestBed.inject(NotesStore);
const setChecklist = vi.spyOn(store, 'setChecklist').mockResolvedValue(undefined);
@@ -255,7 +252,7 @@ describe('NoteCardComponent', () => {
await fixture.whenStable();
const written = setChecklist.mock.calls[0][1];
- expect(written.map((item) => item.done)).toEqual([true, true, false, true, false]);
+ expect(written.map((item) => item.done)).toEqual([true, true, true, false, false]);
});
});
@@ -541,6 +538,66 @@ describe('NoteCardComponent', () => {
expect(text('[data-testid="note-card-more"]')).toBe('+3 autres');
});
+ describe('having more items than it has rows', () => {
+ function itemTexts(): string[] {
+ return [...fixture.nativeElement.querySelectorAll('.card-item .item-text')].map((node) =>
+ (node as HTMLElement).textContent?.trim(),
+ );
+ }
+
+ it('spends its two rows on what is still to do', async () => {
+ fixture.componentRef.setInput(
+ 'note',
+ checklist([
+ { text: 'Relire', done: true },
+ { text: 'Deployer', done: false },
+ { text: 'Annoncer', done: false },
+ ]),
+ );
+ await fixture.whenStable();
+
+ expect(itemTexts()).toEqual(['Deployer', 'Annoncer']);
+ });
+
+ it('falls back on the last done ones rather than drawing nothing', async () => {
+ fixture.componentRef.setInput(
+ 'note',
+ checklist(['Relire', 'Deployer', 'Annoncer', 'Archiver'].map((text) => ({ text, done: true }))),
+ );
+ await fixture.whenStable();
+
+ expect(itemTexts()).toEqual(['Annoncer', 'Archiver']);
+ });
+
+ it('counts every item it left out, wherever they sat', async () => {
+ fixture.componentRef.setInput(
+ 'note',
+ checklist([1, 2, 3, 4, 5].map((n) => ({ text: 't' + n, done: n === 1 }))),
+ );
+ await fixture.whenStable();
+
+ expect(itemTexts()).toEqual(['t2', 't3']);
+ expect(text('[data-testid="note-card-more"]')).toBe('+3 autres');
+ });
+
+ it('ticks the item it drew, not the one at the same place in the list', async () => {
+ const setChecklist = vi.spyOn(TestBed.inject(NotesStore), 'setChecklist').mockResolvedValue();
+ fixture.componentRef.setInput(
+ 'note',
+ checklist([
+ { text: 'Relire', done: true },
+ { text: 'Deployer', done: false },
+ { text: 'Annoncer', done: false },
+ ]),
+ );
+ await fixture.whenStable();
+
+ fixture.nativeElement.querySelectorAll('.card-item')[0].click();
+
+ expect(setChecklist.mock.calls[0][1].map((item) => item.done)).toEqual([true, true, false]);
+ });
+ });
+
it('shows no language badge, a checklist having no format to announce', () => {
expect(fixture.debugElement.query(By.directive(LanguageBadgeComponent))).toBeNull();
});
diff --git a/src/app/notes/canvas/note-card/note-card.component.ts b/src/app/notes/canvas/note-card/note-card.component.ts
index 71456aa..fd9ba02 100644
--- a/src/app/notes/canvas/note-card/note-card.component.ts
+++ b/src/app/notes/canvas/note-card/note-card.component.ts
@@ -129,24 +129,39 @@ export class NoteCardComponent {
protected readonly progress = computed(() => checklistProgress(this.note().items));
/**
* The excerpt cannot replace the layer: these are real checkboxes a card can be
- * ticked from. The window slides to the matching item instead.
+ * ticked from, so the card shows the matching item rather than quoting it.
*/
- private readonly itemWindowStart = computed(() => {
+ private readonly matchedItem = computed(() => {
const hit = this.searchHit();
- if (hit?.field !== 'item') return 0;
+ if (hit?.field !== 'item') return -1;
- const items = this.note().items;
- const texts = items.map((item) => item.text);
- return this.windowStart(items.length, this.indexOfHit(texts, hit.excerpt), MAX_VISIBLE_ITEMS);
+ const texts = this.note().items.map((item) => item.text);
+ return this.indexOfHit(texts, hit.excerpt);
});
+ /**
+ * Two rows, spent on what is left to do. ⚠️ A matched item keeps its seat whatever its
+ * state — it is why the card is on screen — and a list with nothing left falls back on
+ * its *last* items, the top of a finished list saying the least about where it ended.
+ * Each row carries the position it holds in the note, which is what gets ticked.
+ */
protected readonly visibleItems = computed(() => {
- const from = this.itemWindowStart();
- return this.note().items.slice(from, from + MAX_VISIBLE_ITEMS);
+ const items = this.note().items;
+ const seats = new Set();
+ const matched = this.matchedItem();
+ if (matched >= 0) seats.add(matched);
+
+ for (const [at, item] of items.entries()) {
+ if (seats.size >= MAX_VISIBLE_ITEMS) break;
+ if (!item.done) seats.add(at);
+ }
+ for (let at = items.length - 1; at >= 0 && seats.size < MAX_VISIBLE_ITEMS; at--) {
+ seats.add(at);
+ }
+
+ return items.map((item, at) => ({ ...item, at })).filter((row) => seats.has(row.at));
});
- protected readonly hiddenItemCount = computed(() =>
- Math.max(0, this.note().items.length - MAX_VISIBLE_ITEMS),
- );
+ protected readonly hiddenItemCount = computed(() => this.note().items.length - this.visibleItems().length);
protected readonly copyText = computed(() => noteCopyText(this.note()));
@@ -180,17 +195,12 @@ export class NoteCardComponent {
this.selection.toggleChecked(this.note().id);
}
- /**
- * ⚠️ The template counts within the window; the position in the note is what gets
- * written. A search slides the window, so the two are not the same number.
- */
- protected onItemToggle(event: MouseEvent, indexInWindow: number): void {
+ protected onItemToggle(event: MouseEvent, at: number): void {
event.stopPropagation();
- const index = this.itemWindowStart() + indexInWindow;
void this.notes.setChecklist(
this.note().id,
- this.note().items.map((item, at) => (at === index ? { ...item, done: !item.done } : { ...item })),
+ this.note().items.map((item, index) => (index === at ? { ...item, done: !item.done } : { ...item })),
);
}