@datagouv/components-next 1.0.2-dev.153 → 1.0.2-dev.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { g as Ke } from "./main-CWzZZ7OE.js";
1
+ import { g as Ke } from "./main-B4HZYPjy.js";
2
2
  import We from "vue";
3
3
  function Fe($, K) {
4
4
  for (var V = 0; V < K.length; V++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datagouv/components-next",
3
- "version": "1.0.2-dev.153",
3
+ "version": "1.0.2-dev.154",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,6 +20,10 @@
20
20
  class="inline-block rounded font-medium px-2 py-0.5 text-xs max-w-full truncate"
21
21
  :style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
22
22
  >{{ value }}</span>
23
+ <span
24
+ v-else-if="columnType === 'number' && noNumberFormat"
25
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
26
+ >{{ value }}</span>
23
27
  <span
24
28
  v-else-if="columnType === 'number'"
25
29
  :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
@@ -28,6 +32,10 @@
28
32
  v-else-if="columnType === 'date'"
29
33
  :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
30
34
  >{{ formatCellDate(value) }}</span>
35
+ <span
36
+ v-else-if="columnType === 'year'"
37
+ :class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
38
+ >{{ value }}</span>
31
39
  <span
32
40
  v-else
33
41
  class="text-gray-title truncate block text-xs"
@@ -44,6 +52,7 @@ defineProps<{
44
52
  columnType: ColumnType
45
53
  categoryBadgeStyle?: BadgeStyle
46
54
  compact?: boolean
55
+ noNumberFormat?: boolean
47
56
  }>()
48
57
 
49
58
  const { t } = useTranslation()
@@ -128,7 +128,7 @@ function filterByValue() {
128
128
  const val = String(cell.value.value ?? '')
129
129
  const col = cell.value.column
130
130
  const existing = filters.value[col] ?? {}
131
- if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
131
+ if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date' || cell.value.columnType === 'year') {
132
132
  const current = existing.in ?? []
133
133
  if (!current.includes(val)) {
134
134
  filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
@@ -63,6 +63,7 @@
63
63
  ExploreResourceView), so they line up with the resource title /
64
64
  download button above. Only the table itself goes edge-to-edge
65
65
  (via -mx-4 below). -->
66
+ <slot name="toolbar-top" />
66
67
  <div>
67
68
  <div class="flex items-center py-3 gap-2">
68
69
  <!-- Mobile: filter & sort button -->
@@ -379,15 +380,32 @@
379
380
  v-for="col in displayedColumns"
380
381
  :key="col"
381
382
  data-cell
382
- 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' }"
383
+ class="p-2 align-middle whitespace-nowrap border-r border-gray-default last:border-r-0 overflow-hidden"
384
+ :class="[
385
+ !props.disablePopover && 'cursor-pointer',
386
+ { 'text-right font-mono tabular-nums text-sm': getColumnType(col) === 'number' || getColumnType(col) === 'date' || getColumnType(col) === 'year' },
387
+ ]"
384
388
  :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
385
389
  @click="onCellClick(col, row[col], $event)"
386
390
  >
391
+ <a
392
+ v-if="linkColumns?.has(col)"
393
+ :href="getRowHref(row) ?? ''"
394
+ class="link"
395
+ >
396
+ <TabularCell
397
+ :value="row[col]"
398
+ :column-type="getColumnType(col)"
399
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
400
+ :no-number-format="props.noFormatColumns?.includes(col)"
401
+ />
402
+ </a>
387
403
  <TabularCell
404
+ v-else
388
405
  :value="row[col]"
389
406
  :column-type="getColumnType(col)"
390
407
  :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
408
+ :no-number-format="props.noFormatColumns?.includes(col)"
391
409
  />
392
410
  </td>
393
411
  </tr>
@@ -402,6 +420,7 @@
402
420
 
403
421
  <!-- Cell popover -->
404
422
  <TabularCellPopover
423
+ v-if="!props.disablePopover"
405
424
  v-model:cell="activeCell"
406
425
  v-model:filters="filters"
407
426
  />
@@ -450,11 +469,25 @@
450
469
  class="min-w-0 pl-4 cursor-pointer"
451
470
  @click="onCellClick(col, row[col], $event)"
452
471
  >
472
+ <a
473
+ v-if="linkColumns?.has(col)"
474
+ :href="getRowHref(row) ?? ''"
475
+ >
476
+ <TabularCell
477
+ :value="row[col]"
478
+ :column-type="getColumnType(col)"
479
+ :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
480
+ compact
481
+ :no-number-format="props.noFormatColumns?.includes(col)"
482
+ />
483
+ </a>
453
484
  <TabularCell
485
+ v-else
454
486
  :value="row[col]"
455
487
  :column-type="getColumnType(col)"
456
488
  :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
457
489
  compact
490
+ :no-number-format="props.noFormatColumns?.includes(col)"
458
491
  />
459
492
  </div>
460
493
  </div>
@@ -614,7 +647,7 @@ import { useFetch } from '../../functions/api'
614
647
  import { useComponentsConfig } from '../../config'
615
648
  import { useTranslation } from '../../composables/useTranslation'
616
649
  import { injectTabularProfile } from '../../composables/useTabularProfile'
617
- import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType } from '../../functions/tabular'
650
+ import { buildTypeConfig, buildFormatConfig, humanizeFormat, GENERIC_FORMATS, hasFilterForColumn as _hasFilterForColumn, isTruthy, isFalsy, resolveColumnType, buildGlobalSearchConditions } from '../../functions/tabular'
618
651
  import ClientOnly from '../ClientOnly.vue'
619
652
  import SimpleBanner from '../SimpleBanner.vue'
620
653
  import BrandedButton from '../BrandedButton.vue'
@@ -633,8 +666,37 @@ const props = defineProps<{
633
666
  // the host's padding. Used on the standalone explore page where the table
634
667
  // should span the whole screen while the toolbar stays inside the container.
635
668
  fullBleed?: boolean
669
+ // When set, searches across multiple columns using the Tabular API's or(...)
670
+ // parameter. Text and categorical columns get a __contains filter; number
671
+ // columns get a __exact filter (since __contains is not supported for numbers
672
+ // by the API). Date and boolean columns are excluded.
673
+ // Note: combined via AND with any existing column-specific `contains` filters,
674
+ // so it acts as an additional narrowing constraint, not a replacement.
675
+ globalSearch?: string
676
+ // When true, clicking a cell does not open the popover. Use when row-click
677
+ // navigation is the primary interaction.
678
+ disablePopover?: boolean
679
+ // Initial filters applied on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
680
+ // Used when navigating from the detail page badge with query params.
681
+ initialFilters?: Record<string, ColumnFilters>
682
+ // When set, renders <a> tags inside the specified column cells for native
683
+ // browser UX (hover URL, ctrl+click, middle-click).
684
+ rowHref?: { columns: string[], href: (row: TabularRow) => string }
685
+ // Columns listed here render their values raw (no number formatting).
686
+ // Useful for identifier columns.
687
+ noFormatColumns?: string[]
636
688
  }>()
637
689
 
690
+ const rowHrefFn = computed(() => props.rowHref?.href ?? null)
691
+
692
+ function getRowHref(row: TabularRow): string | null {
693
+ return rowHrefFn.value?.(row) ?? null
694
+ }
695
+
696
+ const linkColumns = computed(() =>
697
+ props.rowHref ? new Set(props.rowHref.columns) : null,
698
+ )
699
+
638
700
  const { t } = useTranslation()
639
701
  const config = useComponentsConfig()
640
702
 
@@ -658,6 +720,10 @@ const dataUrl = computed(() =>
658
720
  const sort = ref<SortConfig | null>(null)
659
721
  const filters = ref<Record<string, ColumnFilters>>({})
660
722
 
723
+ if (props.initialFilters) {
724
+ filters.value = { ...props.initialFilters }
725
+ }
726
+
661
727
  const PAGE_SIZE = 50
662
728
 
663
729
  const dataQuery = computed(() => {
@@ -688,6 +754,10 @@ const dataQuery = computed(() => {
688
754
  q[`${col}__isnotnull`] = ''
689
755
  }
690
756
  }
757
+ if (props.globalSearch && profileData.value?.profile) {
758
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
759
+ q.or = '(' + conditions.join(',') + ')'
760
+ }
691
761
  return q
692
762
  })
693
763
 
@@ -938,6 +1008,7 @@ onUnmounted(() => {
938
1008
  const activeCell = ref<CellInfo | null>(null)
939
1009
 
940
1010
  function onCellClick(col: string, value: unknown, event: MouseEvent) {
1011
+ if (props.disablePopover) return
941
1012
  const el = (event.target as HTMLElement).closest('[data-cell]') as HTMLElement | null
942
1013
  if (!el) return
943
1014
  if (activeCell.value && activeCell.value.element === el) {
@@ -961,7 +1032,12 @@ const activeFilters = computed<ActiveFilter[]>(() => {
961
1032
  parts.push(`= ${filter.in.join(', ')}`)
962
1033
  }
963
1034
  if (filter.exact != null) {
964
- parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
1035
+ if (getColumnType(col) === 'boolean') {
1036
+ parts.push(`= ${filter.exact === 'true' ? t('Vrai') : t('Faux')}`)
1037
+ }
1038
+ else {
1039
+ parts.push(`= ${filter.exact}`)
1040
+ }
965
1041
  }
966
1042
  if (filter.contains) {
967
1043
  parts.push(`${t('contient')} "${filter.contains}"`)
@@ -154,14 +154,14 @@
154
154
 
155
155
  <!-- Number range -->
156
156
  <form
157
- v-if="columnType === 'number' && columnProfile"
157
+ v-if="(columnType === 'number' || columnType === 'year') && columnProfile"
158
158
  class="px-3 py-2 border-b border-black/10 space-y-2"
159
159
  @submit.prevent="applyRange"
160
160
  >
161
161
  <div class="flex items-center gap-2 text-xs text-gray-plain">
162
- <span class="tabular-nums">{{ formatNumber(profileMin) }}</span>
162
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMin : formatNumber(profileMin) }}</span>
163
163
  <span class="text-gray-medium">—</span>
164
- <span class="tabular-nums">{{ formatNumber(profileMax) }}</span>
164
+ <span class="tabular-nums">{{ columnType === 'year' ? profileMax : formatNumber(profileMax) }}</span>
165
165
  </div>
166
166
  <div class="flex items-center gap-2">
167
167
  <input
@@ -62,13 +62,13 @@ export type TabularTopValue = {
62
62
  count: number
63
63
  }
64
64
 
65
- export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean'
65
+ export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' | 'year'
66
66
 
67
67
  export type ColumnFilters = {
68
68
  in?: string[]
69
69
  exact?: string
70
- min?: number
71
- max?: number
70
+ min?: number | string
71
+ max?: number | string
72
72
  contains?: string
73
73
  null?: 'only' | 'exclude'
74
74
  }
@@ -42,12 +42,13 @@ export function buildTypeConfig(t: TranslationFunction): Record<ColumnType, Type
42
42
  text: { icon: RiText, label: t('Texte') },
43
43
  date: { icon: RiCalendarLine, label: t('Date') },
44
44
  boolean: { icon: RiCheckboxLine, label: t('Booléen') },
45
+ year: { icon: RiCalendarLine, label: t('Année') },
45
46
  }
46
47
  }
47
48
 
48
49
  export function resolveColumnType(colInfo: { python_type: string, format?: string }, isCategorical: boolean): ColumnType {
50
+ if (colInfo.format === 'year') return 'year'
49
51
  if (['int', 'float'].includes(colInfo.python_type)) return 'number'
50
- if (colInfo.format === 'year') return 'date'
51
52
  if (['date', 'datetime'].includes(colInfo.python_type)) return 'date'
52
53
  if (colInfo.python_type === 'bool') return 'boolean'
53
54
  if (isCategorical) return 'categorical'
@@ -158,6 +159,30 @@ export function useFormatTabular() {
158
159
  const TRUTHY_VALUES = ['true', '1', 'oui', 'yes']
159
160
  const FALSY_VALUES = ['false', '0', 'non', 'no']
160
161
 
162
+ /**
163
+ * Builds the OR conditions for global search across all columns.
164
+ * Text and categorical columns get a `__contains` filter; number columns
165
+ * get a `__exact` filter only when the search value is numeric.
166
+ * Date and boolean columns are excluded.
167
+ */
168
+ export function buildGlobalSearchConditions(
169
+ allColumns: string[],
170
+ getColumnType: (col: string) => ColumnType,
171
+ searchValue: string,
172
+ ): string[] {
173
+ const conditions: string[] = []
174
+ for (const col of allColumns) {
175
+ const type = getColumnType(col)
176
+ if (type === 'text' || type === 'categorical') {
177
+ conditions.push(col + '__contains.' + encodeURIComponent(searchValue))
178
+ }
179
+ else if (type === 'number' && searchValue.length > 0 && isFinite(Number(searchValue))) {
180
+ conditions.push(col + '__exact.' + encodeURIComponent(searchValue))
181
+ }
182
+ }
183
+ return conditions
184
+ }
185
+
161
186
  export function isTruthy(value: unknown): boolean {
162
187
  if (typeof value === 'boolean') return value
163
188
  if (typeof value === 'string') return TRUTHY_VALUES.includes(value.toLowerCase())