Skip to content

Commit 1a05da1

Browse files
maudetesEstelleThibaudDauce
authored
feat(cada): add minimal cada explore (#1170)
The idea is to replace the existing https://cada.data.gouv.fr/ with a minimal exploration in cdata `/explore/cada`. Replace a dedicated Flask app x mongoDB x ES with a TabularExplorer component (based on [cada Tabular API](https://tabular-api.data.gouv.fr/api/resources/3a6d6fe3-8548-43ed-ad09-72052771447c/)) and detailed pages. The homepage shows as an explore view. The generic popover is deactivated. The row `Numéro de dossier` has a link to the detailed Avis page. <img width="1893" height="1619" alt="image" src="https://github.com/user-attachments/assets/3c072f02-fe9c-497a-94fe-ab41155d07f5" /> The Avis page in `/explore/cdata/[id]` mimics previous behavior in cada.data.gouv.fr. <img width="1202" height="654" alt="image" src="https://github.com/user-attachments/assets/b9e0e335-0153-484c-8818-d6a32e11ba04" /> <img width="1302" height="850" alt="image" src="https://github.com/user-attachments/assets/ec764ac5-9b4c-4281-adf2-25ace1da04a6" /> Some functionality loss: * no facets * no full text search, we use `__contains` for text (which means that there is no text normalization) and `__exact` for numbers (Numéro de dossier) * multiple fields are comma separated, badges are split on comma on a detailed Avis page, but explore behaviour consider these as full string * themes or conclusions are also hierarchical (on top of comma-separated), we show these as raw `Agriculture/Secteurs+Économiques` as they appear in the data For easier redirect, we keep the filter param names, ie `meaning=Favorable/Sauf vie privée`, `administration=Mairie+de+Paris`, etc. Test it locally with ``` NUXT_PUBLIC_CADA_RESOURCE_ID="3a6d6fe3-8548-43ed-ad09-72052771447c" ``` Or go to https://demo.data.gouv.fr/explore/cada --------- Co-authored-by: Estelle <estelle@example.com> Co-authored-by: Thibaud Dauce <git@hadibut.fr>
1 parent dce9fe7 commit 1a05da1

20 files changed

Lines changed: 1288 additions & 70 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ jobs:
244244
NUXT_PUBLIC_HOMEPAGE_HERO_IMAGES: "hero_15.png"
245245
NUXT_SITE_URL: http://localhost:3000
246246
NUXT_PUBLIC_CHARTS_API_BASE: http://localhost:7000
247+
NUXT_PUBLIC_CADA_RESOURCE_ID: 3a6d6fe3-8548-43ed-ad09-72052771447c
247248
run: |
248249
# Start cdata server in background using pre-built artifacts
249250
PORT=3000 node .output/server/index.mjs > cdata.log 2>&1 &

datagouv-components/src/components/TabularExplorer/TabularCell.vue

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@
2121
:style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
2222
>{{ value }}</span>
2323
<span
24-
v-else-if="columnType === 'number'"
25-
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
26-
>{{ formatNumber(value) }}</span>
24+
v-else-if="columnType === 'number' || columnType === 'year'"
25+
:class="compact ? 'font-mono tabular-nums text-xs' : ''"
26+
>{{ columnType === 'year' || noNumberFormat ? value : formatNumber(value) }}</span>
2727
<span
2828
v-else-if="columnType === 'date'"
29-
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
29+
:class="compact ? 'font-mono tabular-nums text-xs' : ''"
3030
>{{ formatCellDate(value) }}</span>
3131
<span
3232
v-else
@@ -44,6 +44,7 @@ defineProps<{
4444
columnType: ColumnType
4545
categoryBadgeStyle?: BadgeStyle
4646
compact?: boolean
47+
noNumberFormat?: boolean
4748
}>()
4849
4950
const { t } = useTranslation()

datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function filterByValue() {
128128
const val = String(cell.value.value ?? '')
129129
const col = cell.value.column
130130
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') {
132132
const current = existing.in ?? []
133133
if (!current.includes(val)) {
134134
filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }

datagouv-components/src/components/TabularExplorer/TabularExplorer.vue

Lines changed: 137 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
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"
@@ -162,12 +166,24 @@
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>
@@ -231,6 +247,7 @@
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
@@ -292,8 +309,12 @@
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]">
@@ -380,14 +401,20 @@
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>
@@ -409,7 +436,9 @@
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"
@@ -445,15 +474,26 @@
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'
613654
import { useFetch } from '../../functions/api'
614655
import { useComponentsConfig } from '../../config'
615656
import { useTranslation } from '../../composables/useTranslation'
616657
import { 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'
618660
import ClientOnly from '../ClientOnly.vue'
619661
import SimpleBanner from '../SimpleBanner.vue'
620662
import 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+
638714
const { t } = useTranslation()
639715
const 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
658764
const sort = ref<SortConfig | null>(null)
659-
const filters = ref<Record<string, ColumnFilters>>({})
765+
const filters = ref<Record<string, ColumnFilters>>({ ...props.initialFilters })
660766
661767
const 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.
704809
const profileLoading = computed(() => !profileData.value && (profileStatus.value === 'idle' || profileStatus.value === 'pending'))
705810
const previewError = computed(() => error.value || profileError.value)
706811
const 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
709817
const allRows = ref<TabularRow[]>([])
@@ -743,8 +851,6 @@ async function loadNextPage() {
743851
744852
const totalLines = computed(() => profileData.value?.profile?.total_lines ?? tableData.value?.meta.total ?? 0)
745853
746-
const allColumns = computed(() => profileData.value?.profile?.header ?? [])
747-
748854
const visibleColumns = ref(new Set(allColumns.value))
749855
750856
watch(allColumns, (cols) => {
@@ -938,6 +1044,9 @@ onUnmounted(() => {
9381044
const activeCell = ref<CellInfo | null>(null)
9391045
9401046
function 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-
10481141
function getColumnProfile(col: string) {
10491142
return profileData.value?.profile?.profile?.[col] ?? null
10501143
}

0 commit comments

Comments
 (0)