Skip to content

feat: assistant catalogue listing page (#15)#72

Merged
martinyde merged 12 commits into
developfrom
feature/issue-15-assistant-catalog
Jun 19, 2026
Merged

feat: assistant catalogue listing page (#15)#72
martinyde merged 12 commits into
developfrom
feature/issue-15-assistant-catalog

Conversation

@martinydeAI

@martinydeAI martinydeAI commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Links to issues

Description

Adds the catalogue / search page at GET /search, the entry point
the mock at
#/search
points to. Ships alongside the new Filter / Catalog / Pagination /
PageHeader / EmptyState component family so the page (and future
filtered-list pages) read as composition instead of bespoke markup.

Application code

  • App\Controller\AssistantCatalogController::show — single action,
    route app_assistant_catalog. Builds a CatalogCriteria from the
    request, hands it to the repository, asks PageMetadata for the
    page-state snapshot, renders. PER_PAGE = 12 per the issue
    checklist. No PHPDoc on the controller per the project rule.
  • App\Catalog\CatalogCriteria (VO) — single typed home for "what
    the user asked for": ?string $q, list<string> $languageModels,
    list<string> $frameworks. Carries fromRequest() named
    constructor, isEmpty(), activeFilters(), toQueryArray(), and
    a private without() that produces the chip-removal query map.
    q is reserved for the search follow-up — the parsing seam is in
    place; only the controller / template / repository touch-points
    are missing.
  • App\Catalog\ActiveFilter (VO) — the four-property record
    activeFilters() yields: type, value, label, and a
    precomputed removeQuery array.
  • App\Pagination\PageMetadata (VO) — total, perPage, page,
    pageCount. fromPaginator() static factory derives total +
    pageCount from a Doctrine Paginator, flooring pageCount to 1
    so empty-result views still render as "page 1 of 1".
  • App\Http\QueryStringList — small reusable helper that reads a
    ?key[]=… parameter into a flat list<string> (dropping
    non-string and empty entries).
  • App\Repository\AssistantRepository::findPaginated(CatalogCriteria, int, int)
    — returns a Paginator filtered by the criteria, ordered by
    id ASC. Adding a new facet (or wiring q) is a single new
    andWhere here plus a one-line read in CatalogCriteria. New
    helpers languageModelFacetCounts() and frameworkFacetCounts()
    power the facet rail; both delegate to a private
    facetCounts(string $field) backbone.
  • templates/base.html.twig — the existing nav.links.catalog
    entry now points at the new route.

Template components (new)

Catalog template went from ~200 lines of mixed markup + utility
classes (only <twig:Eyebrow> reused) to ~70 lines of layout +
composition. New components, organised by scope:

Path Role
components/PageHeader.html.twig eyebrow + h1 + optional lead
components/EmptyState.html.twig tall padded "no results" card
components/Pagination/Nav.html.twig Prev + numbered + Next; takes route, query, metadata
components/Filter/Rail.html.twig sticky left aside with form, submit, reset link
components/Filter/Group.html.twig one <fieldset> per facet
components/Filter/FacetItem.html.twig one tickable row (label + count), composes Form/Checkbox
components/Filter/Chip.html.twig removable pill (link + × icon) for the active-filter rail
components/Filter/ActiveBar.html.twig <ul aria-label="Aktive filtre"> wrapper
components/Catalog/ResultGrid.html.twig result grid wrapper
components/Catalog/AssistantCard.html.twig one result card; reads from an Assistant entity
components/Form/Checkbox.html.twig <input type=checkbox> primitive

Plus an extension to the existing components/Form/Button.html.twig:
new shape (rounded default, pill) and size=sm props so the
filter-rail submit lives inside the existing primitive instead of
duplicating button markup. Login form usages stay on the defaults
so its visual is unchanged.

Active-filter chips loop once over criteria.activeFilters
the per-type duplication a less-typed shape would force is gone
before it ever ships.

Translations

  • translations/messages.da.yaml gains the catalog.* namespace
    (title, eyebrow, heading, result counts singular / plural, empty
    state copy, filter labels / actions, pagination labels).

Frontpage tie-in

  • The frontpage rail's "Se hele kataloget →" link now points at
    app_assistant_catalog (previously #).
  • Frontpage stats key renamed kommuner → organizations
    internally; user-visible label stays "Kommuner". Matches the
    ADR 005 / OrganizationRepository::count() direction the
    controller's TODO already pointed at.

Screenshot of the result

(Optional — screenshot the catalogue with the fixture baseline
applied: 21 entries across two pages, Sprogmodel facet showing
the five language-model buckets, Rammeværk facet showing the
single openwebui bucket.)

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

If your code does not pass all the requirements on the checklist you have to add a comment explaining why this change
should be exempt from the list.

Additional comments or questions

task test-coverage70 tests, 267 assertions, 100 %.
task coding-standards-check → green across PHP, Twig, YAML, JS,
CSS, Markdown, Composer.

Test surface added (six files):

  • tests/Unit/Http/QueryStringListTest.php — 4 cases for the list
    helper.
  • tests/Unit/Pagination/PageMetadataTest.php — 2 cases for
    fromPaginator math.
  • tests/Unit/Catalog/CatalogCriteriaTest.php — 6 cases:
    empty-criteria identity, whitespace-q normalisation, full request
    parse, activeFilters() ordering + label generation,
    removeQuery drops only the targeted value, and removeQuery
    collapses the key when the last value is removed.
  • tests/Integration/Repository/AssistantRepositoryTest.php — five
    new methods covering findPaginated for empty / LM / framework /
    paging branches, plus a fixture-baseline assertion on both facet
    count helpers. The existing persist+find round-trip method was
    refactored to use the fixture baseline per the project rule that
    integration tests must not construct ad-hoc entities.
  • tests/Integration/Controller/AssistantCatalogControllerTest.php
    — five cases: smoke (heading + at least one card), LM filter
    narrows + chip renders, empty state, pagination links + current
    badge, chip-remove URL semantics verified via parse_str
    decoding so the test is robust to Symfony's [] vs [0] query
    encoding choice.

A latent rename Form/InputForm/TextInput (the current
component is text-shaped only) ships as a separate chore: PR
after this one.


Details - AI specificities

Goal and motivation

  • Assistant catalog listing page #15 wants a list/grid of assistants with metadata, links to
    detail pages, pagination, and tests. The mock additionally has
    free-text search, Kommune and Datafølsomhed facets, recent
    searches, and a "Klar til hjemtagning" callout — this PR ships
    the half whose data exists on the entity today and defers the
    rest with explicit follow-up links.
  • The mock is a JS SPA. Reading the upstream
    (itk-dev/research-projects,
    docs/public/projects/ai-bibliotek/mocks/js/views/search.js)
    was the only way to confirm DOM structure and Danish labels;
    the labels here are pulled verbatim from there
    (Filtre, Sprogmodel, Rammeværk, Aktive filtre,
    Nulstil filtre, Ingen assistenter matcher, etc.).
  • The component extraction was driven by review feedback: the
    initial template was almost entirely inline utility classes,
    duplicating active-filter chip markup per facet type. Moving
    to a layered component set (primitives → filter building blocks
    → catalogue domain) preserves the same rendered HTML so all
    existing controller-integration selectors stay valid, while
    laying tracks for future filtered-list pages and the upcoming
    Kommune / Datafølsomhed / q work on this same page.

Design choices

  • CatalogCriteria is the single shared source of "current filter
    state": controller passes it to repository, template loops its
    activeFilters, pagination URLs read its toQueryArray. Adding
    a new filter is a property on the VO + two short lines in the
    factory and helpers; controller and template need no edit. This
    is the lever for the upcoming q + Kommune / Datafølsomhed work.
  • Static named constructors (PageMetadata::fromPaginator,
    CatalogCriteria::fromRequest) over a separate factory service:
    no injected dependency, pure transformation, called once per
    request. An earlier iteration of this PR carried a
    PaginationCalculator service; it was collapsed into
    PageMetadata::fromPaginator because no caller benefited from
    the DI seam.
  • Component layering: primitives (Form/Checkbox, Form/Button),
    filter building blocks (Filter/Rail, Filter/Group,
    Filter/FacetItem composing Form/Checkbox, Filter/Chip,
    Filter/ActiveBar), generic page parts (PageHeader,
    EmptyState, Pagination/Nav), then catalogue-specific
    (Catalog/ResultGrid, Catalog/AssistantCard). Naming avoids
    HTML semantics overloads — no Filter/Option (would clash with
    <option>) and the row is named FacetItem to signal its role.
  • Active-filter chips loop once over criteria.activeFilters in
    the template; the per-type duplication a less-typed shape would
    force is gone before it ever lands.
  • The free-text q seam is wired all the way through (parsed in
    fromRequest, yielded by activeFilters with a quoted label,
    carried in toQueryArray, dropped correctly by without('q', …)).
    Wiring it into the repository is andWhere('a.title LIKE :q OR …') and one <input type="search"> in the template — no
    re-shaping required.

Scope

  • Route + controller + template + criteria/metadata/list VOs +
    repository methods.
  • Eleven new Twig components + one extension (Form/Button).
  • Header nav Katalog link wired to the new route.
  • Frontpage rail link wired to the new route + label refresh.
  • kommuner → organizations identifier rename (label stays
    Kommuner).
  • Danish translations for the new namespace.
  • Test coverage to 100% (six test files).

Non-goals / deferred

  • Free-text q user input. Structurally supported; the
    <input type="search"> and repository clause are intentionally
    not added in this PR.
  • Kommune facet. Depends on ADR 005 /
    #65 (Organization
    entity). Add once Assistant carries an organization
    association.
  • Datafølsomhed facet. No entity field today; track separately
    if the catalogue surfaces sensitivity once that data lands.
  • Recent searches. localStorage convenience in the mock; not
    needed for the v1 server-rendered flow.
  • "Klar til hjemtagning" callout. Tied to export,
    #22.
  • Form/InputForm/TextInput rename. Latent naming fix
    ships as a separate small chore: PR after this one — the
    current Form/Input is implicitly text-shaped only; renaming it
    makes the primitive layer (Form/TextInput, Form/Checkbox,
    future Form/Radio) read consistently.

Conventions applied

  • Controller mirrors AssistantController / FrontpageController:
    small, route-only, no PHPDoc (per CLAUDE.md "Controllers stay
    thin" + "Do not add PHPDoc to controllers"). Query-param parsing
    is delegated to QueryStringList and CatalogCriteria::fromRequest.
  • Service classes and VOs are fully PHPDoc'd (summary,
    description, @param, @return) per the service-class rule.
  • Components follow the existing Path/Pascal.html.twig convention
    with {% props %} declarations. Each composes lower layers
    rather than duplicating their markup.
  • Integration tests use fixture data only — no ad-hoc
    new Assistant(...) in any test on this branch. The existing
    persist+find round-trip in the repository test was refactored
    to use the Borgerservice-vejviser fixture row to comply with
    this rule.
  • |merge([]) is used after |filter in Twig array operations to
    reindex numeric keys so the URL generator emits sensible query
    strings.

Areas needing scrutiny

  • Facet counts are computed against the full catalogue rather than
    the currently-filtered set. The mock does the same; the
    alternative (recompute per filter) would hide reachable buckets.
  • Pagination resets to page 1 implicitly on filter-change because
    the filter form submits without page. Chip-removal links
    inherit the existing page because they reuse
    criteria.toQueryArray — acceptable today (filter changes are
    rare enough that landing on an empty page 2 is mildly
    inconvenient, not broken) but worth noting if reviewers want
    explicit page: 1 reset on chip removal.
  • findPaginated() orders by id ASC for fixture determinism. A
    future "newest first" or relevance-based ordering would belong
    on the repository, not the controller.
  • The chip-remove URL assertion in the controller test uses
    parse_str(parse_url(...)) rather than substring matching. This
    is deliberate — Symfony's URL generator emits framework[0]=…
    instead of framework[]=… (still semantically a list when PHP
    re-parses it), so a string-based assertion would break on the
    encoding flavour rather than the actual behaviour.

Follow-up work intentionally out of scope

  • Free-text ?q=… search input wired to the template + repository
    clause.
  • Kommune facet (after
    #65).
  • Datafølsomhed facet.
  • Form/InputForm/TextInput rename (separate chore: PR
    follows this one).
  • The remaining nav links (Del assistent, Mine assistenter,
    Favoritter, Samlinger) still point at #.

Related

  • Mock.
  • Upstream mock source:
    itk-dev/research-projects/docs/public/projects/ai-bibliotek/mocks/js/views/search.js.
  • ADR 005 /
    #65 for the
    Organization entity that will unblock the Kommune facet.

@martinydeAI martinydeAI added the do-not-merge Block merging until external dependency lands label Jun 17, 2026
martinyde and others added 5 commits June 17, 2026 14:02
…ev/ai-lib into feature/issue-15-assistant-catalog
…agination components

Catalog template was almost entirely bespoke utility classes — only
<twig:Eyebrow> was reused. Extracts the recurring patterns into
named components so subsequent pages (and the upcoming `q` search /
Kommune / Datafølsomhed work on this page) build on a primitive
layer instead of repeating markup.

Generic components (any future page can use):

- PageHeader        — eyebrow + h1 + optional lead.
- EmptyState        — tall padded card with heading + help copy.
- Pagination/Nav    — Prev + numbered + Next; takes route + query
                      map + PageMetadata.

Filter rail building blocks:

- Filter/Rail       — sticky aside with form + submit + reset link.
- Filter/Group      — one fieldset with legend and option list.
- Filter/FacetItem  — one tickable row (label + count), composing
                      the new Form/Checkbox primitive.
- Filter/Chip       — removable pill (link with × icon) for the
                      active-filter rail.
- Filter/ActiveBar  — <ul aria-label="Aktive filtre"> wrapper.

Catalogue domain:

- Catalog/ResultGrid    — result grid wrapper.
- Catalog/AssistantCard — one result card; reads from an Assistant
                          entity. Card upgrades (sensitivity badge,
                          kommune chip via ADR 005 / #65) plug in
                          here.

Form primitive added so we don't overload the existing Form/Input
(which is text-shaped) with checkbox semantics:

- Form/Checkbox — <input type=checkbox> with project styling.

Form/Button gains `shape` (rounded default, pill) and `size=sm` so
the filter-rail submit can live inside the existing primitive
instead of duplicating button markup. Login form usages stay on
the defaults so its visual is unchanged.

templates/catalog/index.html.twig drops from ~200 lines of mixed
markup + utility classes to ~70 lines of layout + composition.
The active-filter chip block is no longer duplicated per filter
type — a single {% for filter in criteria.activeFilters %} loop
drives the rail.

Tests, coding standards, and the 100% coverage gate stay green —
the existing AssistantCatalogControllerTest selectors continue to
match because rendered HTML structure is preserved.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Yde Granath <yde001@gmail.com>
@martinyde martinyde removed the do-not-merge Block merging until external dependency lands label Jun 17, 2026
@martinyde martinyde requested a review from tuj June 18, 2026 07:14
@martinyde martinyde merged commit fe0d7c5 into develop Jun 19, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assistant catalog listing page

3 participants