Pular para o conteúdo

2. Tela, rota e migração

Na página anterior o módulo avisos ganhou coleção, campos e permissões. Aqui ele ganha quatro peças que o núcleo não gera sozinho: uma tela, uma rota pública, uma migração e um gancho. No fim, você o instala no projeto.

Cada passo é o arquivo src/modules/avisos/module.ts inteiro, partindo do último passo da página anterior.

A lista e o formulário de avisos são gerados. Uma tela sua serve para o que não é lista nem formulário: aqui, um resumo.

src/modules/avisos/module.ts
import { collection, defineModule, field, screen } from "../../panel/contract/index";
import { html } from "../../ui/index";
/** Who holds each permission of the module, by role: authors only read, and only admins delete. */
const permissions = {
read: ["admin", "editor", "autor"],
write: ["admin", "editor"],
remove: ["admin"],
} as const;
/** The kinds of notice, in the order the select offers them (the first one is the default). */
const TIPOS = [
{ value: "info", label: "Informação" },
{ value: "atencao", label: "Atenção" },
{ value: "urgente", label: "Urgente" },
];
/** Own screen at `/painel/avisos/resumo`: how many notices exist and how many are on. */
const resumo = screen({
title: "Resumo dos avisos",
permission: "read",
get: async ({ services }) => {
const todos = await services.store.list("avisos", { perPage: 1 });
const ligados = await services.store.list("avisos", {
where: [{ field: "ativo", op: "eq", value: true }],
perPage: 1,
});
return {
title: "Resumo dos avisos",
main: html`<p>${ligados.total} de ${todos.total} avisos estão ativos.</p>
<p><a href="/painel/c/avisos">Ver a lista de avisos</a></p>`,
};
},
});
/** Module with a screen of its own, next to the generated list. */
export const avisos = defineModule({
id: "avisos",
version: "1.0.0",
core: "^1.0.0",
title: "Avisos",
description: "Avisos curtos do site.",
permissions,
permissionLabels: {
read: "Ver avisos",
write: "Criar e editar avisos",
remove: "Excluir avisos",
},
menu: [
{ group: "organizacao", label: "Avisos", collection: "avisos", order: 30 },
{ group: "organizacao", label: "Resumo dos avisos", screen: "resumo", order: 31 },
],
screens: { resumo },
collections: {
avisos: collection({
label: "Avisos",
labelSingular: "Aviso",
fields: {
titulo: field.text({ label: "Título", required: true }),
texto: field.longText({ label: "Texto" }),
tipo: field.select({ label: "Tipo", options: TIPOS, default: "info", required: true }),
validade: field.date({ label: "Vale até" }),
ativo: field.boolean({ label: "Ativo", default: true }),
},
list: {
columns: ["titulo", "tipo", "validade", "ativo"],
filters: ["tipo", "ativo"],
search: ["titulo"],
sort: { field: "createdAt", direction: "desc" },
},
permissions: { read: "read", create: "write", update: "write", delete: "remove" },
}),
},
});
export { avisos as module };

Uma tela é um screen({ title, permission, get }), e post é opcional. O módulo as guarda em screens, pela chave (resumo). O painel monta cada tela duas vezes: em /painel/avisos/resumo e em /painel/avisos/resumo/:id, onde ctx.params.id traz o trecho final do endereço.

  • permission é uma chave curta, declarada em permissions do módulo. Sem a permissão, o painel responde 403; sem sessão, manda para /painel/entrar.
  • get recebe o contexto da tela (services, user, csrf, url…) e devolve { title, main }. O painel usa o title como título da página (o h1) e coloca o main ao lado do menu lateral. rail, status (200 quando ausente) e notice são opcionais.
  • main é um SafeHtml, o que a etiqueta html do ui/ devolve. Ela escapa tudo o que você interpola.
  • Os dados vêm de services. Para ler, use services.store. Para gravar, use services.records, que segue as regras das telas geradas: validação dos campos, ganchos before e after, slug único e conflito de versão.
  • Um formulário numa tela precisa levar o csrf do contexto num campo oculto csrf; o kit confere isso.

A entrada de menu aponta para a tela com screen: "resumo" em vez de collection. Se a chave não existir em screens, o TypeScript e o defineModule recusam. O resultado no painel é o grupo Organização com Avisos (a lista gerada) e Resumo dos avisos.

O site precisa ler os avisos no ar. Isso é uma rota de API sem sessão.

src/modules/avisos/module.ts
import { collection, defineModule, field, listAll, route, screen } from "../../panel/contract/index";
import { html } from "../../ui/index";
/** Who holds each permission of the module, by role: authors only read, and only admins delete. */
const permissions = {
read: ["admin", "editor", "autor"],
write: ["admin", "editor"],
remove: ["admin"],
} as const;
/** The kinds of notice, in the order the select offers them (the first one is the default). */
const TIPOS = [
{ value: "info", label: "Informação" },
{ value: "atencao", label: "Atenção" },
{ value: "urgente", label: "Urgente" },
];
/** Own screen at `/painel/avisos/resumo`: how many notices exist and how many are on. */
const resumo = screen({
title: "Resumo dos avisos",
permission: "read",
get: async ({ services }) => {
const todos = await services.store.list("avisos", { perPage: 1 });
const ligados = await services.store.list("avisos", {
where: [{ field: "ativo", op: "eq", value: true }],
perPage: 1,
});
return {
title: "Resumo dos avisos",
main: html`<p>${ligados.total} de ${todos.total} avisos estão ativos.</p>
<p><a href="/painel/c/avisos">Ver a lista de avisos</a></p>`,
};
},
});
/** Public route `GET /api/avisos/ativos`: the notices that are on and not expired, for the site to show. */
const ativos = route(
"GET",
"/api/avisos/ativos",
async ({ services }) => {
const hoje = services.clock.now().toISOString().slice(0, 10);
const items = await listAll(services.store, "avisos", { where: [{ field: "ativo", op: "eq", value: true }] });
const validos = items.filter((aviso) => typeof aviso.validade !== "string" || aviso.validade >= hoje);
return Response.json(
{ avisos: validos.map(({ titulo, texto, tipo }) => ({ titulo, texto, tipo })) },
{ headers: { "cache-control": "public, max-age=60" } },
);
},
{ public: true },
);
/** Module with a screen and a public route of its own. */
export const avisos = defineModule({
id: "avisos",
version: "1.0.0",
core: "^1.0.0",
title: "Avisos",
description: "Avisos curtos do site.",
permissions,
permissionLabels: {
read: "Ver avisos",
write: "Criar e editar avisos",
remove: "Excluir avisos",
},
menu: [
{ group: "organizacao", label: "Avisos", collection: "avisos", order: 30 },
{ group: "organizacao", label: "Resumo dos avisos", screen: "resumo", order: 31 },
],
screens: { resumo },
api: [ativos],
collections: {
avisos: collection({
label: "Avisos",
labelSingular: "Aviso",
fields: {
titulo: field.text({ label: "Título", required: true }),
texto: field.longText({ label: "Texto" }),
tipo: field.select({ label: "Tipo", options: TIPOS, default: "info", required: true }),
validade: field.date({ label: "Vale até" }),
ativo: field.boolean({ label: "Ativo", default: true }),
},
list: {
columns: ["titulo", "tipo", "validade", "ativo"],
filters: ["tipo", "ativo"],
search: ["titulo"],
sort: { field: "createdAt", direction: "desc" },
},
permissions: { read: "read", create: "write", update: "write", delete: "remove" },
}),
},
});
export { avisos as module };

route(método, caminho, handler, acesso) declara uma rota; o módulo as guarda em api.

  • O caminho vem inteiro. Uma rota pública fica em /api/<módulo> e uma autenticada, em /painel/api/<módulo> (é lá que vai o cookie de sessão). /avisos/x como rota pública dá caminho «/avisos/x» inválido: use /api/avisos ou /api/avisos/…. Segmentos :nome viram ctx.params.
  • acesso diz quem chama: { public: true } (qualquer pessoa) ou { permission: "read" } (quem tem aquela permissão do módulo). Uma permissão que o módulo não declara é erro de manifesto.
  • O handler devolve uma Response. Numa rota pública, ctx.user é null sem sessão, e o handler não deve agir em nome de um usuário. Para ler todas as páginas de uma coleção, listAll junta o que services.store.list entrega por páginas.
  • Para um formulário público (POST), o contrato traz readPublicBody, publicReply e publicGuard; veja a referência do contrato.

Com o módulo ligado, GET /api/avisos/ativos responde 200 com { "avisos": [...] } só com os avisos Ativo e dentro da validade. Desligado na tela Módulos, a rota responde 404.

Imagine que o campo tipo foi acrescentado depois dos primeiros avisos. Os avisos antigos não o têm, e tipo é obrigatório. Uma migração conserta o que já está gravado.

src/modules/avisos/module.ts
import { collection, defineModule, field, listAll, route, screen } from "../../panel/contract/index";
import { html } from "../../ui/index";
/** Who holds each permission of the module, by role: authors only read, and only admins delete. */
const permissions = {
read: ["admin", "editor", "autor"],
write: ["admin", "editor"],
remove: ["admin"],
} as const;
/** The kinds of notice, in the order the select offers them (the first one is the default). */
const TIPOS = [
{ value: "info", label: "Informação" },
{ value: "atencao", label: "Atenção" },
{ value: "urgente", label: "Urgente" },
];
/** Own screen at `/painel/avisos/resumo`: how many notices exist and how many are on. */
const resumo = screen({
title: "Resumo dos avisos",
permission: "read",
get: async ({ services }) => {
const todos = await services.store.list("avisos", { perPage: 1 });
const ligados = await services.store.list("avisos", {
where: [{ field: "ativo", op: "eq", value: true }],
perPage: 1,
});
return {
title: "Resumo dos avisos",
main: html`<p>${ligados.total} de ${todos.total} avisos estão ativos.</p>
<p><a href="/painel/c/avisos">Ver a lista de avisos</a></p>`,
};
},
});
/** Public route `GET /api/avisos/ativos`: the notices that are on and not expired, for the site to show. */
const ativos = route(
"GET",
"/api/avisos/ativos",
async ({ services }) => {
const hoje = services.clock.now().toISOString().slice(0, 10);
const items = await listAll(services.store, "avisos", { where: [{ field: "ativo", op: "eq", value: true }] });
const validos = items.filter((aviso) => typeof aviso.validade !== "string" || aviso.validade >= hoje);
return Response.json(
{ avisos: validos.map(({ titulo, texto, tipo }) => ({ titulo, texto, tipo })) },
{ headers: { "cache-control": "public, max-age=60" } },
);
},
{ public: true },
);
/** Module with a screen, a public route and the migrations of its collection. */
export const avisos = defineModule({
id: "avisos",
version: "1.0.0",
core: "^1.0.0",
title: "Avisos",
description: "Avisos curtos do site.",
permissions,
permissionLabels: {
read: "Ver avisos",
write: "Criar e editar avisos",
remove: "Excluir avisos",
},
menu: [
{ group: "organizacao", label: "Avisos", collection: "avisos", order: 30 },
{ group: "organizacao", label: "Resumo dos avisos", screen: "resumo", order: 31 },
],
screens: { resumo },
api: [ativos],
migrations: [
// Notices saved before the field `tipo` existed are plain information.
{
id: "001-tipo-padrao",
up: (db) => db.transform("avisos", (aviso) => ({ ...aviso, tipo: aviso.tipo ?? "info" })),
},
{ id: "002-indice-validade", up: (db) => db.index("avisos", ["validade"]) },
],
collections: {
avisos: collection({
label: "Avisos",
labelSingular: "Aviso",
fields: {
titulo: field.text({ label: "Título", required: true }),
texto: field.longText({ label: "Texto" }),
tipo: field.select({ label: "Tipo", options: TIPOS, default: "info", required: true }),
validade: field.date({ label: "Vale até" }),
ativo: field.boolean({ label: "Ativo", default: true }),
},
list: {
columns: ["titulo", "tipo", "validade", "ativo"],
filters: ["tipo", "ativo"],
search: ["titulo"],
sort: { field: "createdAt", direction: "desc" },
},
permissions: { read: "read", create: "write", update: "write", delete: "remove" },
}),
},
});
export { avisos as module };

migrations é uma lista ordenada de passos { id, up }. Cada id é único dentro do módulo (um repetido dá migrations[1].id: «a» repetido); numerar (001-…) deixa a ordem à vista. up recebe db, que oferece só o que qualquer banco sabe fazer:

  • db.transform(coleção, fn) reescreve todos os registros: fn recebe os campos guardados (sem id, version e datas) e devolve os campos que ficam no lugar deles.
  • db.index(coleção, campos, { unique }) cria um índice; repetir a chamada não faz mal.
  • db.raw(sql) é a saída de emergência, na linguagem do banco. Confira db.dialect antes.

Cada migração roda uma vez, em sua própria transação, e o painel registra que ela foi aplicada. Se uma falha, ela é desfeita, o servidor não sobe e o erro diz qual: Migração «001-tipo-padrao» do módulo «avisos» falhou: …. As migrações pendentes rodam quando o servidor de produção abre o banco (bun run start), antes de atender qualquer pedido. Sem NODE_ENV=production, o src/server.ts sobe o painel com os dados em memória, e não há banco para migrar. Escreva cada passo de modo que rodar de novo seja inofensivo.

Um aviso urgente sem texto não diz nada. O gancho recusa esse caso nas telas geradas e em tudo que grava por services.records.

src/modules/avisos/module.ts
import {
collection,
defineModule,
field,
type HookEvent,
type HookOutcome,
listAll,
route,
screen,
} from "../../panel/contract/index";
import { html } from "../../ui/index";
/** Who holds each permission of the module, by role: authors only read, and only admins delete. */
const permissions = {
read: ["admin", "editor", "autor"],
write: ["admin", "editor"],
remove: ["admin"],
} as const;
/** The kinds of notice, in the order the select offers them (the first one is the default). */
const TIPOS = [
{ value: "info", label: "Informação" },
{ value: "atencao", label: "Atenção" },
{ value: "urgente", label: "Urgente" },
];
/** Own screen at `/painel/avisos/resumo`: how many notices exist and how many are on. */
const resumo = screen({
title: "Resumo dos avisos",
permission: "read",
get: async ({ services }) => {
const todos = await services.store.list("avisos", { perPage: 1 });
const ligados = await services.store.list("avisos", {
where: [{ field: "ativo", op: "eq", value: true }],
perPage: 1,
});
return {
title: "Resumo dos avisos",
main: html`<p>${ligados.total} de ${todos.total} avisos estão ativos.</p>
<p><a href="/painel/c/avisos">Ver a lista de avisos</a></p>`,
};
},
});
/** Public route `GET /api/avisos/ativos`: the notices that are on and not expired, for the site to show. */
const ativos = route(
"GET",
"/api/avisos/ativos",
async ({ services }) => {
const hoje = services.clock.now().toISOString().slice(0, 10);
const items = await listAll(services.store, "avisos", { where: [{ field: "ativo", op: "eq", value: true }] });
const validos = items.filter((aviso) => typeof aviso.validade !== "string" || aviso.validade >= hoje);
return Response.json(
{ avisos: validos.map(({ titulo, texto, tipo }) => ({ titulo, texto, tipo })) },
{ headers: { "cache-control": "public, max-age=60" } },
);
},
{ public: true },
);
/** Before saving: an urgent notice without a text says nothing, so it is refused with a pt-BR message. */
function exigeTexto({ record }: HookEvent): HookOutcome {
const texto = typeof record?.texto === "string" ? record.texto.trim() : "";
if (record?.tipo === "urgente" && texto === "") return { refuse: "Um aviso urgente precisa de texto." };
return undefined;
}
/** The finished module: collection, screen, public route, migrations and a hook. */
export const avisos = defineModule({
id: "avisos",
version: "1.0.0",
core: "^1.0.0",
title: "Avisos",
description: "Avisos curtos do site.",
permissions,
permissionLabels: {
read: "Ver avisos",
write: "Criar e editar avisos",
remove: "Excluir avisos",
},
menu: [
{ group: "organizacao", label: "Avisos", collection: "avisos", order: 30 },
{ group: "organizacao", label: "Resumo dos avisos", screen: "resumo", order: 31 },
],
screens: { resumo },
api: [ativos],
hooks: { "avisos.beforeCreate": exigeTexto, "avisos.beforeUpdate": exigeTexto },
migrations: [
// Notices saved before the field `tipo` existed are plain information.
{
id: "001-tipo-padrao",
up: (db) => db.transform("avisos", (aviso) => ({ ...aviso, tipo: aviso.tipo ?? "info" })),
},
{ id: "002-indice-validade", up: (db) => db.index("avisos", ["validade"]) },
],
collections: {
avisos: collection({
label: "Avisos",
labelSingular: "Aviso",
fields: {
titulo: field.text({ label: "Título", required: true }),
texto: field.longText({ label: "Texto" }),
tipo: field.select({ label: "Tipo", options: TIPOS, default: "info", required: true }),
validade: field.date({ label: "Vale até" }),
ativo: field.boolean({ label: "Ativo", default: true }),
},
list: {
columns: ["titulo", "tipo", "validade", "ativo"],
filters: ["tipo", "ativo"],
search: ["titulo"],
sort: { field: "createdAt", direction: "desc" },
},
permissions: { read: "read", create: "write", update: "write", delete: "remove" },
}),
},
});
export { avisos as module };

Um gancho é uma função em hooks, sob a chave <coleção>.<momento><Evento>. Aqui, avisos.beforeCreate e avisos.beforeUpdate; o núcleo dispara também afterCreate, afterUpdate, beforeDelete e afterDelete para cada coleção. Uma chave fora do formato dá gancho «avisos.antesCriar» inválido: use <coleção|content|publish>.(before|after)<Evento>.

  • O handler recebe o evento: record (os valores já validados), previous (o registro antes da mudança, quando existia), user e services.
  • Num before*, devolver { refuse: "mensagem" } recusa a gravação e mostra a mensagem em português. Devolver nada deixa passar. Se o handler lançar um erro, a gravação também é recusada, com a mensagem genérica Não foi possível concluir a ação: um módulo falhou. Tente de novo., e o erro vai para o registro de auditoria (hook.error).
  • Os ganchos before* rodam na ordem dos módulos e param no primeiro que recusa. O resultado de um after* é ignorado.
  • Na tela gerada, a recusa do exemplo volta como status 422, com o formulário preenchido e o aviso Um aviso urgente precisa de texto. (o painel acrescenta «Erro:» antes, só para leitores de tela)

Um módulo também pode ouvir a coleção de outro. O módulo oficial redirecionamentos ouve posts.afterSlugChange; veja os módulos oficiais como modelo.

O módulo está pronto. Falta o projeto conhecê-lo.

  1. Salve o último passo em src/modules/avisos/module.ts.
  2. Abra src/modules/index.ts, o arquivo que o servidor importa para saber quais módulos carregar. Acrescente um import { module as … } do seu módulo e o nome ao fim da lista:
src/modules/index.ts
// Generated by `slash create`: the installed modules, dependencies first.
import type { ModuleDef } from "../panel/contract/index";
import { module as postsModule } from "./posts/module";
import { module as categoriasModule } from "./categorias/module";
import { module as comentariosModule } from "./comentarios/module";
import { module as metricasModule } from "./metricas/module";
import { module as newsletterModule } from "./newsletter/module";
import { module as paginasModule } from "./paginas/module";
import { module as redirecionamentosModule } from "./redirecionamentos/module";
import { module as seoModule } from "./seo/module";
import { module as tagsModule } from "./tags/module";
import { module as avisosModule } from "./avisos/module";
export const modules: readonly ModuleDef[] = [postsModule, categoriasModule, comentariosModule, metricasModule, newsletterModule, paginasModule, redirecionamentosModule, seoModule, tagsModule, avisosModule];
  1. Reinicie o servidor (bun run start). O painel carrega o módulo, aplica a migração e mostra Avisos e Resumo dos avisos no grupo Organização.

Quatro detalhes:

  • O módulo nasce ligado. Quem administra o painel pode desligá-lo na tela Módulos; veja Módulos.
  • A ordem da lista não manda: o servidor ordena os módulos pelas dependências que cada um declara em requires. O exemplo segue a ordem que o slash create escreve, dependências primeiro.
  • Não escreva avisos no slash.lock. Esse arquivo guarda só os módulos oficiais; um nome que o catálogo do slash não conhece faz slash module add recusar tudo. Mais na próxima página.
  • O módulo não depende de nenhum outro. Se o seu depender (por exemplo, de posts), declare requires: ["posts"]; o servidor não sobe com uma dependência ausente.

A próxima página testa o módulo com o kit de conformidade.