@datagouv/components-next 1.0.2-dev.152 → 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-BdrYl6mr.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.152",
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] } }
@@ -374,22 +374,38 @@
374
374
  <tr
375
375
  v-for="(row, i) in allRows"
376
376
  :key="row.__id ?? i"
377
- class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100 cursor-pointer"
378
- @click="emit('rowClick', row)"
377
+ class="border-b border-gray-default even:bg-gray-lowest-2 hover:bg-gray-100"
379
378
  >
380
379
  <td
381
380
  v-for="col in displayedColumns"
382
381
  :key="col"
383
382
  data-cell
384
- 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"
385
- :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
+ ]"
386
388
  :style="columnWidths[col] ? { maxWidth: columnWidths[col] + 'px' } : { maxWidth: '300px' }"
387
389
  @click="onCellClick(col, row[col], $event)"
388
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>
389
403
  <TabularCell
404
+ v-else
390
405
  :value="row[col]"
391
406
  :column-type="getColumnType(col)"
392
407
  :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
408
+ :no-number-format="props.noFormatColumns?.includes(col)"
393
409
  />
394
410
  </td>
395
411
  </tr>
@@ -429,9 +445,8 @@
429
445
  <div
430
446
  v-for="(row, i) in allRows"
431
447
  :key="row.__id ?? i"
432
- class="border border-gray-default rounded-lg p-3 space-y-2 cursor-pointer"
448
+ class="border border-gray-default rounded-lg p-3 space-y-2"
433
449
  :class="i % 2 === 1 ? 'bg-gray-lowest-2' : 'bg-white'"
434
- @click="emit('rowClick', row)"
435
450
  >
436
451
  <div
437
452
  v-for="col in mobileVisibleFields(i)"
@@ -454,11 +469,25 @@
454
469
  class="min-w-0 pl-4 cursor-pointer"
455
470
  @click="onCellClick(col, row[col], $event)"
456
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>
457
484
  <TabularCell
485
+ v-else
458
486
  :value="row[col]"
459
487
  :column-type="getColumnType(col)"
460
488
  :category-badge-style="getColumnType(col) === 'categorical' ? getCategoryBadgeStyle(col, String(row[col])) : undefined"
461
489
  compact
490
+ :no-number-format="props.noFormatColumns?.includes(col)"
462
491
  />
463
492
  </div>
464
493
  </div>
@@ -618,7 +647,7 @@ import { useFetch } from '../../functions/api'
618
647
  import { useComponentsConfig } from '../../config'
619
648
  import { useTranslation } from '../../composables/useTranslation'
620
649
  import { injectTabularProfile } from '../../composables/useTabularProfile'
621
- 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'
622
651
  import ClientOnly from '../ClientOnly.vue'
623
652
  import SimpleBanner from '../SimpleBanner.vue'
624
653
  import BrandedButton from '../BrandedButton.vue'
@@ -637,8 +666,10 @@ const props = defineProps<{
637
666
  // the host's padding. Used on the standalone explore page where the table
638
667
  // should span the whole screen while the toolbar stays inside the container.
639
668
  fullBleed?: boolean
640
- // When set, searches across multiple text columns using the Tabular API's
641
- // or(...) parameter. Each text/varchar column gets a __contains filter.
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.
642
673
  // Note: combined via AND with any existing column-specific `contains` filters,
643
674
  // so it acts as an additional narrowing constraint, not a replacement.
644
675
  globalSearch?: string
@@ -648,11 +679,23 @@ const props = defineProps<{
648
679
  // Initial filters applied on mount, e.g. { 'Administration': { contains: 'Ministère' } }.
649
680
  // Used when navigating from the detail page badge with query params.
650
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[]
651
688
  }>()
652
689
 
653
- const emit = defineEmits<{
654
- rowClick: [row: TabularRow]
655
- }>()
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
+ )
656
699
 
657
700
  const { t } = useTranslation()
658
701
  const config = useComponentsConfig()
@@ -712,8 +755,8 @@ const dataQuery = computed(() => {
712
755
  }
713
756
  }
714
757
  if (props.globalSearch && profileData.value?.profile) {
715
- const textCols = allColumns.value.filter(c => getColumnType(c) === 'text')
716
- q.or = '(' + textCols.map(c => c + '__contains.' + encodeURIComponent(props.globalSearch!)).join(',') + ')'
758
+ const conditions = buildGlobalSearchConditions(allColumns.value, getColumnType, props.globalSearch)
759
+ q.or = '(' + conditions.join(',') + ')'
717
760
  }
718
761
  return q
719
762
  })
@@ -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,7 +62,7 @@ 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[]
@@ -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())