Ir para o conteúdo
Trilha
Capítulos

Aprender

Telas prontas

Uma tela de app interno de ponta a ponta — moldura, números, tabela que vive na URL, a parte lenta, o arquivo, o formulário em etapas — montada com o kit que você já tem.

O capítulo anterior é um catálogo: trinta componentes, um de cada vez, cada um com a sua demo. Este aqui é uma tela. As mesmas peças, na ordem em que alguém de fato as escreve, com o motivo de cada uma estar ali — porque saber o que é o ui.DataTable não diz onde ele fica, quem o preenche nem o que o handler acima dele ainda deve.

A tela é o console de pedidos de um app interno: a moldura em volta, quatro números no topo, uma tabela filtrável no meio, um gráfico que chega atrasado, uma linha que abre um arquivo, um formulário que leva três telas e uma célula que muda sem ninguém clicar. Cada bloco abaixo é uma declaração de examples/cookbook/screens.go, então tudo isso compila junto com o resto do repositório.

A tela, e onde ficam os arquivos dela#

O roteamento é a árvore de pastas, então a tela já está descrita por onde os arquivos dela estão:

app/
  layout.go                    ← a moldura: ui.Shell em volta de toda página
  orders/
    page.go                    ← a listagem, e a tabela como fragmento
    insights/page.go           ← o que o ui.Defer pede
    new/
      page.go                  ← etapa um
      items/page.go            ← etapa dois: o GET desenha, o POST salva o rascunho
      confirm/page.go          ← etapa três
    id_/
      page.go                  ← um pedido: metadados e a nota fiscal
      state/page.go            ← a célula viva, em rota própria

Nenhuma rota é registrada à mão e nenhum arquivo se registra sozinho: o trilha gen lê a árvore. Veja Páginas e rotas.

A moldura#

O ui.Shell é a barra lateral, o topo e o menu de quem está logado, escritos uma vez, no layout raiz. Ele é uma composição de componentes que já existiam — ui.Sidebar, ui.Nav, ui.Menu —, então o que ele não cobre você escreve ao lado com as mesmas peças. O item ativo é aquele cujo Href é o maior prefixo do caminho atual, e é por isso que /orders/42 acende Orders, e não Dashboard.

// OrdersLayout is the frame, written once for every screen of the app.
// ui.Shell is a composition of components that already existed, so anything it
// does not cover is written beside it with the same pieces. ui.LiveScript goes
// in the head because the screen has a fragment that arrives late and a cell
// that refreshes itself; a page with neither never downloads it.
func OrdersLayout(c *trilha.Ctx, children h.Node) (h.Node, error) {
	return h.Html(h.Lang("en"),
		h.Head(
			h.Meta(h.Charset("utf-8")),
			h.Meta(h.Name("viewport"), h.Content("width=device-width, initial-scale=1")),
			h.Title(h.Text("Acme Ops")),
			ui.Head(c),
			ui.LiveScript(c),
		),
		h.Body(ui.Body(),
			ui.Shell(c, ui.ShellOpts{
				Brand: h.A(h.Class("ui-brand"), h.Href("/"), h.Text("Acme Ops")),
				Nav: []ui.NavGroup{
					{Label: "Work", Items: []ui.NavItem{
						{Href: "/", Label: "Dashboard", Icon: "house"},
						{Href: "/orders", Label: "Orders", Icon: "search"},
					}},
					// Hide is a courtesy to whoever reads the menu, never a
					// permission: what keeps somebody out of /settings is the
					// middleware.go at the root of that folder.
					{Label: "Admin", Hide: !ordersAdmin(c), Items: []ui.NavItem{
						{Href: "/settings", Label: "Settings", Icon: "settings"},
					}},
				},
				User:   ui.UserMenu{Name: "Ana Reis", Detail: "ana@acme.example", Items: []h.Node{ui.MenuLink("/profile", h.Text("Profile"))}},
				Header: []h.Node{ui.ThemeToggle()},
			}, children),
			ui.Flashes(c),
		),
	), nil
}

Duas decisões que merecem nome. O ui.LiveScript(c) está no head porque esta tela tem um fragmento que chega depois e uma célula que se atualiza sozinha; o ui.Head não carrega esse script, e uma página sem nenhum dos dois nunca o baixa. E o Hide do grupo Admin é uma cortesia com quem lê o menu, não uma permissão — a mesma pergunta é feita de novo, para valer, pelo middleware.go na raiz de /settings:

// ordersAdmin is the question the menu asks, and the same one the middleware
// at the root of /settings asks again — the menu hides, the middleware
// refuses, and only one of the two is a permission.
func ordersAdmin(c *trilha.Ctx) bool {
	role, _ := c.Get("role").(string)
	return role == "admin"
}

Veja Shell para Current, a lateral que recolhe e o IconNode, e a demo com a moldura desenhada.

O que é um pedido, e o que ele pode ser#

A linha é o tipo do app, não do kit. ui.Columns[T] é genérico, então quem confere a tabela é o compilador, e não um map[string]any que renderiza em branco quando um campo muda de nome:

// Order is the row of the screen. The type is the app's, not the kit's:
// ui.Columns is generic, so a field that changes name stops compiling instead
// of rendering blank.
type Order struct {
	ID       string
	Customer string
	Total    float64
	State    string
	Updated  time.Time
	Invoice  string // where the PDF is; empty until the order ships
}

Os estados são uma lista só, declarada uma vez:

// OrderStates is the single declaration of what an order can be: the value the
// database holds, the word a person reads and the tone the badge wears. The
// filter's select, the form's validation and ui.Status all read this list, so
// a state added here shows up in the three of them at once.
var OrderStates = trilha.Enum{
	{Value: "draft", Label: "Draft"},
	{Value: "picking", Label: "Picking", Tone: "info"},
	{Value: "shipped", Label: "Shipped", Tone: "success"},
	{Value: "returned", Label: "Returned", Tone: "danger"},
}

Essa lista é lida por quatro coisas diferentes — o <select> do filtro, a validação do formulário (validate:"enum=ops.OrderState", depois do trilha.RegisterEnum), o emblema da tabela e o emblema da tela de detalhe. Acrescentar um estado é uma linha aqui, e os quatro seguem. Veja Validação e a demo.

Um handler, três respostas#

A tela inteira é uma função. Ela responde a página e responde só a tabela quando o kit a troca — o id do fragmento é o id do elemento que está sendo substituído, e esse é o protocolo inteiro:

// OrdersPage is the screen and each of its pieces. One handler answers three
// requests — the whole page, the table when the kit swaps it, and nothing else
// — because the id of the fragment is the id of the element being replaced,
// and that is the entire protocol.
func OrdersPage(c *trilha.Ctx) (h.Node, error) {
	var q OrdersQuery
	if err := c.Bind(&q); err != nil {
		return nil, err
	}
	// Sort arrived from the address, typed by whoever wrote it. Restrict is
	// what turns it into a column name, before the repository sees it.
	q.Restrict("customer", "total", "updated")
	rows, total, err := orders.Search(q)
	if err != nil {
		return nil, err
	}
	table := ordersTable(c, q, rows, total)
	if c.Fragment() == "orders" {
		return table, nil
	}
	sum, err := orders.Summary()
	if err != nil {
		return nil, err
	}
	c.SetTitle("Orders")
	return h.Div(
		ui.PageHeader("Orders", ui.ButtonLink("/orders/new", ui.Icon("plus"), h.Text("New order"))),
		ui.Grid(orderStats(sum)...),
		ui.Defer(c, "insights", "/orders/insights", ui.DeferOpts{Height: "12rem"}),
		table,
	), nil
}

O c.Bind preenche a struct a partir da query e aplica os limites do ListParams; o Restrict é a linha que importa para a segurança, porque o Sort veio do endereço, digitado por quem quis, e não é nome de coluna até a tela dizer quais nomes existem. Veja Interatividade para o fragmento e Listagens para o resto.

Quatro números no topo#

Três contagens e um número que tem forma. O sparkline tem o tamanho de uma linha de texto, em SVG escrito pelo servidor: chega junto com a página, imprime e não custa download nenhum.

// orderStats is the row of numbers on top. Three of them are counts and the
// fourth has a shape, which is what a sparkline is for: a drawing the size of
// a line of text, in SVG written by the server, next to the number it is
// about.
func orderStats(s OrdersSummary) []h.Node {
	return []h.Node{
		ui.Card(ui.Stat("Open", strconv.Itoa(s.Open))),
		ui.Card(ui.Stat("Shipped today", strconv.Itoa(s.ShippedToday))),
		ui.Card(ui.Stat("Revenue", dollars(s.Revenue), ui.StatHint("this month"))),
		ui.Card(ui.Stat("Late", strconv.Itoa(s.Late),
			ui.SparklineTitle(s.LateByDay, ui.SparkOpts{}, ui.ChartTitle("Late orders, last 14 days")))),
	}
}

// dollars is money as text, because ui.Stat takes a string: the framework has
// no currency, so where the symbol goes is the app's decision. In a cell,
// where a node fits, ui.Number does the digits by the locale of the Ctx.
func dollars(v float64) string { return "$" + strconv.FormatFloat(v, 'f', 2, 64) }

O ui.ChartTitle é o que transforma o desenho em uma imagem com nome em vez de enfeite, e o dollars é um lembrete de que o framework não tem moeda: o ui.Stat recebe texto, então onde o símbolo entra é decisão do app. Onde cabe um nó — uma célula de tabela —, o ui.Number e o ui.Date leem o locale do Ctx. Veja Gráficos, Formatação e a demo.

A tabela vive na URL#

O estado da listagem é uma struct, e tudo nele está no endereço, então a tela pode ser compartilhada, recarregada, salva nos favoritos e desandada com o botão do próprio navegador:

// OrdersQuery is the state of the screen, and all of it is in the address:
// ListParams brings page, order and search, and the fields beside it are the
// filters this screen added. c.Bind fills the whole thing, applies the limits
// of ListParams and keeps the rest of the query so the links preserve it.
type OrdersQuery struct {
	trilha.ListParams
	State string `form:"state" validate:"omitempty,enum=ops.OrderState"`
}

As colunas são o tipo do app de novo. Duas das células são formatadas, e é por isso que elas recebem o Ctx:

// ordersColumns names the table. Key is what the URL orders by and what
// Restrict accepts; Cell renders one row of the app's own type. It takes the
// Ctx because two of the cells are formatted — the number and the date read
// the locale off it, and the same screen serves both languages.
func ordersColumns(c *trilha.Ctx) ui.Columns[Order] {
	return ui.Columns[Order]{
		{Key: "customer", Label: "Customer", Sort: true, Cell: func(o Order) h.Node { return h.Text(o.Customer) }},
		{Key: "total", Label: "Total", Sort: true, Num: true, Cell: func(o Order) h.Node {
			return ui.Number(c, o.Total, ui.Decimals(2))
		}},
		{Key: "state", Label: "State", Cell: orderStateCell},
		{Key: "updated", Label: "Updated", Sort: true, Cell: func(o Order) h.Node {
			return ui.Date(c, o.Updated, ui.Relative())
		}},
	}
}

E a tabela é uma chamada só, com tudo o que a URL disse sobre ela:

// ordersTable is the listing: the filter form on top, the sortable headers,
// the pagination at the foot and the empty state, all of it built from what
// Bind read. ID is what makes ordering, filtering and paging swap the table
// instead of reloading the screen.
func ordersTable(c *trilha.Ctx, q OrdersQuery, rows []Order, total int) h.Node {
	return ui.DataTable(c, ordersColumns(c), rows, ui.ListState{
		Params:  q.ListParams,
		Total:   total,
		ID:      "orders",
		Search:  "Search by customer",
		Filters: ordersFilters(q),
		Empty:   ordersEmpty(q),
		Cards:   true,
		RowHref: func(i int) string { return "/orders/" + rows[i].ID },
	})
}
// ordersFilters is the rest of the filter form — the search box is the kit's.
// The select's options come from the enum, so the filter cannot offer a state
// the validation would refuse.
func ordersFilters(q OrdersQuery) h.Node {
	return ui.Field("state", "State", ui.Select(h.ID("state"), h.Name("state"),
		OrderStates.Options(q.State, "Any state")))
}

O ID é o que faz ordenar, filtrar e paginar trocarem a tabela em vez de recarregarem a tela — com o JavaScript desligado, os mesmos links navegam e o mesmo formulário envia. O RowHref faz a linha abrir o pedido. As opções do filtro vêm do enum, então a tela não consegue oferecer um estado que a validação recusaria. Veja Listagens e a demo.

Filtrada até não sobrar nada#

Uma lista sem nada dentro e uma lista que um filtro esvaziou são duas telas diferentes. A primeira diz "crie o primeiro pedido"; a segunda tem de dizer o que desfazer, ou a pessoa está olhando para um app que parece ter perdido os dados dela:

// ordersEmpty is the screen a filter that matched nothing shows. It is not the
// same screen as an app with no orders at all: this one says what to undo, and
// the way out is the same listing with the filter dropped and the rest of the
// address kept.
func ordersEmpty(q OrdersQuery) h.Node {
	return ui.Empty(ui.EmptyOpts{
		Icon:   "funnel",
		Title:  "No order matches this filter",
		Hint:   "Try another state, or clear the search.",
		Action: ui.ButtonLink(q.Href("q", "", "state", "", "page", ""), ui.Outline(), h.Text("Clear the filter")),
	})
}

O q.Href preserva o resto do endereço, então a saída larga o filtro e a busca, e nada mais. O ui.DataTable faz essa distinção sozinho, em inglês; o ListState.Empty é como a aplicação diz isso com as palavras dela. Veja a demo.

A parte lenta, depois da página#

O gráfico agrupa um mês de pedidos por canal e leva dois segundos. Nada mais na tela devia esperar por ele, e ninguém devia ficar olhando para um spinner no lugar onde a página inteira estava:

// OrdersInsights is the route the placeholder asks for once the page has
// loaded. It is an ordinary page: with no fragment header it answers the whole
// thing, which is where the placeholder's <noscript> link goes — without
// JavaScript the slow part is one click away instead of missing.
func OrdersInsights(c *trilha.Ctx) (h.Node, error) {
	data, err := orders.Insights(c.Context())
	if err != nil {
		return nil, err
	}
	block := h.Div(h.ID("insights"), ui.Bars(data, ui.ChartTitle("Orders by channel")))
	if c.Fragment() == "insights" {
		return block, nil
	}
	return h.Div(ui.PageHeader("Insights"), block), nil
}

O ui.Defer desenha um placeholder de altura conhecida — a página não pode pular quando o conteúdo chega — e pede o fragmento assim que a página carrega. A rota é uma página comum: sem o cabeçalho de fragmento ela responde a coisa inteira, que é para onde vai o link do <noscript> do placeholder; sem JavaScript, a parte lenta fica a um clique de distância em vez de sumir.

Um Defer por parte da tela, nunca um por linha de lista: uma página que adia vinte fragmentos fez vinte requisições para se desenhar, que é justamente a SPA que ela estava evitando. Veja Fragmentos vivos e a demo.

A célula que anda sozinha#

Um pedido é separado e despachado por gente que não está olhando para esta tela. A célula de estado fica de olho:

// orderStateCell is the one cell that moves without anybody clicking. ui.On
// waits for an event named after the order and asks this same fragment's route
// again — the event carries the name, never the HTML, so the authorization and
// the rendering stay where they already are.
func orderStateCell(o Order) h.Node {
	return h.Div(h.ID("order-"+o.ID+"-state"), ui.On("order:"+o.ID, "/orders/"+o.ID+"/state"),
		ui.Status(OrderStates, o.State))
}

// OrderStatePage is what the event makes the browser ask for. A value the enum
// no longer knows renders muted instead of taking the screen down.
func OrderStatePage(c *trilha.Ctx) (h.Node, error) {
	o, err := orders.Find(c.Param("id"))
	if err != nil {
		return nil, err
	}
	return orderStateCell(o), nil
}

O ui.On espera um evento com o nome do pedido e pede de novo a rota da própria célula. O evento carrega o nome, nunca o HTML: a autorização e a renderização continuam onde já estavam, e o stream nunca vira um canal de dados. O ui.Poll("30s", src) é a mesma ideia no relógio, para uma tela que não tem um barramento atrás.

A célula é a mesma função na tabela e na tela de detalhe, porque um fragmento não sabe em que tela está. Veja Fragmentos vivos e a demo.

A linha, aberta#

// OrderPage is what a row opens: the metadata on one side, the invoice on the
// other, and the same live cell the table had — the fragment does not care
// which screen it is on.
func OrderPage(c *trilha.Ctx) (h.Node, error) {
	o, err := orders.Find(c.Param("id"))
	if err != nil {
		return nil, err
	}
	c.SetTitle("Order " + o.ID)
	return h.Div(
		ui.PageHeader("Order "+o.ID, ui.ButtonLink("/orders", ui.Outline(), ui.Icon("arrow-left"), h.Text("Back"))),
		ui.Grid(
			ui.Card(ui.Stack(
				ui.Stat("Customer", o.Customer),
				ui.Stat("Total", dollars(o.Total)),
				orderStateCell(o),
			)),
			orderInvoice(c, o),
		),
	), nil
}

A nota fiscal é um arquivo, e arquivo tem três casos: um que o navegador desenha, um que ele renderiza embutido e um que ele não mostra de jeito nenhum. O ui.Preview escolhe; o terceiro caso vira um cartão com botão de baixar em vez de um quadro que renderiza em branco. O quarto caso é o arquivo que ainda não existe, e aí é o ui.Empty de novo — o mesmo componente, outra frase:

// orderInvoice is the file beside its metadata. ui.Preview decides how to show
// it from the type — an image as an <img>, a PDF in a frame, anything a
// browser cannot render as a card with a download button instead of a frame
// that renders blank.
func orderInvoice(c *trilha.Ctx, o Order) h.Node {
	if o.Invoice == "" {
		return ui.Empty(ui.EmptyOpts{Icon: "download", Title: "No invoice yet",
			Hint: "It is issued when the order ships."})
	}
	return ui.Preview(c, o.Invoice, ui.PreviewOpts{Title: "Invoice " + o.ID, Height: "24rem"})
}

Veja a demo e Uploads para saber de onde vem o o.Invoice.

O formulário que leva três telas#

O ui.Steps desenha onde a pessoa está: o que ficou para trás linka de volta, o que está à frente é texto puro, e a etapa atual leva aria-current="step".

// orderSteps is the new-order form, which is three screens. A step ahead of
// the current one never links, however hard somebody stares at it: a wizard
// whose third step is one click away is a wizard whose steps did not have to
// happen in order.
var orderSteps = []ui.Step{
	{Label: "Customer", Href: "/orders/new"},
	{Label: "Items", Href: "/orders/new/items"},
	{Label: "Confirm"},
}

O que viaja entre as telas é um rascunho, uma struct por etapa, para que cada tela valide os próprios campos e nenhuma mensagem aponte para um campo duas telas atrás:

// OrderDraft is what travels between the three screens, one struct per step so
// that each screen validates its own fields and no message ever points at a
// field two screens back.
type OrderDraft struct {
	Who   OrderWhoStep   `json:"who"`
	Items OrderItemsStep `json:"items"`
}

// OrderWhoStep is the first screen.
type OrderWhoStep struct {
	Customer string `form:"customer" validate:"required,max=80"`
	Email    string `form:"email"    validate:"required,email"`
}

// OrderItemsStep is the second.
type OrderItemsStep struct {
	SKU string `form:"sku" validate:"required"`
	Qty int    `form:"qty" validate:"required,min=1"`
}
// OrderItemsPage is every screen after the first. No draft is not a failure:
// it is somebody whose draft expired or who typed the address of step two, and
// the answer is step one — not an empty form that would lose what they fill in
// here.
func OrderItemsPage(c *trilha.Ctx) (h.Node, error) {
	var d OrderDraft
	if err := c.Draft("new-order").Load(&d); err != nil {
		return nil, c.Redirect("/orders/new")
	}
	return h.Div(
		ui.PageHeader("New order"),
		ui.Steps(orderSteps, 2),
		orderItemsForm(c, d.Items),
	), nil
}
// OrderItemsPOST is the end of a step: load what is there, bind only this
// step, save, move on. A 422 here costs nothing that was typed on an earlier
// screen, because the earlier screens are in the draft and not in this form.
func OrderItemsPOST(c *trilha.Ctx) error {
	var d OrderDraft
	_ = c.Draft("new-order").Load(&d)
	if err := c.Bind(&d.Items); err != nil {
		return err // FieldErrors: 422 with the messages next to the fields
	}
	if err := c.Draft("new-order").Save(d, 30*time.Minute); err != nil {
		return err
	}
	return c.Redirect("/orders/new/confirm")
}

O c.Draft é um cookie assinado ou uma store, conforme o Config.Drafts; de um jeito ou de outro, um refresh, um 422 ou um telefonema no meio não custam nada do que foi digitado. Não ter rascunho não é falha — é um rascunho que expirou ou um endereço colado, e a resposta é a etapa um. O formulário posta para a própria rota, com trilha.CSRFInput(c) dentro. Veja Um formulário em etapas e a demo.

O que a tela custou#

Nenhum roteador no cliente, nenhum passo de build, nenhum estado duplicado entre servidor e navegador e nenhum JavaScript sem o qual a tela pare de funcionar: ordenar, filtrar, paginar, o formulário em etapas e a tela de detalhe funcionam com o ui.js desligado, e o que o script acrescenta é que nada disso recarrega. O conjunto é uma pasta de handlers e umas duzentas linhas.

O que não coube aqui, e entra do mesmo jeito quando a tela precisar: ui.Tree para um filtro que é uma hierarquia, ui.Combobox para um campo com milhares de opções, ui.SearchBox para a busca da barra de cima e ui.EmptyError para a parte da tela que não deu para carregar — com o erro em si só em desenvolvimento.

Desafio#

Quem opera este console quer ver de relance quais pedidos estão atrasados: não despachados e sem mexer há mais de dois dias. Acrescente a coluna sem tocar na consulta, na URL nem na tela vazia.

Mostrar solução
// ordersLateColumn is the answer to the chapter's challenge: one more column,
// and nothing else. The row already knows whether it is late, and the cell is
// the same badge the state uses — a second vocabulary of colours on one screen
// is how two tables end up with two different reds.
var ordersLateColumn = ui.Column[Order]{Key: "late", Label: "Late", Cell: func(o Order) h.Node {
	if o.State == "shipped" || time.Since(o.Updated) < 48*time.Hour {
		return ui.Muted(h.Text("—"))
	}
	return ui.Badge(ui.Destructive(), h.Text("late"))
}}

Uma entrada a mais nas colunas de ordersColumns, e nada mais se mexe: a linha já carrega o que a resposta precisa, então não aparece filtro, nem parâmetro, nem uma segunda consulta. O emblema é o mesmo que a célula de estado usa, porque um segundo vocabulário de cores na mesma tela é como duas tabelas acabam com dois vermelhos diferentes.

Repare no que a coluna não tem: Sort: true. Ordenar por ela colocaria late na URL, e o Restrict derrubaria — o banco não tem essa coluna, e a tela estaria mentindo sobre o que consegue ordenar.