11<template >
22 <div >
3+ <!-- Outside the branches below: this slot holds the query controls (search
4+ input…), so it has to stay reachable when the query itself failed. -->
5+ <slot name =" toolbar-top" />
6+
37 <div
48 v-if =" previewError"
59 class =" max-w-3xl mx-auto"
162166
163167 <!-- Rows -->
164168 <span class =" flex items-center gap-1.5 text-xs text-gray-plain" >
169+ <RiLoader5Line
170+ v-if =" isRefreshing "
171+ class="size-3 text-new-primary animate-spin"
172+ aria-hidden="true"
173+ />
165174 <RiLayoutRowLine
175+ v-else
166176 class="size-3 text-mention-grey"
167177 aria-hidden="true"
168178 />
169179 <span class =" font-bold hidden md:inline" >{{ t('Lignes') }}</span >
170- <span class =" font-mono tabular-nums" >{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span >
180+ <!-- The count stays in place while refreshing: the spinner already
181+ says it is stale, and swapping it for a word of another width
182+ would shift the whole toolbar on every search. -->
183+ <span
184+ data-testid =" row-count"
185+ class =" font-mono tabular-nums"
186+ >{{ tableData.meta.total.toLocaleString() }}/{{ totalLines.toLocaleString() }}</span >
171187 </span >
172188 </div >
173189 </div >
231247 <span
232248 v-for =" af in activeFilters"
233249 :key =" af.column"
250+ :data-testid =" `active-filter-${af.column}`"
234251 class =" inline-flex items-center gap-1.5 bg-white border border-gray-silver rounded-lg pl-2 pr-1 py-1 text-xs"
235252 >
236253 <component
292309 <div
293310 v-if =" displayedColumns.length > 0"
294311 ref =" scrollContainer"
295- class =" hidden md:block overflow-auto max-h-[70vh]"
296- :class =" fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4'"
312+ class =" hidden md:block overflow-auto max-h-[70vh] transition-opacity"
313+ :class =" [
314+ fullBleed ? 'relative left-1/2 w-[calc(100vw_-_4rem)] -translate-x-1/2' : '-mx-4',
315+ { 'opacity-40': isRefreshing },
316+ ]"
317+ :aria-busy =" isRefreshing"
297318 >
298319 <table class =" text-sm border-collapse" >
299320 <thead class =" sticky top-0 bg-white z-10 shadow-[inset_0_-1px_0_0_#E5E5E5]" >
380401 :key =" col"
381402 data-cell
382403 class =" p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden cursor-pointer hover:bg-gray-200/50"
383- :class =" { 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' }"
404+ :class =" { 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' }"
384405 :style =" columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
385406 @click =" onCellClick(col, row[col], $event)"
386407 >
408+ <AppLink
409+ v-if =" isLinkColumn (col )"
410+ :to =" getRowHref (row )"
411+ class="link"
412+ >
413+ <TabularCell v-bind =" cellProps (col , row )" />
414+ </AppLink >
387415 <TabularCell
388- :value =" row [col ]"
389- :column-type =" getColumnType (col )"
390- :category-badge-style =" getColumnType (col ) === ' categorical' ? getCategoryBadgeStyle (col , String (row [col ])) : undefined "
416+ v-else
417+ v-bind =" cellProps (col , row )"
391418 />
392419 </td >
393420 </tr >
409436 <!-- Mobile: card layout -->
410437 <div
411438 v-if =" displayedColumns.length > 0"
412- class =" md:hidden space-y-2 px-1"
439+ class =" md:hidden space-y-2 px-1 transition-opacity"
440+ :class =" { 'opacity-40': isRefreshing }"
441+ :aria-busy =" isRefreshing"
413442 >
414443 <div
415444 v-if =" allRows.length === 0"
445474 :title =" col"
446475 >{{ col }}</span >
447476 </div >
477+ <!-- Numbers and dates take their colour from here rather than from
478+ the cell, so a link wrapping one (see `rowHref`) keeps its own. -->
448479 <div
449480 data-cell
450- class =" min-w-0 pl-4 cursor-pointer"
481+ class =" min-w-0 pl-4 cursor-pointer text-gray-title "
451482 @click =" onCellClick(col, row[col], $event)"
452483 >
484+ <AppLink
485+ v-if =" isLinkColumn (col )"
486+ :to =" getRowHref (row )"
487+ class="link"
488+ >
489+ <TabularCell
490+ v-bind =" cellProps (col , row )"
491+ compact
492+ />
493+ </AppLink >
453494 <TabularCell
454- :value =" row [col ]"
455- :column-type =" getColumnType (col )"
456- :category-badge-style =" getColumnType (col ) === ' categorical' ? getCategoryBadgeStyle (col , String (row [col ])) : undefined "
495+ v-else
496+ v-bind =" cellProps (col , row )"
457497 compact
458498 />
459499 </div >
@@ -609,12 +649,14 @@ import {
609649 RiFilterLine ,
610650 RiCloseLine ,
611651 RiSearchLine ,
652+ RiLoader5Line ,
612653} from ' @remixicon/vue'
613654import { useFetch } from ' ../../functions/api'
614655import { useComponentsConfig } from ' ../../config'
615656import { useTranslation } from ' ../../composables/useTranslation'
616657import { injectTabularProfile } from ' ../../composables/useTabularProfile'
617- import { buildTypeConfig , buildFormatConfig , humanizeFormat , GENERIC_FORMATS , hasFilterForColumn as _hasFilterForColumn , isTruthy , isFalsy , resolveColumnType } from ' ../../functions/tabular'
658+ import { buildTypeConfig , buildFormatConfig , humanizeFormat , GENERIC_FORMATS , hasFilterForColumn as _hasFilterForColumn , isTruthy , isFalsy , resolveColumnType , buildGlobalSearchConditions } from ' ../../functions/tabular'
659+ import AppLink from ' ../AppLink.vue'
618660import ClientOnly from ' ../ClientOnly.vue'
619661import SimpleBanner from ' ../SimpleBanner.vue'
620662import BrandedButton from ' ../BrandedButton.vue'
@@ -633,8 +675,42 @@ const props = defineProps<{
633675 // the host's padding. Used on the standalone explore page where the table
634676 // should span the whole screen while the toolbar stays inside the container.
635677 fullBleed? : boolean
678+ // When set, searches across multiple columns using the Tabular API's or(...)
679+ // parameter. Text and categorical columns get a __contains filter; number
680+ // columns get a __exact filter (since __contains is not supported for numbers
681+ // by the API). Year, date and boolean columns are excluded.
682+ // Note: combined via AND with any existing column-specific `contains` filters,
683+ // so it acts as an additional narrowing constraint, not a replacement.
684+ globalSearch? : string
685+ // Initial filters applied on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
686+ // Used when navigating from the detail page badge with query params.
687+ initialFilters? : Record <string , ColumnFilters >
688+ // When set, renders <a> tags inside the specified column cells for native
689+ // browser UX (hover URL, ctrl+click, middle-click).
690+ rowHref? : { columns: string [], href: (row : TabularRow ) => string }
691+ // Columns listed here render their values raw (no number formatting).
692+ // Useful for identifier columns.
693+ noFormatColumns? : string []
636694}>()
637695
696+ function getRowHref(row : TabularRow ): string | undefined {
697+ return props .rowHref ?.href (row )
698+ }
699+
700+ function isLinkColumn(col : string ): boolean {
701+ return props .rowHref ?.columns .includes (col ) ?? false
702+ }
703+
704+ function cellProps(col : string , row : TabularRow ) {
705+ const columnType = getColumnType (col )
706+ return {
707+ value: row [col ],
708+ columnType ,
709+ categoryBadgeStyle: columnType === ' categorical' ? getCategoryBadgeStyle (col , String (row [col ])) : undefined ,
710+ noNumberFormat: props .noFormatColumns ?.includes (col ),
711+ }
712+ }
713+
638714const { t } = useTranslation ()
639715const config = useComponentsConfig ()
640716
@@ -654,9 +730,39 @@ const dataUrl = computed(() =>
654730 ` ${config .tabularApiUrl }/api/resources/${props .resourceId }/data/ ` ,
655731)
656732
733+ // Profile is shared with sibling components (e.g. DataStructure) via
734+ // `provideTabularProfile` in the parent. Falls back to a local fetch
735+ // when no parent provides it (standalone usage).
736+ // Fetched before the rows: `dataQuery` reads the column types to build the
737+ // global search, so the profile has to be resolved by the time it is evaluated.
738+ const { data : profileData, error : profileError, status : profileStatus } = await injectTabularProfile (() => props .resourceId )
739+
740+ const allColumns = computed (() => profileData .value ?.profile ?.header ?? [])
741+
742+ // Column type helpers
743+ function getColumnTypeFromName(col : string ): ColumnType {
744+ const profile = profileData .value ?.profile
745+ if (! profile ) return ' text'
746+ const colInfo = profile .columns [col ]
747+ if (! colInfo ) return ' text'
748+ return resolveColumnType (colInfo , profile .categorical .includes (col ))
749+ }
750+
751+ const columnTypesMap = computed (() => {
752+ const map: Record <string , ColumnType > = {}
753+ for (const col of allColumns .value ) {
754+ map [col ] = getColumnTypeFromName (col )
755+ }
756+ return map
757+ })
758+
759+ function getColumnType(col : string ): ColumnType {
760+ return columnTypesMap .value [col ] ?? ' text'
761+ }
762+
657763// Sort & filter state
658764const sort = ref <SortConfig | null >(null )
659- const filters = ref <Record <string , ColumnFilters >>({})
765+ const filters = ref <Record <string , ColumnFilters >>({ ... props . initialFilters })
660766
661767const PAGE_SIZE = 50
662768
@@ -688,22 +794,24 @@ const dataQuery = computed(() => {
688794 q [` ${col }__isnotnull ` ] = ' '
689795 }
690796 }
797+ if (props .globalSearch && profileData .value ?.profile ) {
798+ const conditions = buildGlobalSearchConditions (allColumns .value , getColumnType , props .globalSearch )
799+ q .or = ' (' + conditions .join (' ,' ) + ' )'
800+ }
691801 return q
692802})
693803
694- const { data : tableData, error } = await useFetch <TabularDataResponse >(dataUrl , { raw: true , query: dataQuery })
695-
696- // Profile is shared with sibling components (e.g. DataStructure) via
697- // `provideTabularProfile` in the parent. Falls back to a local fetch
698- // when no parent provides it (standalone usage).
699- const { data : profileData, error : profileError, status : profileStatus } = await injectTabularProfile (() => props .resourceId )
804+ const { data : tableData, error, status : dataStatus } = await useFetch <TabularDataResponse >(dataUrl , { raw: true , query: dataQuery })
700805
701806// The component renders nothing useful until the profile is available
702807// (allColumns is derived from it). Surface a clear loading / error state
703808// so we don't end up with an empty table + a spinner running forever.
704809const profileLoading = computed (() => ! profileData .value && (profileStatus .value === ' idle' || profileStatus .value === ' pending' ))
705810const previewError = computed (() => error .value || profileError .value )
706811const previewLoading = computed (() => ! previewError .value && (! tableData .value || profileLoading .value ))
812+ // A search / filter / sort change refetches while the previous rows stay on
813+ // screen: without a signal, the table looks unchanged for several seconds.
814+ const isRefreshing = computed (() => dataStatus .value === ' pending' && ! previewLoading .value )
707815
708816// Infinite scroll state
709817const allRows = ref <TabularRow []>([])
@@ -743,8 +851,6 @@ async function loadNextPage() {
743851
744852const totalLines = computed (() => profileData .value ?.profile ?.total_lines ?? tableData .value ?.meta .total ?? 0 )
745853
746- const allColumns = computed (() => profileData .value ?.profile ?.header ?? [])
747-
748854const visibleColumns = ref (new Set (allColumns .value ))
749855
750856watch (allColumns , (cols ) => {
@@ -938,6 +1044,9 @@ onUnmounted(() => {
9381044const activeCell = ref <CellInfo | null >(null )
9391045
9401046function onCellClick(col : string , value : unknown , event : MouseEvent ) {
1047+ // A link inside a cell (see `rowHref`) owns its clicks: navigate, don't open
1048+ // the popover on top of the page we're leaving.
1049+ if ((event .target as HTMLElement ).closest (' a' )) return
9411050 const el = (event .target as HTMLElement ).closest (' [data-cell]' ) as HTMLElement | null
9421051 if (! el ) return
9431052 if (activeCell .value && activeCell .value .element === el ) {
@@ -961,7 +1070,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
9611070 parts .push (` = ${filter .in .join (' , ' )} ` )
9621071 }
9631072 if (filter .exact != null ) {
964- parts .push (` = ${filter .exact === ' true' ? t (' Vrai' ) : t (' Faux' )} ` )
1073+ if (getColumnType (col ) === ' boolean' ) {
1074+ parts .push (` = ${filter .exact === ' true' ? t (' Vrai' ) : t (' Faux' )} ` )
1075+ }
1076+ else {
1077+ parts .push (` = ${filter .exact } ` )
1078+ }
9651079 }
9661080 if (filter .contains ) {
9671081 parts .push (` ${t (' contient' )} "${filter .contains }" ` )
@@ -1024,27 +1138,6 @@ function toggleMobileFilterColumn(col: string) {
10241138 mobileFilterExpandedCol .value = mobileFilterExpandedCol .value === col ? null : col
10251139}
10261140
1027- // Column type helpers
1028- function getColumnTypeFromName(col : string ): ColumnType {
1029- const profile = profileData .value ?.profile
1030- if (! profile ) return ' text'
1031- const colInfo = profile .columns [col ]
1032- if (! colInfo ) return ' text'
1033- return resolveColumnType (colInfo , profile .categorical .includes (col ))
1034- }
1035-
1036- const columnTypesMap = computed (() => {
1037- const map: Record <string , ColumnType > = {}
1038- for (const col of allColumns .value ) {
1039- map [col ] = getColumnTypeFromName (col )
1040- }
1041- return map
1042- })
1043-
1044- function getColumnType(col : string ): ColumnType {
1045- return columnTypesMap .value [col ] ?? ' text'
1046- }
1047-
10481141function getColumnProfile(col : string ) {
10491142 return profileData .value ?.profile ?.profile ?.[col ] ?? null
10501143}
0 commit comments