forked from yvv/decision
Lot U : « Autres versions » repliées dans le vote ; contrôles corrigés après le premier passage (état partagé, nom du titre en anglais, attente du chargement) ; le panier dit « simulé » ; CLAUDE.md (composants, panneau, 38 contrôles)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,8 @@ web/src/
|
|||||||
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||||
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
|
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
|
||||||
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList,
|
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList,
|
||||||
# OrderModule / OrderEditor (l'ordre d'une section, la liste où l'on clique),
|
# OrderLine / Reorder (l'ordre d'une section : une ligne en fin de section, les poignées
|
||||||
|
# sur ses articles, sur place), QuorumSetting (le quorum de l'auteur), Crans (l'inertie dessinée),
|
||||||
# TitleBar / TitlePropose (le titre du document), Seal…
|
# TitleBar / TitlePropose (le titre du document), Seal…
|
||||||
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
||||||
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules
|
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules
|
||||||
@@ -88,11 +89,12 @@ web/src/
|
|||||||
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
||||||
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
||||||
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
||||||
# vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages)
|
# vote déplié ?vote=, panneau sans onglets ?onglet=panier|proposer|details|reglages ;
|
||||||
|
# l'ancien onglet=propositions déplie le vote ; note : docs/chantiers/acte-certification/UX-lecteur.md)
|
||||||
# · g/[group]/decisions · g/[group]/mandats ·
|
# · g/[group]/decisions · g/[group]/mandats ·
|
||||||
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
||||||
# /groupes, /decisions… redirigent
|
# /groupes, /decisions… redirigent
|
||||||
web/e2e/ # Playwright C1–C34 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
web/e2e/ # Playwright C1–C37 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
||||||
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
@@ -114,7 +116,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
|||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 35 contrôles (36 tests, C25 en deux)
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 38 contrôles (40 tests, C25 et C35 en deux)
|
||||||
|
|
||||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.
|
|||||||
|
|
||||||
/** Unfolds the vote of an open proposal, from « Propositions en cours » (its sections folded: lot U). */
|
/** Unfolds the vote of an open proposal, from « Propositions en cours » (its sections folded: lot U). */
|
||||||
async function unfold(page: Page, title: string) {
|
async function unfold(page: Page, title: string) {
|
||||||
|
await page.locator('ul.deep li.section').first().waitFor();
|
||||||
const all = page.getByRole('button', { name: 'Tout déplier' });
|
const all = page.getByRole('button', { name: 'Tout déplier' });
|
||||||
if (await all.isVisible()) await all.click();
|
if (await all.isVisible()) await all.click();
|
||||||
// By its title only: the excerpt of another proposal may name it.
|
// By its title only: the excerpt of another proposal may name it.
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti
|
|||||||
expect((await toggle.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
expect((await toggle.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
const fold = page.locator('section.text section.fold');
|
const fold = page.locator('section.text section.fold');
|
||||||
await expect(fold.getByText('9 oui sur 12 votants pour passer', { exact: true })).toBeVisible();
|
// Level 2, the quorum 12: « sur 12 votants » before any vote; the desktop controls may have voted.
|
||||||
|
await expect(fold.locator('.need')).toHaveText(/^9 oui (sur 12 votants pour passer|pour passer · le quorum la bloque)$/);
|
||||||
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
|
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
|
||||||
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
|
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||||
const panel = page.locator('aside.panel');
|
const panel = page.locator('aside.panel');
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée
|
|||||||
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
||||||
await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]);
|
await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]);
|
||||||
await expect(order).toContainText('1 ordre proposé');
|
await expect(order).toContainText('1 ordre proposé');
|
||||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles' })).toHaveCount(0);
|
await expect(page.locator('ul.deep button.row .title', { hasText: /^Ordre des articles · / })).toHaveCount(0);
|
||||||
// The order in force is read from it, among its other versions.
|
// The order in force is read from it, among its other versions (folded until asked).
|
||||||
|
await fold.getByRole('button', { name: 'Autres versions (1)' }).click();
|
||||||
await expect(fold.getByRole('region', { name: 'Autres versions' })).toContainText('Ordre en vigueur');
|
await expect(fold.getByRole('region', { name: 'Autres versions' })).toContainText('Ordre en vigueur');
|
||||||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
@@ -132,7 +133,9 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
|||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
const head = page.locator('header.head');
|
const head = page.locator('header.head');
|
||||||
const bar = head.getByRole('group', { name: 'Titre du document' });
|
// A module, named for what it is (in French here; the language changes below).
|
||||||
|
await expect(head.getByRole('group', { name: 'Titre du document' })).toBeVisible();
|
||||||
|
const bar = head.locator('.title-unit');
|
||||||
await expect(bar).toContainText('Titre du document · seed a minima · 0 vote');
|
await expect(bar).toContainText('Titre du document · seed a minima · 0 vote');
|
||||||
await expect(bar).toContainText('inertie 4 / 5 · haute');
|
await expect(bar).toContainText('inertie 4 / 5 · haute');
|
||||||
await head.hover();
|
await head.hover();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss
|
|||||||
await expect(line.getByText(E1)).toHaveCount(0);
|
await expect(line.getByText(E1)).toHaveCount(0);
|
||||||
// Nowhere a block that repeats the articles of a section, and no order among the proposals.
|
// Nowhere a block that repeats the articles of a section, and no order among the proposals.
|
||||||
await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0);
|
await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0);
|
||||||
await expect(page.locator('ul.deep').getByText('Ordre des articles', { exact: false })).toHaveCount(0);
|
await expect(page.locator('ul.deep').getByText(/^Ordre des articles · /)).toHaveCount(0);
|
||||||
await line.scrollIntoViewIfNeeded();
|
await line.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c35-1-ligne-ordre');
|
await shot(page, 'c35-1-ligne-ordre');
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss
|
|||||||
}
|
}
|
||||||
expect(await moduleInForce(page, pid)).toBe(true);
|
expect(await moduleInForce(page, pid)).toBe(true);
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
|
await expect(page.locator('section.text article.unit', { hasText: E2 })).toBeVisible();
|
||||||
const titles = await page.locator('section.text article.unit h2').allTextContents();
|
const titles = await page.locator('section.text article.unit h2').allTextContents();
|
||||||
expect(titles.indexOf(E2)).toBeGreaterThan(-1);
|
expect(titles.indexOf(E2)).toBeGreaterThan(-1);
|
||||||
expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1));
|
expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1));
|
||||||
|
|||||||
@@ -295,8 +295,9 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
|
|||||||
// A version unfolds its vote below it, as any proposal.
|
// A version unfolds its vote below it, as any proposal.
|
||||||
await versions.nth(1).click();
|
await versions.nth(1).click();
|
||||||
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
|
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
|
||||||
// Level 3, the quorum 50 (ADR 0013): 39 « pour » at no vote, the voters missing counted as « non ».
|
// Level 3, the quorum 50 (ADR 0013): 39 « oui » pass it, the voters missing counted as « non »
|
||||||
await expect(page.locator('section.fold').getByText('39 oui sur 50 votants pour passer', { exact: true })).toBeVisible();
|
// (« sur 50 votants » before any vote; a « contre » of another control may be there already).
|
||||||
|
await expect(page.locator('section.fold .need')).toHaveText(/^39 oui (sur 50 votants pour passer|pour passer · le quorum la bloque)$/);
|
||||||
await expect(page).toHaveURL(/vote=/);
|
await expect(page).toHaveURL(/vote=/);
|
||||||
await e3.click();
|
await e3.click();
|
||||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil
|
|||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
|
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
|
||||||
// How far the threshold is: the water, the totals, the votes asked and those missing.
|
// How far the threshold is: the water, the totals, the votes asked and those missing.
|
||||||
await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible();
|
// (the first: its other versions, inside, have theirs)
|
||||||
|
await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ }).first()).toBeVisible();
|
||||||
await expect(fold.getByText(/^\d+ oui sur \d+ votants? pour passer$/)).toBeVisible();
|
await expect(fold.getByText(/^\d+ oui sur \d+ votants? pour passer$/)).toBeVisible();
|
||||||
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
|
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
|
||||||
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
|
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
|
||||||
@@ -196,8 +197,9 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
|||||||
const fold = page.locator('section.text section.fold');
|
const fold = page.locator('section.text section.fold');
|
||||||
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
|
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
|
||||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||||
// The totals and how far it is stay in sight: Raison d'être, level 2, the quorum 12 (ADR 0013).
|
// The totals and how far it is stay in sight: Raison d'être, level 2, the quorum 12 (ADR 0013) —
|
||||||
await expect(fold.getByText('9 oui sur 12 votants pour passer', { exact: true })).toBeVisible();
|
// « sur 12 votants » before any vote; C23 may have counted a voice already.
|
||||||
|
await expect(fold.locator('.need')).toHaveText(/^9 oui (sur 12 votants pour passer|pour passer · le quorum la bloque)$/);
|
||||||
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
||||||
await shot(page, '6-vote-ferme');
|
await shot(page, '6-vote-ferme');
|
||||||
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<p class="faint small note-send">
|
<p class="faint small note-send">
|
||||||
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else if access?.simulated}{t('vote.simulated.note')}{:else}{t('basket.note.server')}{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
|
/** « Autres versions », folded until asked: the vote stays short. */
|
||||||
|
let othersOpen = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let root: HTMLElement | undefined = $state();
|
let root: HTMLElement | undefined = $state();
|
||||||
@@ -249,8 +251,12 @@
|
|||||||
|
|
||||||
<div class="more">
|
<div class="more">
|
||||||
{#if others.length}
|
{#if others.length}
|
||||||
<section class="others" aria-labelledby="others-{p.id}">
|
<button type="button" class="disclose" aria-expanded={othersOpen} aria-controls="others-{p.id}" onclick={() => (othersOpen = !othersOpen)}>
|
||||||
<h4 id="others-{p.id}">{t('vote.others')}</h4>
|
<span class="tri" aria-hidden="true"></span>{t('vote.others.count', { n: others.length })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if others.length && othersOpen}
|
||||||
|
<section class="others" id="others-{p.id}" aria-label={t('vote.others')}>
|
||||||
<ul>
|
<ul>
|
||||||
{#each others as v (v.id)}
|
{#each others as v (v.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -331,10 +337,28 @@
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.others h4 {
|
.disclose {
|
||||||
margin: 0;
|
justify-self: start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--water);
|
||||||
|
font-weight: 750;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 800;
|
}
|
||||||
|
.tri {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-block: 0.3rem solid transparent;
|
||||||
|
border-left: 0.45rem solid currentColor;
|
||||||
|
transition: transform 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
.disclose[aria-expanded='true'] .tri {
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
.others ul {
|
.others ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -496,5 +520,8 @@
|
|||||||
.dot {
|
.dot {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.tri {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const en: Messages = {
|
|||||||
'vote.saved.simulated': 'Vote simulated.',
|
'vote.saved.simulated': 'Vote simulated.',
|
||||||
'vote.simulated.mark': 'simulated vote',
|
'vote.simulated.mark': 'simulated vote',
|
||||||
'vote.others': 'Other versions',
|
'vote.others': 'Other versions',
|
||||||
|
'vote.others.count': 'Other versions ({n})',
|
||||||
'vote.need': '{yes} yes to pass · {no} no to block',
|
'vote.need': '{yes} yes to pass · {no} no to block',
|
||||||
'vote.need.quorum': '{yes} yes to pass · the quorum blocks it',
|
'vote.need.quorum': '{yes} yes to pass · the quorum blocks it',
|
||||||
'vote.need.first.one': '{yes} yes out of {quorum} voter to pass',
|
'vote.need.first.one': '{yes} yes out of {quorum} voter to pass',
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const es: Messages = {
|
|||||||
'vote.saved.simulated': 'Voto simulado.',
|
'vote.saved.simulated': 'Voto simulado.',
|
||||||
'vote.simulated.mark': 'voto simulado',
|
'vote.simulated.mark': 'voto simulado',
|
||||||
'vote.others': 'Otras versiones',
|
'vote.others': 'Otras versiones',
|
||||||
|
'vote.others.count': 'Otras versiones ({n})',
|
||||||
'vote.need': '{yes} sí para aprobar · {no} no para bloquear',
|
'vote.need': '{yes} sí para aprobar · {no} no para bloquear',
|
||||||
'vote.need.quorum': '{yes} sí para aprobar · el quórum la bloquea',
|
'vote.need.quorum': '{yes} sí para aprobar · el quórum la bloquea',
|
||||||
'vote.need.first.one': '{yes} sí de {quorum} votante para aprobar',
|
'vote.need.first.one': '{yes} sí de {quorum} votante para aprobar',
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export const fr = {
|
|||||||
'vote.saved.simulated': 'Vote simulé.',
|
'vote.saved.simulated': 'Vote simulé.',
|
||||||
'vote.simulated.mark': 'vote simulé',
|
'vote.simulated.mark': 'vote simulé',
|
||||||
'vote.others': 'Autres versions',
|
'vote.others': 'Autres versions',
|
||||||
|
'vote.others.count': 'Autres versions ({n})',
|
||||||
'vote.need': '{yes} oui pour passer · {no} non pour bloquer',
|
'vote.need': '{yes} oui pour passer · {no} non pour bloquer',
|
||||||
'vote.need.quorum': '{yes} oui pour passer · le quorum la bloque',
|
'vote.need.quorum': '{yes} oui pour passer · le quorum la bloque',
|
||||||
'vote.need.first.one': '{yes} oui sur {quorum} votant pour passer',
|
'vote.need.first.one': '{yes} oui sur {quorum} votant pour passer',
|
||||||
|
|||||||
Reference in New Issue
Block a user