diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index a824b1f..6fcf33a 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -149,8 +149,9 @@ pub async fn group( "SELECT d.id, d.slug, d.title, d.summary, (SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force, + -- Articles in force; open proposals of articles and modules alike (ADR 0009). (SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id - WHERE u.doc_id = d.id AND u.kind = 'article' AND p.status = 'open') AS open + WHERE u.doc_id = d.id AND p.status = 'open') AS open FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at", ) .bind(&slug) diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index 2a99fb8..de0f240 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -183,7 +183,7 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { let id = doc["doc"]["id"].as_str().expect("id"); let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; assert_eq!(all["units"].as_array().expect("units").len(), 33); - // The list of the group counts articles only. + // The list of the group: the articles in force; no module has an open proposal yet. let g = c.ok("GET", "/api/groups/monnaie-libre", None).await; assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32))); } @@ -250,6 +250,17 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { .expect("id") .to_owned(); yvv.vote(&p, "for").await; + // The list of the group counts it among the proposals in progress, never among the articles. + let g = yvv.ok("GET", "/api/groups/essai", None).await; + let before = doc["units"] + .as_array() + .expect("u") + .iter() + .map(|u| u["proposals"].as_array().expect("p").iter().filter(|x| x["status"] == "open").count() as u64); + assert_eq!( + (g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), + (Some(3), Some(before.sum::() + 1)) + ); // A new article enters the section: at its end, and at the end of the order under vote. let created = chiara .ok( diff --git a/web/e2e/entree.spec.ts b/web/e2e/entree.spec.ts index 73a8854..99376cb 100644 --- a/web/e2e/entree.spec.ts +++ b/web/e2e/entree.spec.ts @@ -142,8 +142,10 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to const rows = page.locator('ul.lines > li'); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText("Acte d'engagement Certification"); - await expect(rows.first()).toContainText('33 modules à voter'); - await expect(rows.first()).toContainText('dont 33 sans ton vote'); + // 33 articles, the title of the document and the order of 7 sections (ADR 0009; the + // section of a single article has no order to decide). + await expect(rows.first()).toContainText('41 modules à voter'); + await expect(rows.first()).toContainText('dont 41 sans ton vote'); // A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre » // counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie // libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's @@ -163,8 +165,8 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to .poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 }) .toBe('confirmed'); await page.reload(); - await expect(rows.first()).toContainText('33 modules à voter'); - await expect(rows.first()).toContainText('dont 32 sans ton vote'); + await expect(rows.first()).toContainText('41 modules à voter'); + await expect(rows.first()).toContainText('dont 40 sans ton vote'); await rows.first().getByRole('link').click(); await expect(page).toHaveURL(path(DOC)); }); diff --git a/web/e2e/modules.spec.ts b/web/e2e/modules.spec.ts new file mode 100644 index 0000000..07233c4 --- /dev/null +++ b/web/e2e/modules.spec.ts @@ -0,0 +1,225 @@ +import { devices, expect, test, type Page } from '@playwright/test'; +import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers'; + +// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the +// order of its articles (inertia 2), proposed with a list one clicks; at the head of +// the document, its title (inertia 4). Both voted like an article: unfolded, in the +// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs). +// The server is shared with the specs that follow: the test group keeps its articles +// and their votes; its conclusion is reordered and its title changes. + +const K1 = 'Règles abrégées de la toile de confiance'; +const K2 = 'Monnaie G1'; + +/** A vote unfolded in the list of proposals: « Pour », then the news. */ +async function voteFor(page: Page, row: string) { + await page.locator('ul.deep button.row', { hasText: row }).click(); + await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); +} + +test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + await page.goto(TEST_DOC); + // Each section ends with its order; a section of one article has nothing to decide. + const modules = page.locator('section.text [role="group"]'); + await expect(modules).toHaveCount(7); + await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0); + const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' }); + await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K1}`, `2 ${K2}`]); + await expect(order).toContainText('2 articles · inertie 2 / 5 · basse'); + await expect(order.getByText('seed a minima · 0 vote')).toBeVisible(); + await order.scrollIntoViewIfNeeded(); + await shot(page, 'c25-1-ordre-lecture'); + + // Its vote unfolds under it, like an article's; the mark tells the real number of votes. + const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' }); + await fond.hover(); + await fond.getByRole('button', { name: 'Le vote' }).click(); + const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }); + await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible(); + await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']); + await fondVote.getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible(); + await fondVote.getByRole('button', { name: 'Retirer mon vote' }).click(); + await expect(fond.getByText('seed a minima · 0 vote')).toBeVisible(); + await fond.getByRole('button', { name: 'Le vote' }).click(); + + // Another order: the article, then its new place. + await order.hover(); + await order.getByRole('button', { name: 'Proposer un autre ordre' }).click(); + await expect(page).toHaveURL(/onglet=proposer/); + const panel = page.locator('aside.panel'); + const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' }); + const items = list.locator('button.item'); + const names = list.locator('button.item .name'); + await expect(names).toHaveText([K1, K2]); + const submit = panel.getByRole('button', { name: 'Proposer cet ordre' }); + await expect(submit).toBeDisabled(); + await items.nth(1).click(); + await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true'); + await items.nth(0).click(); + await expect(names).toHaveText([K2, K1]); + await expect(panel.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached(); + await expect(submit).toBeEnabled(); + // With the keyboard: the moved article keeps the focus, the arrows move it. + await expect(items.nth(0)).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(names).toHaveText([K1, K2]); + await expect(submit).toBeDisabled(); + await page.keyboard.press('ArrowUp'); + await expect(names).toHaveText([K2, K1]); + // Or its arrows. + await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click(); + await panel.getByRole('button', { name: `Monter « ${K2} »` }).click(); + await expect(names).toHaveText([K2, K1]); + await shot(page, 'c25-2-ordre-propose'); + await submit.click(); + await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); + + // Its vote unfolds in the list of proposals, the list it proposes in sight. + const fold = page.locator('ul.deep section.fold'); + await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »'); + await expect(fold.locator('ol.order li')).toHaveText([K2, K1]); + await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible(); + await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`); + await fold.getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(page.getByText('Vote enregistré.')).toBeVisible(); + await shot(page, 'c25-3-ordre-vote'); + + // In the basket too: at the end of its section. + await page.getByRole('button', { name: /^Voter en lot/ }).click(); + const basket = panel.getByRole('region', { name: 'Conclusion' }); + await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion'); + await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); + + // A second voice brings it into force: the section reads in its new order. + const yvv = await person(browser, 'Yvv'); + await yvv.goto(TEST_DOC); + await voteFor(yvv, 'Ordre des articles · Conclusion'); + await expect(yvv.getByText('Le nouvel ordre de « Conclusion » est entré en vigueur.')).toBeVisible(); + await page.reload(); + await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]); + // In force now: its vote stays unfolded, under the module, the list whole. + await expect(order.locator('section.fold')).toContainText('2 pour · 0 contre'); + await order.scrollIntoViewIfNeeded(); + await shot(page, 'c25-3b-ordre-en-vigueur'); + await expect(order).toContainText(/adopté le/); + const rows = await page.locator('ul.deep button.row .title').allTextContents(); + expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1)); +}); + +test('C26 — le titre du document : proposé et voté en tête, ses traductions à revoir', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + await page.goto(TEST_DOC); + const head = page.locator('header.head'); + const bar = head.locator('.title-unit'); + await expect(bar).toContainText('Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote'); + await head.hover(); + await bar.getByRole('button', { name: 'Proposer un autre titre' }).click(); + await expect(page).toHaveURL(/onglet=proposer/); + const panel = page.locator('aside.panel'); + const input = panel.getByRole('textbox', { name: 'Nouveau titre' }); + await expect(input).toHaveValue("Acte d'engagement Certification"); + const submit = panel.getByRole('button', { name: 'Proposer un autre titre' }); + await expect(submit).toBeDisabled(); + await expect(panel.getByText("Le titre du document se vote à l'inertie 4 / 5 · haute.", { exact: false })).toBeVisible(); + const next = "Acte d'engagement de la certification"; + await input.fill(next); + await shot(page, 'c26-1-titre-propose'); + await submit.click(); + await expect(page.getByText('Titre proposé au vote.')).toBeVisible(); + const fold = page.locator('ul.deep section.fold'); + await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`); + await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document'); + await fold.getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(page.getByText('Vote enregistré.')).toBeVisible(); + + const yvv = await person(browser, 'Yvv'); + await yvv.goto(TEST_DOC); + await voteFor(yvv, next); + await expect(yvv.getByText(`Le titre « ${next} » est entré en vigueur.`)).toBeVisible(); + + // The document changes title; its translations stay editorial, to review. + await page.reload(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(next); + await expect(bar).toContainText(/adopté le/); + await expect(bar.locator('.tag')).toHaveCount(0); + // In force now: its vote stays unfolded, under the title. + const vote = bar.locator('section.fold'); + await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true'); + await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible(); + await expect(vote.getByText('2 pour · 0 contre')).toBeVisible(); + await shot(page, 'c26-2-titre-vote'); + await bar.getByRole('button', { name: 'Le vote' }).click(); + await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act'); + await expect(bar.locator('.tag')).toHaveText('translation needs review'); + await shot(page, 'c26-3-titre-traduction-a-revoir'); +}); + +test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que le groupe demande', async ({ page }) => { + await signIn(page, 'Chiara'); + // A group of four whose median is 2,5 voters, brought back to half of it by the scale. + await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { + const res = await route.fetch(); + const body = await res.json(); + body.doc.electorate = 4; + body.doc.participation = { ...body.doc.participation, source: 'median', median: 2.5, sample: 12, bounded: true, voters: 2, ratio: 0.5, bounds: [0.05, 0.5] }; + await route.fulfill({ response: res, json: body }); + }); + await page.goto(TEST_DOC); + await page.getByRole('button', { name: 'Réglages de la formule' }).click(); + const panel = page.locator('aside.panel'); + // The measure as it is: 2,5 of 4 is 62,5 %, never 3 of 4 rounded first. + await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible(); + // Then what the group asks, the group as subject. + await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible(); + await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude.")).toBeVisible(); + await shot(page, 'c27-participation-bornee'); +}); + +test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, la liste tient', async ({ browser }) => { + // The chain is waited for: the server reads the indexer every 10 seconds. + test.setTimeout(90_000); + const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' }); + const page = await ctx.newPage(); + await signIn(page, 'Chiara'); + await wallet(page, [ALICE]); + await page.goto(DOC); + // The basket of Monnaie libre: the order of the fundamental commitments, alone, in one transaction. + await page.getByRole('button', { name: /^Voter en lot/ }).click(); + const panel = page.locator('aside.panel'); + await panel.getByRole('group', { name: 'Ton choix pour « Ordre des articles · Engagements fondamentaux »' }).getByRole('button', { name: 'Pour' }).click(); + await panel.getByRole('button', { name: 'Signer et envoyer (1)' }).click(); + const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); + await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 }); + await dialog.getByRole('button', { name: 'Fermer' }).click(); + await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); + const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' }); + await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible(); + await fond.getByRole('button', { name: 'Le vote' }).click(); + await expect(page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }).locator('.voted')).toHaveText( + 'Ton vote : pour · inscrit sur la chaîne Ğ1', + ); + await fond.scrollIntoViewIfNeeded(); + await shot(page, 'c25-4-ordre-mobile'); + // The title on a phone: its gestures in sight, without a pointer to hover with. + await page.goto(TEST_DOC); + await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1'); + await shot(page, 'c26-4-titre-mobile'); + // The list one clicks, on a phone: nothing overflows, the buttons are big enough to touch. + const tech = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' }); + await tech.getByRole('button', { name: 'Proposer un autre ordre' }).click(); + const items = panel.getByRole('list', { name: 'Nouvel ordre des articles' }).locator('button.item'); + await expect(items).toHaveCount(11); + await items.nth(3).click(); + await items.nth(0).click(); + await expect(panel.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled(); + const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); + expect(holds).toBe(true); + const box = await items.nth(0).boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + await shot(page, 'c25-5-ordre-propose-mobile'); + await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded(); + await ctx.close(); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index b105d87..d958118 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -43,7 +43,9 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être"); await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte'); await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité'); - await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3); + // The seed mark, with the real number of votes beside it (none yet on these three). + await expect(units.getByText('seed a minima · 0 vote')).toHaveCount(3); + await expect(units.getByTitle("Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.")).toHaveCount(3); await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); // One line per article: E3 and E7 hold their two variants inside. await expect(page.locator('ul.deep button.row')).toHaveCount(30); @@ -287,7 +289,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { // At 100 voters: C plus each level's share of the 50 votes beyond C. const required = panel.locator('table tbody td.required'); await expect(required).toHaveText(['80', '84', '88', '92', '95']); - await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible(); + await expect(panel.getByText("Voix pour : à 100 votants, comme d'habitude.")).toBeVisible(); + await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).toBeVisible(); await page.keyboard.press('Escape'); // From the vote of an article: Unicité du compte is at 4 / 5, its row is marked. @@ -300,7 +303,7 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await expect(panel.locator('tr.current th')).toContainText('4'); await expect(panel.locator('tr.current th')).toContainText('cet article'); await expect(required).toHaveText(['50', '50', '50', '50', '50']); - await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible(); + await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible(); await page.keyboard.press('Escape'); // The five levels to choose from, in the test group; the change is voted at the article's level. @@ -311,9 +314,26 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await e2.getByText("Proposer un changement d'inertie").click(); const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio'); await expect(levels).toHaveCount(5); - await expect(levels.nth(3)).toBeDisabled(); + // The current level is reachable — one stop in the tab order — and never chosen. + await expect(levels.nth(3)).toHaveAttribute('aria-disabled', 'true'); await expect(levels.nth(3)).toContainText('actuelle'); + await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); + await expect(levels.nth(3)).toHaveAttribute('tabindex', '0'); await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible(); + await levels.nth(3).click(); + await expect(e2.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0); + // With the keyboard: the arrows go from level to level and choose it, wrapping round. + await levels.nth(3).focus(); + await page.keyboard.press('ArrowRight'); + await expect(levels.nth(4)).toBeFocused(); + await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); + await page.keyboard.press('ArrowRight'); + await expect(levels.nth(0)).toBeFocused(); + await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true'); + await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); + await page.keyboard.press('ArrowLeft'); + await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); + // Or with a click. await levels.nth(4).click(); await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); await e2.getByRole('button', { name: 'Soumettre au vote' }).click(); diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts index cb37a23..42afaf3 100644 --- a/web/src/lib/basket.ts +++ b/web/src/lib/basket.ts @@ -5,7 +5,7 @@ // chooses one's version. Then one gesture sends it all: one write on the // server, or one transaction on the Ğ1 chain. -import type { Choice, Proposal, Unit } from './types'; +import type { Choice, Modules, Proposal, Unit } from './types'; /** A choice in the basket: for, against, or withdrawing one's vote. Absent: nothing. */ export type Picks = Record; @@ -25,22 +25,40 @@ export interface Section { slots: Slot[]; } -/** The articles of the document with something to vote, by section, in document order. */ -export function sections(units: Unit[]): Section[] { +/** What a unit offers to vote: its version in force and open versions, its open changes of inertia. */ +function slotOf(unit: Unit): Slot | null { + const open = unit.proposals.filter((p) => p.status === 'open'); + const versions = [ + ...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []), + ...open.filter((p) => p.kind !== 'inertia'), + ]; + const inertia = open.filter((p) => p.kind === 'inertia'); + return versions.length || inertia.length ? { unit, versions, inertia } : null; +} + +/** + * The articles of the document with something to vote, by section, in document order — + * and its modules (ADR 0009): the title of the document first, the order of each section + * at the end of it; an order with nothing to decide (one article) is left out. + */ +export function sections(units: Unit[], modules: Modules = { title: null, orders: [] }): Section[] { const out: Section[] = []; for (const unit of units) { - const open = unit.proposals.filter((p) => p.status === 'open'); - const versions = [ - ...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []), - ...open.filter((p) => p.kind !== 'inertia'), - ]; - const inertia = open.filter((p) => p.kind === 'inertia'); - if (!versions.length && !inertia.length) continue; + const slot = slotOf(unit); + if (!slot) continue; const last = out.at(-1); - const slot = { unit, versions, inertia }; if (last && last.key === unit.section) last.slots.push(slot); else out.push({ key: unit.section, slots: [slot] }); } + for (const order of modules.orders) { + const slot = order.moot ? null : slotOf(order); + if (!slot) continue; + const home = out.find((s) => s.key === order.section); + if (home) home.slots.push(slot); + else out.push({ key: order.section, slots: [slot] }); + } + const title = modules.title && slotOf(modules.title); + if (title) out.unshift({ key: null, slots: [title] }); return out; } diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index ed10023..dc3ed06 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -6,8 +6,8 @@ import { onMount } from 'svelte'; import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket'; import type { DocState } from '$lib/doc.svelte'; - import { i18n, t, type Key } from '$lib/i18n/index.svelte'; - import { relationOf, titleOf } from '$lib/labels'; + import { i18n, t } from '$lib/i18n/index.svelte'; + import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels'; import type { BasketState } from '$lib/picks.svelte'; import { toasts } from '$lib/toast.svelte'; import type { Proposal } from '$lib/types'; @@ -30,12 +30,10 @@ onread: (proposalId: string) => void; } = $props(); - const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']; - const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null); - const view = $derived(doc.view); const access = $derived(view?.vote); - const all = $derived(sections(view?.units ?? [])); + // The modules too (ADR 0009): the title first, each order at the end of its section. + const all = $derived(sections(view?.units ?? [], view?.modules)); const slots = $derived(all.flatMap((s) => s.slots)); const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)))); const toSend = $derived(lines(basket.picks, known)); @@ -51,6 +49,7 @@ }); function headline(s: Slot): string { + if (s.unit.kind) return moduleName(s.unit); const text = s.versions.find((p) => p.kind === 'text'); return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code); } @@ -58,6 +57,9 @@ /** What tells a version from the others of its article. */ function labelOf(p: Proposal, s: Slot): string { if (p.status === 'in_force') return t('basket.inforce'); + // Another title reads as itself; another order, as a proposal (its list unfolds with its vote). + if (p.kind === 'title') return p.title; + if (p.kind === 'order') return p.author?.name ? t('panel.by', { name: p.author.name }) : t('module.order.proposal'); if (p.kind !== 'text') return titleOf(p, s.unit); if (p.label) return t('deep.variant', { label: p.label }); if (p.author?.name) return t('panel.by', { name: p.author.name }); diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index ac68677..4da2a2a 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -5,8 +5,8 @@ // marks — and each unfolds its own vote. import type { Snippet } from 'svelte'; import { plain } from '$lib/markdown'; - import { inertiaBadge, relationOf, titleOf } from '$lib/labels'; - import { i18n, t, type Key } from '$lib/i18n/index.svelte'; + import { inertiaBadge, moduleName, orderNote, relationOf, sectionLabel, titleOf } from '$lib/labels'; + import { i18n, t } from '$lib/i18n/index.svelte'; import type { Proposal, Unit } from '$lib/types'; import { excerpts, type Article } from '$lib/versions'; import Icon from './Icon.svelte'; @@ -18,7 +18,9 @@ ontoggle, fold, onsection, + nameOf = (id) => id, }: { + /** Articles and modules (ADR 0009) with open proposals, in document order. */ articles: Article[]; /** The version whose vote is unfolded. */ vote: string | null; @@ -26,10 +28,16 @@ fold: Snippet<[Unit, Proposal]>; /** Opens the basket on a section; absent when this session does not vote here. */ onsection?: (section: string) => void; + /** The title of an article, for the orders proposed. */ + nameOf?: (unitId: string) => string; } = $props(); - const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']; - const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null); + /** What a line shows of a version: the start of its text, or the order it proposes. */ + function excerptOf(p: Proposal): string { + if (p.kind === 'text') return plain(p.body); + if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · '); + return ''; + } // Unfolded by hand, or because the version whose vote is unfolded is inside. let unfolded = $state>({}); @@ -38,6 +46,7 @@ /** The title of the article: its best text version, or what the best proposal is. */ function headline(a: Article): string { + if (a.unit.kind) return moduleName(a.unit); const text = a.versions.find((p) => p.kind === 'text'); const best = a.versions[0]; return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code; @@ -45,7 +54,7 @@ /** What tells a version from its siblings: its variant, its author, or its rank of arrival. */ function labelOf(p: Proposal, a: Article): string { - if (p.kind !== 'text') return titleOf(p, a.unit); + if (p.kind === 'inertia' || p.kind === 'remove') return titleOf(p, a.unit); if (p.label) return t('deep.variant', { label: p.label }); if (p.author?.name) return t('panel.by', { name: p.author.name }); const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id); @@ -56,10 +65,18 @@ function toldApart(a: Article): Map { const textual = a.versions.filter((p) => p.kind === 'text'); const told = excerpts(textual.map((p) => plain(p.body))); - return new Map(textual.map((p, k) => [p.id, told[k] ?? plain(p.body)])); + return new Map([ + ...a.versions.map((p): [string, string] => [p.id, excerptOf(p)]), + ...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]), + ]); } +{#snippet module(p: Proposal)} + {#if p.kind === 'title'}{t('module.title.name')}{/if} + {#each orderNote(p) as note (note)}{note}{/each} +{/snippet} + {#snippet marks(p: Proposal)} {#if p.evaluation?.wellRated}{t('deep.wellrated')}{/if} {#if p.blocked}{t('deep.blocked')}{/if} @@ -86,11 +103,12 @@ {titleOf(best, unit)} - {#if best.kind === 'text'}{plain(best.body)}{/if} + {#if excerptOf(best)}{excerptOf(best)}{/if} {t('tally.short', { for: best.tally.for, against: best.tally.against })} {#if relationOf(best, unit)}{relationOf(best, unit)}{/if} {#if unit.inertia !== 3}{inertiaBadge(unit.inertia)}{/if} + {@render module(best)} {@render marks(best)} @@ -106,7 +124,7 @@ {title} - {#if best.kind === 'text'}{plain(best.body)}{/if} + {#if excerptOf(best)}{excerptOf(best)}{/if} {i18n.plural('deep.versions', a.versions.length)} {#if a.leads}{t('deep.lead', { label: labelOf(best, a) })}{/if} @@ -126,10 +144,11 @@ {/each} - + + {:else if panel.tab === 'propose' && unit?.kind === 'order'} + onvote(id, true)} oncancel={onclose} /> + {:else if panel.tab === 'propose' && unit?.kind === 'title'} + onvote(id, true)} /> {:else if panel.tab === 'propose'} x.kind === 'inertia' && x.status === 'open')); const level = (k: Inertia | null) => (k ? inertiaLevel(k) : '?'); + let group: HTMLElement | undefined = $state(); + + // A radiogroup with one stop in the tab order: the level chosen, else the current + // one — reachable, read as « actuelle », never chosen. The arrows go from level to + // level and choose the one they land on. + function choose(k: Inertia) { + if (k !== unit.inertia) target = k; + } + + function key(e: KeyboardEvent, k: Inertia) { + const at = INERTIA_LEVELS.indexOf(k); + const last = INERTIA_LEVELS.length - 1; + const to = + e.key === 'ArrowRight' || e.key === 'ArrowDown' ? (at === last ? 0 : at + 1) + : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? (at === 0 ? last : at - 1) + : e.key === 'Home' ? 0 + : e.key === 'End' ? last + : null; + if (to === null) return; + e.preventDefault(); + const next = INERTIA_LEVELS[to]; + if (next === undefined) return; + choose(next); + group?.querySelectorAll('[role="radio"]')[to]?.focus(); + } async function submit() { if (!target) return; @@ -46,16 +71,19 @@ {/each}
{t('inertia.change')} -
+
{#each INERTIA_LEVELS as k (k)} + + + + + + {/each} + +

{said}

+

{t('order.edit.note', { level: inertiaLevel(unit.inertia) })}

+

{t('module.order.rule')}

+ {#if error}{/if} +
+ {#if oncancel}{/if} + +
+
+ + diff --git a/web/src/lib/components/OrderModule.svelte b/web/src/lib/components/OrderModule.svelte new file mode 100644 index 0000000..ae7dffe --- /dev/null +++ b/web/src/lib/components/OrderModule.svelte @@ -0,0 +1,89 @@ + + + + {#snippet meta()} + {i18n.plural('module.order.count', members.length)} · {inertiaBadge(unit.inertia)} + {/snippet} + {#snippet content()} + +
    + {#each members as id, i (id)}
  1. {i + 1} {nameOf(id)}
  2. {/each} +
+ {/snippet} +
+ + diff --git a/web/src/lib/components/ProposeView.svelte b/web/src/lib/components/ProposeView.svelte index a90b1a4..2176853 100644 --- a/web/src/lib/components/ProposeView.svelte +++ b/web/src/lib/components/ProposeView.svelte @@ -3,6 +3,7 @@ import { api, ApiError } from '$lib/api'; import type { DocState } from '$lib/doc.svelte'; import { t, type Key } from '$lib/i18n/index.svelte'; + import { sectionLabel } from '$lib/labels'; import { toasts } from '$lib/toast.svelte'; type Mode = 'replace' | 'remove' | 'new'; @@ -28,7 +29,7 @@ const draft = $derived(doc.proposal(draftId)); let target = $state(null); let mode = $state('replace'); - let after = $state(''); + let section = $state(''); let title = $state(''); let body = $state(''); let hints = $state([]); @@ -37,7 +38,8 @@ let savedId = $state(null); const unit = $derived(doc.unit(target)); - const units = $derived(doc.view?.units ?? []); + // The sections of the document, in its order: a new article goes at the end of one (ADR 0009). + const sections = $derived([...new Set((doc.view?.units ?? []).map((u) => u.section).filter((s): s is string => !!s))]); // (Re)initialise only when the panel is pointed elsewhere — never on a reload, // which would wipe what the person is typing. @@ -49,7 +51,8 @@ target = u0; savedId = d0; mode = u0 ? 'replace' : 'new'; - after = doc.inForce.at(-1)?.id ?? ''; + // The section of the article one comes from, else the last one. + section = u?.section ?? doc.view?.units.at(-1)?.section ?? ''; title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? ''; body = pf?.body ?? d?.body ?? u?.inForce?.body ?? ''; error = ''; @@ -89,7 +92,7 @@ const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, { kind: mode === 'remove' ? 'remove' : 'text', unitId: mode === 'new' ? null : target, - afterUnitId: mode === 'new' ? after || null : null, + section: mode === 'new' ? section || null : null, title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title, body: mode === 'remove' ? '' : body, submit: false, @@ -162,14 +165,14 @@
{/if} - {#if mode === 'new'} + {#if mode === 'new' && sections.length} +

{t('propose.section.note')}

{/if} {#if mode === 'remove'} diff --git a/web/src/lib/components/TitleBar.svelte b/web/src/lib/components/TitleBar.svelte new file mode 100644 index 0000000..e49846a --- /dev/null +++ b/web/src/lib/components/TitleBar.svelte @@ -0,0 +1,114 @@ + + +
+

+ + {moduleName(unit)} · {inertiaBadge(unit.inertia)} + {#if p?.seedMinimal} · {i18n.plural('doc.seed.votes', votes)} + {:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} + + {#if stale}{t('doc.lang.stale')}{/if} + + + + + +

+ {#if voteOpen && fold}
{@render fold()}
{/if} +
+ + diff --git a/web/src/lib/components/TitlePropose.svelte b/web/src/lib/components/TitlePropose.svelte new file mode 100644 index 0000000..1bebc40 --- /dev/null +++ b/web/src/lib/components/TitlePropose.svelte @@ -0,0 +1,93 @@ + + +
+ +

{t('title.propose.note', { level: inertiaLevel(unit.inertia) })}

+ {#if error}{/if} +
+ +
+
+ + diff --git a/web/src/lib/components/UnitBlock.svelte b/web/src/lib/components/UnitBlock.svelte index ad55907..a459294 100644 --- a/web/src/lib/components/UnitBlock.svelte +++ b/web/src/lib/components/UnitBlock.svelte @@ -14,6 +14,11 @@ actions = true, voteFor = null, voteOpen = false, + votes = 0, + module = false, + proposeLabel, + meta, + content, fold, onaction, }: { @@ -27,34 +32,53 @@ /** The version « Le vote » unfolds, and whether it is unfolded. */ voteFor?: string | null; voteOpen?: boolean; + /** The votes on the version in force, shown next to the seed mark. */ + votes?: number; + /** A module (ADR 0009): quieter than an article, its own words to propose. */ + module?: boolean; + proposeLabel?: string; + /** What the header adds after the title (a count, a level). */ + meta?: Snippet; + /** In place of the text, for a module. */ + content?: Snippet; /** The vote, unfolded under the text. */ fold?: Snippet; onaction: (action: 'vote' | 'proposals' | 'propose') => void; } = $props(); -
+ +

{text.title}

+ {@render meta?.()} {#if mark !== 'unchanged'}{t(`doc.mark.${mark}`)}{/if} {#if text.stale}{t('doc.lang.stale')}{:else if text.missing}{t('doc.lang.missing')}{/if}
- + {#if content}{@render content()}{:else}{/if} {#if actions}
- {#if seedMinimal}{t('doc.seed')} · 0 vote + {#if seedMinimal}{i18n.plural('doc.seed.votes', votes)} {:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if} - - + +
{/if} {#if voteOpen && fold}{@render fold()}{/if} -
+