Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 92 additions & 12 deletions e2e/specs/07-checklists.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 `<div>` inside a `<button>` 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 () => {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<string[]> {
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';

Expand All @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions src/app/notes/canvas/note-card/note-card.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
<!-- In the flow now that the card is not a button: the items used to be a layer of
their own, absolutely placed, because a `<button>` cannot hold one. -->
<ul class="card-items">
@for (item of visibleItems(); track $index) {
@for (item of visibleItems(); track item.at) {
<li>
<button
type="button"
Expand All @@ -92,7 +92,7 @@
[class.done]="item.done"
[attr.aria-checked]="item.done"
[attr.aria-label]="'notes.checklistToggle' | transloco: { text: item.text }"
(click)="onItemToggle($event, $index)"
(click)="onItemToggle($event, item.at)"
>
<span class="item-box" aria-hidden="true">{{ item.done ? '☑' : '☐' }}</span>
<span class="item-text">{{ item.text }}</span>
Expand Down
77 changes: 67 additions & 10 deletions src/app/notes/canvas/note-card/note-card.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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. */
Expand All @@ -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);
Expand All @@ -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]);
});
});

Expand Down Expand Up @@ -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();
});
Expand Down
46 changes: 28 additions & 18 deletions src/app/notes/canvas/note-card/note-card.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
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()));

Expand Down Expand Up @@ -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 })),
);
}

Expand Down