feat(browse): recherche unifiée dossiers + APIs + fix icônes DSFR
- Recherche simultanée dans les dossiers (filtrage client-side) et les APIs (backend) via forkJoin — affiche dossiers en tuiles puis APIs en cartes, avec labels "Dossiers"/"APIs" si les deux ont des résultats - Loader instantané dès la frappe (avant le debounce 400ms) - Fix angular.json : ajout du mapping dist/icons/ → assets/dsfr/icons/ pour servir les SVG référencés par le CSS DSFR (tous les icônes étaient cassés car le chemin relatif ../../icons/ donnait 404) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -36,6 +36,11 @@
|
|||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
||||||
"output": "assets/dsfr/utility"
|
"output": "assets/dsfr/utility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@gouvfr/dsfr/dist/icons",
|
||||||
|
"output": "assets/dsfr/icons"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterOutlet, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [RouterOutlet, RouterLink],
|
||||||
template: `
|
template: `
|
||||||
<header role="banner" class="fr-header">
|
<header role="banner" class="fr-header">
|
||||||
<div class="fr-header__body">
|
<div class="fr-header__body">
|
||||||
@@ -17,7 +17,7 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-header__service">
|
<div class="fr-header__service">
|
||||||
<a href="/" routerLink="/browse" title="Accueil — Datacat">
|
<a href="/" routerLink="/" title="Accueil — Datacat">
|
||||||
<p class="fr-header__service-title">Datacat</p>
|
<p class="fr-header__service-title">Datacat</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
||||||
@@ -26,24 +26,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fr-header__menu">
|
|
||||||
<div class="fr-container">
|
|
||||||
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
|
|
||||||
<ul class="fr-nav__list">
|
|
||||||
<li class="fr-nav__item">
|
|
||||||
<a
|
|
||||||
class="fr-nav__link"
|
|
||||||
routerLink="/browse"
|
|
||||||
routerLinkActive="fr-nav__link--active"
|
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
|
||||||
>
|
|
||||||
Parcourir
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Routes } from '@angular/router';
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'browse',
|
loadComponent: () =>
|
||||||
pathMatch: 'full',
|
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'browse',
|
path: 'browse',
|
||||||
@@ -38,6 +38,6 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: 'browse',
|
redirectTo: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ActivatedRoute } from '@angular/router';
|
|||||||
import { CategoryService } from '../../core/category.service';
|
import { CategoryService } from '../../core/category.service';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
|
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-browse',
|
selector: 'app-browse',
|
||||||
@@ -26,7 +27,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
<ol class="fr-breadcrumb__list">
|
<ol class="fr-breadcrumb__list">
|
||||||
<li>
|
<li>
|
||||||
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
|
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
|
||||||
</li>
|
</li>
|
||||||
@for (crumb of ancestors(); track crumb.id) {
|
@for (crumb of ancestors(); track crumb.id) {
|
||||||
<li>
|
<li>
|
||||||
@@ -55,7 +56,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
|
|
||||||
<!-- Barre de recherche -->
|
<!-- Barre de recherche -->
|
||||||
<div class="fr-search-bar fr-mb-3w">
|
<div class="fr-search-bar fr-mb-3w">
|
||||||
<label class="fr-label" for="search-api">Rechercher une API</label>
|
<label class="fr-label" for="search-api">Rechercher</label>
|
||||||
<input id="search-api" class="fr-input" type="search" placeholder="Titre, description..."
|
<input id="search-api" class="fr-input" type="search" placeholder="Titre, description..."
|
||||||
[value]="searchQuery()"
|
[value]="searchQuery()"
|
||||||
(input)="onSearchChange($any($event.target).value)" />
|
(input)="onSearchChange($any($event.target).value)" />
|
||||||
@@ -77,7 +78,6 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
@if (!loading() && browseData()) {
|
@if (!loading() && browseData()) {
|
||||||
<!-- Sous-catégories -->
|
<!-- Sous-catégories -->
|
||||||
@if (browseData()!.subcategories.length > 0) {
|
@if (browseData()!.subcategories.length > 0) {
|
||||||
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
|
|
||||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||||
@for (cat of browseData()!.subcategories; track cat.id) {
|
@for (cat of browseData()!.subcategories; track cat.id) {
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
@@ -115,7 +115,6 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
|
|
||||||
<!-- APIs -->
|
<!-- APIs -->
|
||||||
@if (browseData()!.apis.length > 0) {
|
@if (browseData()!.apis.length > 0) {
|
||||||
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
|
|
||||||
<div class="fr-grid-row fr-grid-row--gutters">
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
@for (api of browseData()!.apis; track api.id) {
|
@for (api of browseData()!.apis; track api.id) {
|
||||||
<div class="fr-col-12 fr-col-md-4">
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
@@ -154,39 +153,74 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem
|
|||||||
<p class="fr-callout__text">Chargement...</p>
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!searchLoading() && searchResults().length === 0) {
|
@if (!searchLoading()) {
|
||||||
<p class="fr-text--lead">Aucune API trouvée pour « {{ searchQuery() }} »</p>
|
<!-- Dossiers -->
|
||||||
}
|
@if (searchCategoryResults().length > 0) {
|
||||||
<div class="fr-grid-row fr-grid-row--gutters">
|
@if (searchResults().length > 0) {
|
||||||
@for (api of searchResults(); track api.id) {
|
<p class="fr-text--sm fr-text--bold fr-mb-2w">Dossiers</p>
|
||||||
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
|
}
|
||||||
<div class="fr-card fr-card--sm fr-enlarge-link">
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||||
<div class="fr-card__body">
|
@for (cat of searchCategoryResults(); track cat.id) {
|
||||||
<div class="fr-card__content">
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
<h3 class="fr-card__title">
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
<div class="fr-tile__body">
|
||||||
</h3>
|
<div class="fr-tile__content">
|
||||||
@if (api.description) {
|
<h3 class="fr-tile__title">
|
||||||
<p class="fr-card__desc">{{ api.description }}</p>
|
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
||||||
}
|
</h3>
|
||||||
<div class="fr-badges-group fr-mt-1w">
|
@if (cat.description) {
|
||||||
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
<p class="fr-tile__desc">{{ cat.description }}</p>
|
||||||
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
@if (searchTotalPages() > 1) {
|
<!-- APIs -->
|
||||||
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
|
@if (searchResults().length > 0) {
|
||||||
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
|
@if (searchCategoryResults().length > 0) {
|
||||||
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
|
<p class="fr-text--sm fr-text--bold fr-mb-2w">APIs</p>
|
||||||
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
|
}
|
||||||
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
|
@for (api of searchResults(); track api.id) {
|
||||||
</div>
|
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
|
||||||
|
<div class="fr-card fr-card--sm fr-enlarge-link">
|
||||||
|
<div class="fr-card__body">
|
||||||
|
<div class="fr-card__content">
|
||||||
|
<h3 class="fr-card__title">
|
||||||
|
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||||
|
</h3>
|
||||||
|
@if (api.description) {
|
||||||
|
<p class="fr-card__desc">{{ api.description }}</p>
|
||||||
|
}
|
||||||
|
<div class="fr-badges-group fr-mt-1w">
|
||||||
|
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
||||||
|
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (searchTotalPages() > 1) {
|
||||||
|
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
|
||||||
|
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
|
||||||
|
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
|
||||||
|
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
|
||||||
|
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
|
||||||
|
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Aucun résultat -->
|
||||||
|
@if (searchCategoryResults().length === 0 && searchResults().length === 0) {
|
||||||
|
<p class="fr-text--lead">Aucun résultat pour « {{ searchQuery() }} »</p>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +289,7 @@ export class BrowseComponent implements OnInit {
|
|||||||
/* Signaux recherche */
|
/* Signaux recherche */
|
||||||
searchQuery = signal('');
|
searchQuery = signal('');
|
||||||
searchResults = signal<ApiEntryListItem[]>([]);
|
searchResults = signal<ApiEntryListItem[]>([]);
|
||||||
|
searchCategoryResults = signal<Category[]>([]);
|
||||||
searchTotal = signal(0);
|
searchTotal = signal(0);
|
||||||
searchPage = signal(1);
|
searchPage = signal(1);
|
||||||
searchLoading = signal(false);
|
searchLoading = signal(false);
|
||||||
@@ -373,20 +408,32 @@ export class BrowseComponent implements OnInit {
|
|||||||
this.searchPage.set(1);
|
this.searchPage.set(1);
|
||||||
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
if (this.searchDebounce) clearTimeout(this.searchDebounce);
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
|
this.searchLoading.set(true);
|
||||||
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
|
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
|
||||||
|
} else {
|
||||||
|
this.searchLoading.set(false);
|
||||||
|
this.searchCategoryResults.set([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSearch() {
|
loadSearch() {
|
||||||
this.searchLoading.set(true);
|
this.searchLoading.set(true);
|
||||||
this.apiService.list({
|
const q = this.searchQuery().trim().toLowerCase();
|
||||||
search: this.searchQuery(),
|
|
||||||
page: this.searchPage(),
|
forkJoin({
|
||||||
limit: this.searchLimit,
|
apis: this.apiService.list({ search: this.searchQuery(), page: this.searchPage(), limit: this.searchLimit }),
|
||||||
|
categories: this.categoryService.list(),
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (res) => {
|
next: ({ apis, categories }) => {
|
||||||
this.searchResults.set(res.items);
|
this.searchResults.set(apis.items);
|
||||||
this.searchTotal.set(res.total);
|
this.searchTotal.set(apis.total);
|
||||||
|
this.searchCategoryResults.set(
|
||||||
|
categories.items.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
(c.description ?? '').toLowerCase().includes(q),
|
||||||
|
),
|
||||||
|
);
|
||||||
this.searchLoading.set(false);
|
this.searchLoading.set(false);
|
||||||
},
|
},
|
||||||
error: () => this.searchLoading.set(false),
|
error: () => this.searchLoading.set(false),
|
||||||
|
|||||||
Reference in New Issue
Block a user