Spread the Rules table's treatment to every table that carries verbs (record-table-actions, archived 2026-09-19): the table is align-middle, the Actions header and cells are text-end, every button in an Actions cell carries ms-1, and the header's word is for assistive technology only (a visually-hidden span; Primer: row actions do not require a visible column header). The two integrations tables and the member domains list, which headed their controls with an empty cell, take the hidden word too. A new anatomy-lint rule, actions-column, refuses a table that drifts from any of it. Decisions on the way: a row tint marks the row in play (the current workspace, a staged change), never a record's status; the member catalog lists only what can be bought, so a Listed product without an active, synced, recurring price is left out instead of shown with "Not available for purchase"; two Actions cells that carried text in a verb's place are empty (the Placements column and the pending panel already say why); and four record tables gain their width floor. Specs: page-anatomy "Tables share one density" modified, ui-quality-gate gains "Lint refuses an Actions column without its treatment", member-product-discovery's Extras bucket and truthful-copy requirements modified. docs/design-system.md §6 states the treatment and its reasons.
99 lines
4.4 KiB
Go
99 lines
4.4 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestMemberAddonsTruthfulCopy pins the member-product-discovery Extras
|
|
// bucket after record-table-actions (maintainer, 2026-09-19): every listed
|
|
// row can be bought, because buildAddonsData drops a product without an
|
|
// active, synced, recurring price instead of listing it with "Not available
|
|
// for purchase" where its Add would be. A listed product renders the normal
|
|
// purchase affordance (an enabled Add control wired to checkout) with no
|
|
// unavailability copy and no "Coming soon" roadmap claim, and a carried
|
|
// display_category label renders as a small grouping badge alongside it.
|
|
func TestMemberAddonsTruthfulCopy(t *testing.T) {
|
|
t.Run("a listed product renders the purchase affordance and a category badge, no unavailability copy", func(t *testing.T) {
|
|
data := AddonsData{Addons: []AddonViewModel{
|
|
{ProductID: "p2", Name: "Extra Storage", Description: "More room.", DisplayCategory: "addon", PriceID: "price-1"},
|
|
}}
|
|
out := renderMemberDomains(t, "member_addons.html", data)
|
|
|
|
if strings.Contains(out, "Not available for purchase") || strings.Contains(out, "Coming soon") {
|
|
t.Error("a listed product must not render unavailability copy")
|
|
}
|
|
if strings.Contains(out, "disabled") {
|
|
t.Error("purchasable product's control must not render disabled")
|
|
}
|
|
if !strings.Contains(out, `hx-post="/billing/checkout"`) || !strings.Contains(out, `"price_id": "price-1"`) {
|
|
t.Error("purchasable product must submit to checkout with its price id")
|
|
}
|
|
if !strings.Contains(out, `<span class="badge text-bg-light border">addon</span>`) {
|
|
t.Error("a carried display_category must render as a small grouping badge (statusBadge, light)")
|
|
}
|
|
})
|
|
|
|
t.Run("blank display_category renders no badge", func(t *testing.T) {
|
|
data := AddonsData{Addons: []AddonViewModel{
|
|
{ProductID: "p3", Name: "Extra Storage", Description: "More room.", PriceID: "price-1"},
|
|
}}
|
|
out := renderMemberDomains(t, "member_addons.html", data)
|
|
if strings.Contains(out, `class="badge`) {
|
|
t.Error("a blank display_category must not render a grouping badge")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMemberAddonsHeadingAndDefinition pins the non-plan section's
|
|
// conditional heading (design.md D6, maintainer decision 2026-08-23):
|
|
// "More products" beneath plan sections (with the definition line), plain
|
|
// "Products" when no plans exist and this section is the whole catalog —
|
|
// a plan-less deployment must not read its catalog as extras. "Add-ons"
|
|
// stays retired: a solo-purchasable product is not an addition to anything.
|
|
func TestMemberAddonsHeadingAndDefinition(t *testing.T) {
|
|
row := []AddonViewModel{
|
|
{ProductID: "p1", Name: "Extra Storage", Description: "More room.", PriceID: "price-1"},
|
|
}
|
|
|
|
withPlans := renderMemberDomains(t, "member_addons.html", AddonsData{Addons: row, HasPlans: true})
|
|
if !strings.Contains(withPlans, ">More products<") {
|
|
t.Error(`with plans above, expected the section heading "More products"`)
|
|
}
|
|
if !strings.Contains(withPlans, "Products to add alongside your plan, or to use on their own.") {
|
|
t.Error("with plans above, expected the definition line")
|
|
}
|
|
|
|
alone := renderMemberDomains(t, "member_addons.html", AddonsData{Addons: row, HasPlans: false})
|
|
if !strings.Contains(alone, ">Products<") {
|
|
t.Error(`standing alone, expected the plain heading "Products"`)
|
|
}
|
|
if strings.Contains(alone, "alongside your plan") {
|
|
t.Error("standing alone, the plan-referencing definition line must not render")
|
|
}
|
|
|
|
for _, out := range []string{withPlans, alone} {
|
|
if strings.Contains(out, ">Add-ons<") || strings.Contains(out, ">Extras<") {
|
|
t.Error(`"Add-ons" and "Extras" must both be retired as the bucket heading`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMemberAddonsNoQualifyingProductsRendersNothing pins the "No qualifying
|
|
// products" scenario: an empty Addons slice renders no section at all — no
|
|
// heading, no table, no empty-state chrome.
|
|
func TestMemberAddonsNoQualifyingProductsRendersNothing(t *testing.T) {
|
|
out := renderMemberDomains(t, "member_addons.html", AddonsData{})
|
|
if strings.TrimSpace(strings.ReplaceAll(out, "\n", "")) == "" {
|
|
return // an all-comment render collapses to whitespace; that's fine
|
|
}
|
|
for _, unwanted := range []string{">More products<", ">Products<", "<table", "<h2", "<h6"} {
|
|
if strings.Contains(out, unwanted) {
|
|
t.Errorf("no qualifying products must render no section chrome; found %q in: %s", unwanted, out)
|
|
}
|
|
}
|
|
}
|