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.
Passo 4: uma tela própria
Seção intitulada “Passo 4: uma tela própria”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.
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 empermissionsdo módulo. Sem a permissão, o painel responde 403; sem sessão, manda para/painel/entrar.getrecebe o contexto da tela (services,user,csrf,url…) e devolve{ title, main }. O painel usa otitlecomo título da página (oh1) e coloca omainao lado do menu lateral.rail,status(200 quando ausente) enoticesão opcionais.mainé umSafeHtml, o que a etiquetahtmldoui/devolve. Ela escapa tudo o que você interpola.- Os dados vêm de
services. Para ler, useservices.store. Para gravar, useservices.records, que segue as regras das telas geradas: validação dos campos, ganchosbeforeeafter, slug único e conflito de versão. - Um formulário numa tela precisa levar o
csrfdo contexto num campo ocultocsrf; 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.
Passo 5: uma rota pública
Seção intitulada “Passo 5: uma rota pública”O site precisa ler os avisos no ar. Isso é uma rota de API sem sessão.
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/xcomo rota pública dácaminho «/avisos/x» inválido: use /api/avisos ou /api/avisos/…. Segmentos:nomeviramctx.params. acessodiz 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énullsem 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,listAlljunta o queservices.store.listentrega por páginas. - Para um formulário público (POST), o contrato traz
readPublicBody,publicReplyepublicGuard; 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.
Passo 6: uma migração
Seção intitulada “Passo 6: uma migração”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.
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:fnrecebe os campos guardados (semid,versione 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. Confiradb.dialectantes.
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.
Passo 7: um gancho
Seção intitulada “Passo 7: um gancho”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.
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),usereservices. - 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éricaNã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 umafter*é 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.
Instalar no projeto
Seção intitulada “Instalar no projeto”O módulo está pronto. Falta o projeto conhecê-lo.
- Salve o último passo em
src/modules/avisos/module.ts. - Abra
src/modules/index.ts, o arquivo que o servidor importa para saber quais módulos carregar. Acrescente umimport { module as … }do seu módulo e o nome ao fim da lista:
// 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];- 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 oslash createescreve, dependências primeiro. - Não escreva
avisosnoslash.lock. Esse arquivo guarda só os módulos oficiais; um nome que o catálogo do slash não conhece fazslash module addrecusar tudo. Mais na próxima página. - O módulo não depende de nenhum outro. Se o seu depender (por exemplo, de
posts), declarerequires: ["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.