Remove server-side search filtering and implement client-side filtering

This commit moves project search filtering from the server to the
client.
Changes include:

- Remove `Query` field from `ListTablosInput` and related handlers
- Add French date formatting for project cards
- Convert search form to client-side filter with data attributes
- Add empty state message for no search results
- Update button border-radius from 0 to 0.7rem
- Increase air.toml build command to include Tailwind CSS generation
This commit is contained in:
Arthur Belleville 2026-05-10 13:53:23 +02:00
parent 8bcf81a3f1
commit c780dd1625
No known key found for this signature in database
14 changed files with 478 additions and 395 deletions

View file

@ -4,11 +4,11 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ."
cmd = "go run ./cmd/buildstyles && pnpm exec tailwindcss -i tailwind.input.css -o static/tailwind.css --cwd . && go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ."
entrypoint = ["./tmp/main"]
include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"]
exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"]
exclude_regex = ["_templ\\.go$"]
exclude_regex = ["_templ\\.go$", "static/(styles|tailwind)\\.css$"]
delay = 200
stop_on_error = true
send_interrupt = true

View file

@ -164,7 +164,6 @@ func (r *PostgresAuthRepository) CreateTablo(ctx context.Context, input tablomod
func (r *PostgresAuthRepository) ListTablos(ctx context.Context, input tablomodel.ListInput) ([]tablomodel.Record, error) {
params := sqlcdb.ListTablosParams{
OwnerID: input.OwnerID,
Query: nullableText(strings.TrimSpace(input.Query)),
Status: nullableStatus(input.Status),
}

View file

@ -44,6 +44,5 @@ type UpdateInput struct {
type ListInput struct {
OwnerID uuid.UUID
Query string
Status *Status
}

View file

@ -0,0 +1,13 @@
package dates
import (
"fmt"
"time"
)
var frenchMonths = [...]string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."}
func FormatFrenchDate(value time.Time) string {
month := frenchMonths[int(value.Month())-1]
return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year())
}

View file

@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/dates"
"xtablo-backend/internal/web/views"
)
@ -37,16 +38,11 @@ type ListTablosInput = tablomodel.ListInput
type TablosPageState struct {
View string
Query string
Status string
ModalKind string
EditingTabloID string
}
func normalizeTabloQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func parseTablosPageState(values interface {
Get(string) string
}) TablosPageState {
@ -64,7 +60,6 @@ func parseTablosPageState(values interface {
return TablosPageState{
View: view,
Query: strings.TrimSpace(values.Get("q")),
Status: status,
ModalKind: normalizedModalKind(strings.TrimSpace(values.Get("modal"))),
}
@ -329,7 +324,6 @@ func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloR
return views.NewTablosPageViewModel(
user.DisplayName,
state.View,
state.Query,
state.Status,
state.ModalKind,
state.EditingTabloID,
@ -343,7 +337,6 @@ func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloR
func listTablosForState(ctx context.Context, repo AuthRepository, ownerID uuid.UUID, state TablosPageState) ([]TabloRecord, error) {
return repo.ListTablos(ctx, ListTablosInput{
OwnerID: ownerID,
Query: state.Query,
Status: state.statusFilter(),
})
}
@ -396,7 +389,6 @@ func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosI
r.mu.RLock()
defer r.mu.RUnlock()
query := normalizeTabloQuery(input.Query)
var tablos []TabloRecord
for _, tablo := range r.tablos {
@ -409,9 +401,6 @@ func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosI
if input.Status != nil && tablo.Status != *input.Status {
continue
}
if query != "" && !strings.Contains(strings.ToLower(tablo.Name), query) {
continue
}
tablos = append(tablos, tablo)
}
@ -505,9 +494,6 @@ func buildStatefulRequestURL(path string, state TablosPageState) string {
values := url.Values{}
values.Set("view", state.View)
values.Set("status", state.Status)
if strings.TrimSpace(state.Query) != "" {
values.Set("q", strings.TrimSpace(state.Query))
}
encoded := values.Encode()
if encoded == "" {
return path
@ -538,13 +524,11 @@ func tabloIconPresentation(name string) (string, string, string, string) {
}
func formatFrenchDate(value time.Time) string {
months := []string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."}
month := months[int(value.Month())-1]
return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year())
return dates.FormatFrenchDate(value)
}
func formatCardDate(value time.Time) string {
return value.Format("Jan 02, 2006")
return dates.FormatFrenchDate(value)
}
func projectInitial(name string) string {

View file

@ -7,6 +7,7 @@ import (
"net/url"
"strings"
"testing"
"time"
)
func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) {
@ -54,7 +55,7 @@ func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) {
}
}
func TestInMemoryTablosListFiltersBySearchAndStatus(t *testing.T) {
func TestInMemoryTablosListFiltersByStatus(t *testing.T) {
repo := NewInMemoryAuthRepository()
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
@ -81,15 +82,14 @@ func TestInMemoryTablosListFiltersBySearchAndStatus(t *testing.T) {
tablos, err := repo.ListTablos(context.Background(), ListTablosInput{
OwnerID: user.ID,
Query: "delivery",
Status: &[]TabloStatus{TabloStatusInProgress}[0],
})
if err != nil {
t.Fatalf("list filtered tablos: %v", err)
t.Fatalf("list status-filtered tablos: %v", err)
}
if len(tablos) != 1 {
t.Fatalf("expected 1 filtered tablo, got %d", len(tablos))
t.Fatalf("expected 1 status-filtered tablo, got %d", len(tablos))
}
if tablos[0].ID != expectedTablo.ID {
@ -177,7 +177,7 @@ func TestGetTablosPageDefaultsToGridAndAllStatus(t *testing.T) {
}
}
func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) {
func TestGetTablosPageIgnoresSearchQueryParamAndLeavesFilteringToClient(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
@ -192,10 +192,10 @@ func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) {
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Alpha Draft",
Status: TabloStatusTodo,
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create todo tablo: %v", err)
t.Fatalf("create first in-progress tablo: %v", err)
}
_, err = repo.CreateTablo(context.Background(), CreateTabloInput{
@ -218,11 +218,54 @@ func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) {
}
body := rec.Body.String()
if !strings.Contains(body, "Beta Delivery") {
t.Fatalf("expected filtered tablo to be visible, got %q", body)
if !strings.Contains(body, "Alpha Draft") {
t.Fatalf("expected non-matching tablo to stay visible for client filtering, got %q", body)
}
if strings.Contains(body, "Alpha Draft") {
t.Fatalf("expected non-matching tablo to be filtered out, got %q", body)
if !strings.Contains(body, "Beta Delivery") {
t.Fatalf("expected matching tablo to be visible, got %q", body)
}
}
func TestGetTablosPageRendersClientSideProjectFilterHooks(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Searchable Project",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create searchable tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
body := rec.Body.String()
for _, want := range []string{
`data-project-filter-root`,
`data-project-filter-input`,
`data-project-filter-item`,
`window.xtabloProjectFilterInitialized`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected client-side filter markup %q, got %q", want, body)
}
}
if strings.Contains(body, `name="q"`) {
t.Fatalf("expected search query field to be removed from markup, got %q", body)
}
}
@ -443,6 +486,13 @@ func TestGetTablosPageGridUsesProjectDateRowMarkup(t *testing.T) {
}
}
func TestFormatCardDateUsesFrenchMonthNames(t *testing.T) {
got := formatCardDate(time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC))
if got != "10 mai 2026" {
t.Fatalf("expected French card date label, got %q", got)
}
}
func TestGetTablosPageGridUsesProjectCardMarkup(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)

View file

@ -1,7 +1,7 @@
.ui-button {
align-items: center;
border: 0;
border-radius: 0rem;
border-radius: 0.7rem;
cursor: pointer;
display: inline-flex;
font-weight: 600;

View file

@ -33,6 +33,9 @@ func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) {
if project.EditRequestURL != "/tablos/11111111-1111-1111-1111-111111111111/edit" {
t.Fatalf("expected edit request url to be set, got %q", project.EditRequestURL)
}
if project.CardDateLabel != "10 mai 2026" {
t.Fatalf("expected French card date label, got %q", project.CardDateLabel)
}
}
func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) {

View file

@ -7,6 +7,7 @@ import (
"github.com/a-h/templ"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/dates"
)
const overviewProjectsPreviewLimit = 6
@ -119,7 +120,7 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView {
StatusTone: statusTone,
Initial: projectInitial(tablo.Name),
Accent: overviewProjectAccent(tablo.Name),
CardDateLabel: tablo.CreatedAt.Format("Jan 02, 2006"),
CardDateLabel: dates.FormatFrenchDate(tablo.CreatedAt),
Progress: progress,
ProgressLabel: progressPercentLabel(progress),
DeleteRequestURL: "/tablos/" + tablo.ID.String(),

View file

@ -3,7 +3,7 @@ package views
import "xtablo-backend/internal/web/ui"
templ TablosPageContent(vm TablosPageViewModel) {
<div class="px-4 pt-8 pb-6">
<div class="px-4 pt-8 pb-6" data-project-filter-root>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Mes Projets</h1>
@ui.Button(ui.ButtonProps{
@ -49,27 +49,18 @@ templ TablosPageContent(vm TablosPageViewModel) {
</a>
</div>
<div class="flex flex-col md:flex-row gap-4 mb-6">
<form
class="relative md:w-[350px]"
hx-get={ vm.SearchHref() }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
hx-trigger="input changed delay:300ms from:input[name='q']"
>
<input type="hidden" name="view" value={ vm.View }/>
<input type="hidden" name="status" value={ vm.Status }/>
<div class="relative md:w-[350px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none">
@ActionIcon("search")
</span>
<input
name="q"
value={ vm.Query }
data-project-filter-input
placeholder="Rechercher..."
autocomplete="off"
class="w-full pl-10 pr-4 py-3 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
type="text"
/>
</form>
</div>
<div class="flex items-center gap-2 flex-wrap">
@StatusPill(vm, "all", "Tous")
@StatusPill(vm, "todo", "Pas commencé")
@ -79,19 +70,27 @@ templ TablosPageContent(vm TablosPageViewModel) {
</div>
if vm.HasTablos() {
if vm.IsGridView() {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6" data-project-filter-results>
for _, tablo := range vm.Tablos {
@TabloGridCard(tablo)
}
</div>
} else {
<div class="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-x-auto -mx-4 sm:mx-0">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-x-auto -mx-4 sm:mx-0" data-project-filter-results>
@ui.Table(ui.TableProps{
Head: TabloListHead(),
Body: TabloListBody(vm.Tablos),
})
</div>
}
<div
data-project-filter-empty
hidden
class="rounded-xl border border-dashed border-[#EAECF0] bg-white/80 px-6 py-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-400"
>
Aucun projet ne correspond à votre recherche.
</div>
@InitProjectFilterScript()
} else {
@ui.EmptyState(ui.EmptyStateProps{
Title: "Aucun projet trouvé",
@ -177,7 +176,7 @@ templ TabloGridCard(tablo TabloCardView) {
}
templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) {
<article class="project-card" style={ projectColorVariableStyle(tablo.Color) } { attrs... }>
<article class="project-card" style={ projectColorVariableStyle(tablo.Color) } data-project-filter-item data-project-name={ tablo.Name } { attrs... }>
<div class="project-card-top">
@ui.Badge(ui.BadgeProps{
Label: tablo.StatusLabel,
@ -211,7 +210,7 @@ templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) {
}
templ TabloListRow(tablo TabloCardView) {
<tr class="border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer" style={ projectColorVariableStyle(tablo.Color) }>
<tr class="border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer" style={ projectColorVariableStyle(tablo.Color) } data-project-filter-item data-project-name={ tablo.Name }>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="project-list-icon w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden [&>svg]:w-4 [&>svg]:h-4">
@ -256,6 +255,56 @@ templ CreateTabloModal(vm TablosPageViewModel) {
})
}
templ InitProjectFilterScript() {
<script>
(function () {
function normalizeProjectFilterValue(value) {
return (value || "").toLowerCase().trim();
}
function applyProjectFilter(root) {
if (!root) return;
var input = root.querySelector("[data-project-filter-input]");
var results = root.querySelector("[data-project-filter-results]");
var empty = root.querySelector("[data-project-filter-empty]");
if (!input || !results || !empty) return;
var query = normalizeProjectFilterValue(input.value);
var visibleCount = 0;
root.querySelectorAll("[data-project-filter-item]").forEach(function (item) {
var name = normalizeProjectFilterValue(item.getAttribute("data-project-name") || item.textContent);
var matches = query === "" || name.indexOf(query) !== -1;
item.hidden = !matches;
if (matches) {
item.removeAttribute("hidden");
visibleCount += 1;
} else {
item.setAttribute("hidden", "");
}
});
var hasVisibleResults = visibleCount > 0;
results.hidden = !hasVisibleResults;
empty.hidden = hasVisibleResults;
if (hasVisibleResults) {
results.removeAttribute("hidden");
empty.setAttribute("hidden", "");
} else {
results.setAttribute("hidden", "");
empty.removeAttribute("hidden");
}
}
function handleProjectFilterInput(event) {
var input = event.target.closest("[data-project-filter-input]");
if (!input) return;
applyProjectFilter(input.closest("[data-project-filter-root]"));
}
if (!window.xtabloProjectFilterInitialized) {
document.addEventListener("input", handleProjectFilterInput);
window.xtabloProjectFilterInitialized = true;
}
document.querySelectorAll("[data-project-filter-root]").forEach(applyProjectFilter);
})();
</script>
}
templ TabloListHead() {
<tr class="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
@ -281,7 +330,6 @@ templ CreateTabloModalBody(vm TablosPageViewModel) {
>
<input type="hidden" name="view" value={ vm.View }/>
<input type="hidden" name="status" value={ vm.Status }/>
<input type="hidden" name="q" value={ vm.Query }/>
<input type="hidden" name="modal" value="create"/>
if vm.ErrorMessage != "" {
<div class="mb-1 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{ vm.ErrorMessage }</div>
@ -373,7 +421,6 @@ templ EditTabloModalBody(vm TablosPageViewModel) {
>
<input type="hidden" name="view" value={ vm.View }/>
<input type="hidden" name="status" value={ vm.Status }/>
<input type="hidden" name="q" value={ vm.Query }/>
if vm.ErrorMessage != "" {
<div class="mb-1 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{ vm.ErrorMessage }</div>
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
package views
import (
"fmt"
"net/url"
"strings"
@ -34,7 +33,6 @@ type TabloCardView struct {
type TablosPageViewModel struct {
DisplayName string
View string
Query string
Status string
ModalKind string
EditingTabloID string
@ -44,11 +42,10 @@ type TablosPageViewModel struct {
Tablos []TabloCardView
}
func NewTablosPageViewModel(displayName string, view string, query string, status string, modalKind string, editingTabloID string, formName string, formColor string, errorMessage string, tablos []TabloCardView) TablosPageViewModel {
func NewTablosPageViewModel(displayName string, view string, status string, modalKind string, editingTabloID string, formName string, formColor string, errorMessage string, tablos []TabloCardView) TablosPageViewModel {
return TablosPageViewModel{
DisplayName: displayName,
View: normalizedView(view),
Query: strings.TrimSpace(query),
Status: normalizedStatus(status),
ModalKind: normalizedModalKind(modalKind),
EditingTabloID: strings.TrimSpace(editingTabloID),
@ -91,22 +88,6 @@ func (vm TablosPageViewModel) ViewHref(view string) string {
return "/tablos?" + values.Encode()
}
func (vm TablosPageViewModel) SearchHref() string {
return "/tablos"
}
func (vm TablosPageViewModel) HiddenStateFields() map[string]string {
return map[string]string{
"view": vm.View,
"status": vm.Status,
"q": vm.Query,
}
}
func (vm TablosPageViewModel) SearchValues() string {
return fmt.Sprintf("view=%s&status=%s", vm.View, vm.Status)
}
func (vm TablosPageViewModel) CreateModalHref() string {
values := vm.baseValues()
values.Set("modal", "create")
@ -127,10 +108,6 @@ func (vm TablosPageViewModel) EditSubmitHref() string {
return "/tablos/" + vm.EditingTabloID
}
func (vm TablosPageViewModel) HasSearch() bool {
return vm.Query != ""
}
func normalizedView(view string) string {
if view == "list" {
return "list"
@ -171,9 +148,6 @@ func (vm TablosPageViewModel) baseValues() url.Values {
values := url.Values{}
values.Set("view", vm.View)
values.Set("status", vm.Status)
if vm.Query != "" {
values.Set("q", vm.Query)
}
return values
}

View file

@ -394,7 +394,7 @@ input {
.ui-button {
align-items: center;
border: 0;
border-radius: 0rem;
border-radius: 0.7rem;
cursor: pointer;
display: inline-flex;
font-weight: 600;

View file

@ -264,6 +264,10 @@
border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px;
}
.border-dashed {
--tw-border-style: dashed;
border-style: dashed;
}
.border-\[\#DB9729\] {
border-color: #DB9729;
}
@ -321,6 +325,14 @@
.bg-white {
background-color: var(--color-white);
}
.bg-white\/80 {
background-color: color-mix(in srgb, #fff 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-white) 80%, transparent);
}
}
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
@ -336,6 +348,9 @@
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.py-10 {
padding-block: calc(var(--spacing) * 10);
}
.pt-8 {
padding-top: calc(var(--spacing) * 8);
}
@ -351,6 +366,9 @@
.pl-10 {
padding-left: calc(var(--spacing) * 10);
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
@ -558,6 +576,16 @@
background-color: var(--color-gray-800);
}
}
.dark\:bg-gray-800\/60 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 60%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-gray-800) 60%, transparent);
}
}
}
}
.dark\:bg-gray-800\/80 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 80%, transparent);