feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes
- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais uma consulta, e as respostas podem chegar fora de ordem - pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador vazio ou negativo, nem dizer "76 a 100 de 82" - editar utilizador: papel, quota, idioma, com os apelidos que entregam naquela caixa a mostra no mesmo painel - "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem altera definicoes -- uma pagina de diagnostico que mexe naquilo que diagnostica e pior do que nao existir. As regras de cada verificacao sao funcoes puras com testes, separadas da recolha dos factos. Alguns casos que estes testes fixam: uma quota zero significa sem limite, e um racio marcaria todas as caixas ilimitadas como cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar ninguem investigar; e uma resposta de ausencia ligada com o periodo ja passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
52bf43b74f
commit
2b92abd185
16 files changed
+1431
-153
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pageWindow, pageRange } from "./paging";
|
||||
|
||||
describe("pageWindow", () => {
|
||||
it("shows every page when there are few", () => {
|
||||
expect(pageWindow(1, 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("follows the current page without running past the ends", () => {
|
||||
expect(pageWindow(10, 20)).toEqual([8, 9, 10, 11, 12]);
|
||||
expect(pageWindow(1, 20)).toEqual([1, 2, 3]);
|
||||
expect(pageWindow(20, 20)).toEqual([18, 19, 20]);
|
||||
});
|
||||
|
||||
// A page number out of range comes back from a stale URL or a list that
|
||||
// shrank under the reader; it must not produce an empty or negative pager.
|
||||
it.each([
|
||||
[0, 5],
|
||||
[-3, 5],
|
||||
[99, 5],
|
||||
[Number.NaN, 5],
|
||||
])("stays inside the range for page=%s of %s", (page, total) => {
|
||||
const w = pageWindow(page, total);
|
||||
expect(w.length).toBeGreaterThan(0);
|
||||
for (const p of w) {
|
||||
expect(p).toBeGreaterThanOrEqual(1);
|
||||
expect(p).toBeLessThanOrEqual(total);
|
||||
}
|
||||
});
|
||||
|
||||
it("survives having no pages at all", () => {
|
||||
expect(pageWindow(1, 0)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageRange", () => {
|
||||
it("describes the rows on this page", () => {
|
||||
expect(pageRange(1, 25, 100)).toEqual({ from: 1, to: 25 });
|
||||
expect(pageRange(4, 25, 100)).toEqual({ from: 76, to: 100 });
|
||||
});
|
||||
|
||||
// The last page is usually short; saying "76 to 100 of 82" would be wrong.
|
||||
it("stops at the total on a partial last page", () => {
|
||||
expect(pageRange(4, 25, 82)).toEqual({ from: 76, to: 82 });
|
||||
});
|
||||
|
||||
it("says nothing when there is nothing", () => {
|
||||
expect(pageRange(1, 25, 0)).toEqual({ from: 0, to: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The page numbers a pager should offer.
|
||||
*
|
||||
* Rendering every page is fine for three and unusable for four hundred, so the
|
||||
* window follows the current page and the ends stay reachable.
|
||||
*/
|
||||
export function pageWindow(page: number, totalPages: number, span = 2): number[] {
|
||||
if (!Number.isFinite(totalPages) || totalPages < 1) return [1];
|
||||
const current = Math.min(Math.max(Math.trunc(page) || 1, 1), totalPages);
|
||||
|
||||
const first = Math.max(1, current - span);
|
||||
const last = Math.min(totalPages, current + span);
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let p = first; p <= last; p++) pages.push(p);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** The human sentence under a list: which rows these are, out of how many. */
|
||||
export function pageRange(page: number, pageSize: number, total: number): { from: number; to: number } {
|
||||
if (total <= 0) return { from: 0, to: 0 };
|
||||
const from = (page - 1) * pageSize + 1;
|
||||
return { from, to: Math.min(total, page * pageSize) };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Holds a value still until typing stops.
|
||||
*
|
||||
* Every keystroke in a search box would otherwise be one more query against
|
||||
* the database, and the answers can arrive out of order - so the list flickers
|
||||
* between results for "an", "ana" and "a".
|
||||
*/
|
||||
export function useDebounced<T>(value: T, delayMs = 300): T {
|
||||
const [settled, setSettled] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return settled;
|
||||
}
|
||||
Reference in new issue
Block a user