Les progrès de la barre de filtrage (et la gamme de dates Shenanigans) (Français (French))

Les progrès de la barre de filtrage (et la gamme de dates Shenanigans)

Monday, 10 November 2025

//

12 minute read

Présentation

Au cours des derniers jours, j'ai été en train de marteler à la nouvelle barre de filtre de blog: la sélection de la langue, la commande, et un sélectionneur de gamme de dates qui essaie très dur d'être intelligent (parfois trop intelligent).

Ce post marche à travers ce que j'ai construit, pourquoi quelques choses sont allées de côté (dates sans se comporter, met en évidence pas rafraîchissant), et comment je les ai réparés.

Beaucoup de code source et quelques diagrammes de sirène pour montrer le flux.

Rappelez-vous que ce site est un travail en cours, ce genre de choses va se produire lorsque vous mangez-votre-dogfood!

  • Qu'est-ce qui a changé ?
  • Voici le haut niveau: J'ai ajouté une barre de filtre appropriée à l'index du blog qui comprend:
  • Choisissez la langue qui préserve vos filtres actuels et re-demande la liste via HTMX
  • Trier la sélection de l'ordre (date/titre asc/desc) qui préserve également le contexte
  • Un sélecteur de plage de dates (Flatpickr en mode range) avec des points forts de jour pilotés par le serveur

L'état de l'URL est toujours maintenu en synchronisation (travaille en amont/avant, travail de liens profonds)

  • La mise en cache du serveur varie maintenant correctement selon les paramètres du filtreNouveau dans cette mise à jour (The Big Fix!)
  • Filtres de visibilité: Les messages peuvent maintenant être cachés, programmés pour la publication future, ou épinglés vers le haut/blog/date-rangeÉtendue de la date
  • : NouveauAPI retourne les dates min/max pour les limites sensées de sélection<clear-param>Effacer l'aide de la balise param

: Moyen facile de nettoyer les filtres à l'aide

tag helper avec Alpine.js

En chemin, j'ai eu quelques bugs : les plages de dates étant perdues sur le changement de langue/commande, les temps forts du calendrier ne rafraîchissant pas sur l'échange, et un moment amusant où les styles de mode sombre ont fait le calendrier cassé parce que Flatpickr n'était pas redessiner.

<div id="filters" hx-target="#content">
  <select id="languageSelect">…</select>
  <select id="orderSelect">
    <option value="date_desc">Newest first</option>
    <option value="date_asc">Oldest first</option>
    <option value="title_asc">Title A–Z</option>
    <option value="title_desc">Title Z–A</option>
  </select>
  <input id="dateRange" type="text" placeholder="YYYY-MM-DD → YYYY-MM-DD" />
  <button id="clearDateFilter">Clear</button>
  <div id="filterSummary"></div>
</div>

<div id="content"><!-- HTMX swaps blog list here --></div>

La barre de filtre dans le DOM

C'est la structure brute que le script attend:Mostlylucid/src/js/blog-index.jsClient: HTMX + câblage Flatpickr

  • Noyau du comportement vit dans
function applyNavigation(u){
  const target = document.querySelector('#content');
  try{ window.history.pushState({}, '', u.toString()); }catch{}
  if(window.htmx && target){
    window.htmx.ajax('GET', u.toString(), {
      target: '#content',
      swap: 'outerHTML show:none',
      headers: {'pagerequest': 'true'}
    });
  } else {
    window.location.href = u.toString();
  }
}
const url = new URL(window.location.href);
const existingStart = url.searchParams.get('startDate');
const existingEnd   = url.searchParams.get('endDate');
const existingLang  = url.searchParams.get('language') || 'en';
const existingOrderBy = (url.searchParams.get('orderBy') || 'date').toLowerCase();
const existingOrderDir = (url.searchParams.get('orderDir') || 'desc').toLowerCase();

langSelect.value = existingLang;
orderSelect.value = `${existingOrderBy}_${existingOrderDir}`;
updateSummary();
  • Quelques pièces importantes:
async function fetchMonth(year, month, language){
  const res = await fetch(`/blog/calendar-days?year=${year}&month=${month}&language=${encodeURIComponent(language||'en')}`);
  if(!res.ok) return new Set();
  const j = await res.json();
  return new Set(j.dates || []);
}

function formatYMD(d){ return d.toISOString().substring(0,10); }

let highlightDates = new Set();
const fp = window.flatpickr(input, {
  mode: 'range',
  dateFormat: 'Y-m-d',
  defaultDate: [existingStart, existingEnd].filter(Boolean),
  onDayCreate: function(_dObj,_dStr,fpInstance,dayElem){
    const ymd = formatYMD(dayElem.dateObj);
    if(highlightDates.has(ymd)){
      dayElem.classList.add('has-post');
      dayElem.style.background = 'rgba(76,175,80,0.35)';
      dayElem.style.borderRadius = '6px';
    }
  },
  onMonthChange: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onOpen: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onChange: function(selectedDates){
    if(selectedDates.length === 2){
      const [start,end] = selectedDates;
      const u = new URL(window.location.href);
      u.searchParams.set('startDate', formatYMD(start));
      u.searchParams.set('endDate', formatYMD(end));
      u.searchParams.set('page','1');
      u.searchParams.set('language', langSelect.value);
      const [ob,od] = orderSelect.value.split('_');
      u.searchParams.set('orderBy', ob);
      u.searchParams.set('orderDir', od);
      updateSummary();
      applyNavigation(u);
    }
  }
});
  • Préservation des URL et navigation HTMX
langSelect.addEventListener('change', async function(){
  const u = new URL(window.location.href);
  u.searchParams.set('language', langSelect.value);
  u.searchParams.set('page', '1');
  const [ob,od] = (orderSelect.value||'date_desc').split('_');
  u.searchParams.set('orderBy', ob);
  u.searchParams.set('orderDir', od);
  if(input._flatpickr && input._flatpickr.selectedDates.length===2){
    const [s,e] = input._flatpickr.selectedDates;
    u.searchParams.set('startDate', formatYMD(s));
    u.searchParams.set('endDate', formatYMD(e));
  }
  // refresh calendar highlights for new language
  if(input._flatpickr){
    const fp = input._flatpickr;
    highlightDates = await fetchMonth(fp.currentYear, fp.currentMonth+1, langSelect.value);
    fp.redraw();
  }
  updateSummary();
  applyNavigation(u);
});
  • Initialisation, par défaut à partir de l'URL, et maintien de l'interface utilisateur résumée en synchronisation
const obs = new MutationObserver(() => {
  const dr = root.querySelector('#dateRange');
  const fp = dr && dr._flatpickr;
  if(fp && typeof fp.redraw === 'function') fp.redraw();
});
obs.observe(document.documentElement, { attributes:true, attributeFilter:['class'] });

Gamme Flatpickr avec les points forts du jour pilotés par le serveur

Préserver les dates lors du changement de langue ou de la commande

  • Re-draw calendrier lorsque le thème bascule (dark/light) donc Flatpickr repeint*
  • Serveur : les paramètres et la mise en cachecalendar-daysTrois paramètres alimentent la page :
  • L'index lui-même, qui accepte la page, pageTaille, startDate/finDate, langue et ordredate-rangeA
// GET /blog
[HttpGet]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
  VaryByQueryKeys = new[] { "page", "pageSize", nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) },
  Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
  VaryByQueryKeys = new[] { nameof(page), nameof(pageSize), nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) })]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20, DateTime? startDate = null, DateTime? endDate = null,
  string language = MarkdownBaseService.EnglishLanguage, string orderBy = "date", string orderDir = "desc")
{
    var posts = await blogViewService.GetPagedPosts(page, pageSize, language: language, startDate: startDate, endDate: endDate);
    posts.LinkUrl = Url.Action("Index", "Blog", new { startDate, endDate, language, orderBy, orderDir });
    if (Request.IsHtmx()) return PartialView("_BlogSummaryList", posts);
    return View("Index", posts);
}

// GET /blog/calendar-days?year=2025&month=11&language=en
[HttpGet("calendar-days")]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 1800, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) })]
public async Task<IActionResult> CalendarDays(int year, int month, string language = MarkdownBaseService.EnglishLanguage)
{
    if (year < 2000 || month < 1 || month > 12) return BadRequest("Invalid year or month");
    var start = new DateTime(year, month, 1);
    var end = start.AddMonths(1).AddDays(-1);
    var posts = await blogViewService.GetPostsForRange(start, end, language: language);
    if (posts is null) return Json("");
    var dates = posts.Select(p => p.PublishedDate.Date).Distinct().OrderBy(d => d).Select(d => d.ToString("yyyy-MM-dd")).ToList();
    return Json(new { dates });
}

// GET /blog/date-range?language=en
[HttpGet("date-range")]
[ResponseCache(Duration = 3600, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 7200, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(language) })]
public async Task<IActionResult> DateRange(string language = MarkdownBaseService.EnglishLanguage)
{
    var allPosts = await blogViewService.GetAllPosts();
    if (allPosts is null || !allPosts.Any())
    {
        return Json(new
        {
            minDate = DateTime.UtcNow.AddYears(-1).ToString("yyyy-MM-dd"),
            maxDate = DateTime.UtcNow.ToString("yyyy-MM-dd")
        });
    }

    var posts = allPosts;
    if (!string.IsNullOrEmpty(language) && language != MarkdownBaseService.EnglishLanguage)
    {
        posts = allPosts.Where(p => p.Language.Equals(language, StringComparison.OrdinalIgnoreCase)).ToList();
    }

    if (!posts.Any()) posts = allPosts;

    var minDate = posts.Min(p => p.PublishedDate.Date);
    var maxDate = posts.Max(p => p.PublishedDate.Date);

    return Json(new
    {
        minDate = minDate.ToString("yyyy-MM-dd"),
        maxDate = maxDate.ToString("yyyy-MM-dd")
    });
}

paramètre qui renvoie l'ensemble de jours dans un mois donné qui ont des messages (pour les faits saillants)LinkUrlA

public class BasePagingModel<T> : Interfaces.IPagingModel<T> where T : class
{
    public int Page { get; set; }
    public int TotalItems { get; set; } = 0;
    public int PageSize { get; set; }
    public ViewType ViewType { get; set; } = ViewType.TailwindAndDaisy;
    public string LinkUrl { get; set; }
    public List<T> Data { get; set; }
}

paramètre qui renvoie les dates min/max sur tous les messages (en connaissance de langue)

sequenceDiagram
  participant U as User
  participant FP as Flatpickr
  participant JS as blog-index.js
  participant HT as HTMX
  participant C as BlogController

  U->>FP: Selects 2025-11-01 → 2025-11-10
  FP-->>JS: onChange([start,end])
  JS->>JS: Update URLSearchParams (startDate, endDate, language, order)
  JS->>HT: htmx.ajax('GET', /blog?...)
  HT->>C: GET /blog with query
  C-->>HT: Partial _BlogSummaryList
  HT-->>U: Swap #content
  JS->>FP: (after swap) ensure highlights + redraw

Une mise de côté rapide: le modèle de pagination a

flowchart TD
  A[Open calendar / Month change] --> B[Fetch /blog/calendar-days]
  B -->|JSON dates: yyyy-mm-dd array| C[Set highlightDates]
  C --> D[flatpickr redraw]
  D --> E[onDayCreate adds .has-post]

de sorte que la pagination préserve le contexte actuel du filtre lorsqu'il est rendu côté serveur.

Séquence : comment un filtre change de flux

Flux: faits saillants du calendrierBlogPostEntity:

public class BlogPostEntity
{
    // ... existing fields ...

    public bool IsPinned { get; set; }
    public bool IsHidden { get; set; }
    public DateTimeOffset? ScheduledPublishDate { get; set; }
}

Nouvelles fonctionnalités: Visibilité et contrôle du contenuBlogServiceFiltres post-visibilité

// Filter out hidden posts and posts scheduled for the future
var now = DateTimeOffset.UtcNow;
postQuery = postQuery.Where(x =>
    !x.IsHidden &&
    (x.ScheduledPublishDate == null || x.ScheduledPublishDate <= now));

// For page 1, prioritize pinned posts
var isFirstPage = page == null || page.Value == 1;
if (isFirstPage)
{
    postQuery = postQuery.OrderByDescending(x => x.IsPinned)
                         .ThenByDescending(x => x.PublishedDate.DateTime);
}

La plus grande amélioration de cette mise à jour est une bonne gestion de la visibilité.

  • Trois nouveaux champs ont été ajoutés à (IsHidden = trueLes
  • **maintenant filtre les messages de manière appropriée:**Cela signifie:ScheduledPublishDate
  • Postes cachés (IsPinned = true) n'apparaissent jamais dans les listes

Postes prévus

n'apparaissent qu'après leur

[HtmlTargetElement("clear-param")]
public class ClearParamTagHelper : TagHelper
{
    [HtmlAttributeName("name")]
    public string? Name { get; set; }

    [HtmlAttributeName("all")]
    public bool All { get; set; } = false;

    [HtmlAttributeName("exclude")]
    public string Exclude { get; set; } = "";

    // ... styling and Alpine.js integration ...
}

Postes épinglés

<!-- Clear a specific parameter -->
<clear-param name="startDate">Clear Date</clear-param>

<!-- Clear all parameters except language -->
<clear-param all="true" exclude="language">Clear All Filters</clear-param>

) apparaissent toujours en premier à la page 1, parfait pour les annonces ou le contenuwindow.queryParamClearerEffacer l'aide de la balise Param

**Un nouvel helper ASP.NET Core permet de définir facilement les paramètres de la requête :**Utilisation dans les vues Razor:

Le tag helper s'intègre à Alpine.js

  • composant pour gérer la manipulation d'URL et les swaps HTMX.

    • RemarquestartDate/endDate.
    • : L'aide de la balise de recherche et le paramètre de requête plus clair méritent une exploration plus approfondie.new URL(window.location.href)Je vais couvrir tous les détails de mise en œuvre, y compris le composant Alpine.js et les modèles d'aide de tag, dans un futur post sur la construction de composants HTMX/Alpine réutilisables.language/orderBugs que j'ai frappé (et les corrections)
  • Gamme de dates perdues lors du changement de langue/commande

    • Cause: J'ai construit la nouvelle URL à partir de zéro, pas à partir du courant; perdu
    • Correction: Commencez toujours par
  • et ne changer que les parties qui ont changé; ou lire les dates de Flatpickr si présent.

    • Voir
    • les gestionnaires de changement ci-dessus.<html class>Les temps forts du calendrier n'ont pas été rafraîchis après un swap HTMXfp.redraw().
  • Cause : L'instance Flatpickr a vécu sur le vieux DOM et est morte lors de l'échange.

    • Correction : Sur init, détruire toute instance existante, recréer, puis précharger les points forts pour le mois visible.LinkUrlDe plus, il a repris après les swaps (voir ci-dessous).
    • Le mode sombre a fait le calendrier a l'air casséposts.LinkUrl = Url.Action("Index","Blog", new { startDate, endDate, language, orderBy, orderDir })Cause: les classes de style ont changé mais Flatpickr n'a pas redessiné les jours peints.

Correction : Observer

changements et appel

(function(){
  function initFromRoot(root){ /* …the big function shown above… */ }
  if(window.htmx){
    document.body.addEventListener('htmx:afterSwap', function(ev){
      const tgt = ev.detail.target;
      // re-init if the content swapped includes the filters/content area
      if(tgt && (tgt.id === 'content' || tgt.querySelector?.('#filters'))){
        initFromRoot(document);
      }
    });
  }
  // also run on first load
  initFromRoot(document);
})();

Liens de pagination laissés tomber contexte du filtre

  • Cause:
  • n'était pas configuré pour inclure la requête dans certains cas.
  • Correction : Sur le serveur, défini

donc le pager compose correctement les URLs.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.