feat: assistant catalogue listing page (#15)#72
Merged
Conversation
…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>
4 tasks
4 tasks
tuj
approved these changes
Jun 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Links to issues
(delivers the route, listing, facets with active-filter chips,
empty state, numbered pagination, and the header-nav link —
Kommune and Datafølsomhed facets remain deferred until ADR 005 /
Decide on Organization entity, metadata, and assistant-creation derivation flow #65 widens the entity, and free-text search is wired structurally
but not yet exposed in the UI by request).
develop(feat: base assistant detail page (#20) #68 base detail page + fixtures,feat: add base Assistant entity (#14) #67 base entity, feat: add user authentication (#2) #59 Doctrine + PHPUnit). No do-not-merge chain
remains.
Description
Adds the catalogue / search page at
GET /search, the entry pointthe mock at
#/searchpoints 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 aCatalogCriteriafrom therequest, hands it to the repository, asks
PageMetadatafor thepage-state snapshot, renders.
PER_PAGE = 12per the issuechecklist. No PHPDoc on the controller per the project rule.
App\Catalog\CatalogCriteria(VO) — single typed home for "whatthe user asked for":
?string $q,list<string> $languageModels,list<string> $frameworks. CarriesfromRequest()namedconstructor,
isEmpty(),activeFilters(),toQueryArray(), anda private
without()that produces the chip-removal query map.qis reserved for the search follow-up — the parsing seam is inplace; only the controller / template / repository touch-points
are missing.
App\Catalog\ActiveFilter(VO) — the four-property recordactiveFilters()yields:type,value,label, and aprecomputed
removeQueryarray.App\Pagination\PageMetadata(VO) —total,perPage,page,pageCount.fromPaginator()static factory derives total +pageCount from a Doctrine
Paginator, flooringpageCountto 1so empty-result views still render as "page 1 of 1".
App\Http\QueryStringList— small reusable helper that reads a?key[]=…parameter into a flatlist<string>(droppingnon-string and empty entries).
App\Repository\AssistantRepository::findPaginated(CatalogCriteria, int, int)— returns a
Paginatorfiltered by the criteria, ordered byid ASC. Adding a new facet (or wiringq) is a single newandWherehere plus a one-line read inCatalogCriteria. Newhelpers
languageModelFacetCounts()andframeworkFacetCounts()power the facet rail; both delegate to a private
facetCounts(string $field)backbone.templates/base.html.twig— the existingnav.links.catalogentry 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:
components/PageHeader.html.twigcomponents/EmptyState.html.twigcomponents/Pagination/Nav.html.twigroute,query,metadatacomponents/Filter/Rail.html.twigcomponents/Filter/Group.html.twig<fieldset>per facetcomponents/Filter/FacetItem.html.twigForm/Checkboxcomponents/Filter/Chip.html.twigcomponents/Filter/ActiveBar.html.twig<ul aria-label="Aktive filtre">wrappercomponents/Catalog/ResultGrid.html.twigcomponents/Catalog/AssistantCard.html.twigAssistantentitycomponents/Form/Checkbox.html.twig<input type=checkbox>primitivePlus an extension to the existing
components/Form/Button.html.twig:new
shape(roundeddefault,pill) andsize=smprops so thefilter-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.yamlgains thecatalog.*namespace(title, eyebrow, heading, result counts singular / plural, empty
state copy, filter labels / actions, pagination labels).
Frontpage tie-in
app_assistant_catalog(previously#).kommuner → organizationsinternally; user-visible label stays
"Kommuner". Matches theADR 005 /
OrganizationRepository::count()direction thecontroller'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
openwebuibucket.)Checklist
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-coverage→ 70 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 listhelper.
tests/Unit/Pagination/PageMetadataTest.php— 2 cases forfromPaginatormath.tests/Unit/Catalog/CatalogCriteriaTest.php— 6 cases:empty-criteria identity, whitespace-q normalisation, full request
parse,
activeFilters()ordering + label generation,removeQuerydrops only the targeted value, andremoveQuerycollapses the key when the last value is removed.
tests/Integration/Repository/AssistantRepositoryTest.php— fivenew methods covering
findPaginatedfor 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_strdecoding so the test is robust to Symfony's
[]vs[0]queryencoding choice.
A latent rename
Form/Input→Form/TextInput(the currentcomponent is text-shaped only) ships as a separate
chore:PRafter this one.
Details - AI specificities
Goal and motivation
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.
(
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.).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 /
qwork on this same page.Design choices
CatalogCriteriais the single shared source of "current filterstate": controller passes it to repository, template loops its
activeFilters, pagination URLs read itstoQueryArray. Addinga 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.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
PaginationCalculatorservice; it was collapsed intoPageMetadata::fromPaginatorbecause no caller benefited fromthe DI seam.
Form/Checkbox,Form/Button),filter building blocks (
Filter/Rail,Filter/Group,Filter/FacetItemcomposingForm/Checkbox,Filter/Chip,Filter/ActiveBar), generic page parts (PageHeader,EmptyState,Pagination/Nav), then catalogue-specific(
Catalog/ResultGrid,Catalog/AssistantCard). Naming avoidsHTML semantics overloads — no
Filter/Option(would clash with<option>) and the row is namedFacetItemto signal its role.criteria.activeFiltersinthe template; the per-type duplication a less-typed shape would
force is gone before it ever lands.
qseam is wired all the way through (parsed infromRequest, yielded byactiveFilterswith a quoted label,carried in
toQueryArray, dropped correctly bywithout('q', …)).Wiring it into the repository is
andWhere('a.title LIKE :q OR …')and one<input type="search">in the template — nore-shaping required.
Scope
repository methods.
Form/Button).Kataloglink wired to the new route.kommuner → organizationsidentifier rename (label staysKommuner).Non-goals / deferred
quser input. Structurally supported; the<input type="search">and repository clause are intentionallynot added in this PR.
#65 (Organization
entity). Add once
Assistantcarries anorganizationassociation.
if the catalogue surfaces sensitivity once that data lands.
needed for the v1 server-rendered flow.
#22.
Form/Input→Form/TextInputrename. Latent naming fixships as a separate small
chore:PR after this one — thecurrent
Form/Inputis implicitly text-shaped only; renaming itmakes the primitive layer (
Form/TextInput,Form/Checkbox,future
Form/Radio) read consistently.Conventions applied
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
QueryStringListandCatalogCriteria::fromRequest.description,
@param,@return) per the service-class rule.Path/Pascal.html.twigconventionwith
{% props %}declarations. Each composes lower layersrather than duplicating their markup.
new Assistant(...)in any test on this branch. The existingpersist+find round-trip in the repository test was refactored
to use the
Borgerservice-vejviserfixture row to comply withthis rule.
|merge([])is used after|filterin Twig array operations toreindex numeric keys so the URL generator emits sensible query
strings.
Areas needing scrutiny
the currently-filtered set. The mock does the same; the
alternative (recompute per filter) would hide reachable buckets.
the filter form submits without
page. Chip-removal linksinherit the existing
pagebecause they reusecriteria.toQueryArray— acceptable today (filter changes arerare enough that landing on an empty page 2 is mildly
inconvenient, not broken) but worth noting if reviewers want
explicit
page: 1reset on chip removal.findPaginated()orders byid ASCfor fixture determinism. Afuture "newest first" or relevance-based ordering would belong
on the repository, not the controller.
parse_str(parse_url(...))rather than substring matching. Thisis deliberate — Symfony's URL generator emits
framework[0]=…instead of
framework[]=…(still semantically a list when PHPre-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
?q=…search input wired to the template + repositoryclause.
#65).
Form/Input→Form/TextInputrename (separatechore:PRfollows this one).
Del assistent,Mine assistenter,Favoritter,Samlinger) still point at#.Related
itk-dev/research-projects/docs/public/projects/ai-bibliotek/mocks/js/views/search.js.#65 for the
Organization entity that will unblock the Kommune facet.