Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e4f3367
feat: new explorer
ThibaudDauce Mar 2, 2026
9799035
add sort
ThibaudDauce Mar 2, 2026
434427e
add filters
ThibaudDauce Mar 2, 2026
cfe95b7
More tabular explore code
ThibaudDauce Mar 2, 2026
1bd1ad3
more code on explore
ThibaudDauce Mar 2, 2026
c46fd9f
resize columns
ThibaudDauce Mar 2, 2026
f86861a
more explorer features
ThibaudDauce Mar 2, 2026
df09a7b
better resizing on last column
ThibaudDauce Mar 2, 2026
1ef9f9e
fix colors and resizing
ThibaudDauce Mar 2, 2026
9b447e4
fix wrong v-if
ThibaudDauce Mar 2, 2026
511fa11
review fixes
ThibaudDauce Mar 12, 2026
7f49fe7
add new public page and add mobile version
ThibaudDauce Mar 12, 2026
34d9c7b
Merge branch 'main' into new_explorer
ThibaudDauce Mar 12, 2026
73b2b0a
boolean filters and remove custom tailwind values
ThibaudDauce Mar 12, 2026
66dc4cc
fix boolean filter bg color
ThibaudDauce Mar 12, 2026
0cea036
more cleanups
ThibaudDauce Mar 12, 2026
695ced0
remove more custom tailwind values
ThibaudDauce Mar 12, 2026
eb099a2
remove more custom values
ThibaudDauce Mar 12, 2026
eb1b9d7
fix lint
ThibaudDauce Mar 12, 2026
661c595
Merge branch 'main' into new_explorer
ThibaudDauce Mar 12, 2026
1a73429
claude review
ThibaudDauce Mar 23, 2026
8d1c2b2
claude review v2
ThibaudDauce Mar 23, 2026
5e3ff84
claude review v3
ThibaudDauce Mar 23, 2026
53dd9a2
claude review v4
ThibaudDauce Mar 23, 2026
8b4b2e6
claude review v5
ThibaudDauce Mar 23, 2026
834bfcf
Merge branch 'main' into new_explorer
ThibaudDauce Mar 23, 2026
96e3d76
some fixes
ThibaudDauce Mar 23, 2026
fe9eb09
some fixes
ThibaudDauce Mar 23, 2026
bd01175
remove proxy
ThibaudDauce Mar 23, 2026
da26b39
switch example to preprod and add max width
ThibaudDauce Mar 23, 2026
226cc72
fix types
ThibaudDauce Mar 23, 2026
f0cfec2
Merge branch 'main' into new_explorer
ThibaudDauce Mar 23, 2026
5b70f43
add margin right to prevent overlaping between handle and filter
ThibaudDauce Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions components/GristTableViewer/GristTableViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@

<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { AnimatedLoader } from '@datagouv/components-next'
import { AnimatedLoader, useFormatTabular } from '@datagouv/components-next'

interface GristRecord {
id: number
Expand Down Expand Up @@ -136,18 +136,7 @@ const total = computed(() => {
return sum
})

function formatNumber(value: string | number | boolean | null | undefined): string {
if (value === null || value === undefined) {
return '-'
}

const num = Number(value)
if (isNaN(num)) {
return String(value)
}

return num.toLocaleString('fr-FR')
}
const { formatNumber } = useFormatTabular()

function getFieldValue(record: GristRecord, column: string): string {
const value = record.fields[column]
Expand Down
1 change: 1 addition & 0 deletions datagouv-components/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default defineConfigWithVueTs(
// which are required for proper typography (e.g., spaces before punctuation marks like ?, !, :)
'no-irregular-whitespace': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
skipFormatting,
Expand Down
53 changes: 53 additions & 0 deletions datagouv-components/src/components/InfiniteLoader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<div ref="sentinel">
<slot>
<div class="flex items-center justify-center p-4">
<span class="inline-flex items-center gap-2 text-xs text-gray-medium">
<RiLoader4Line
class="size-4 animate-spin"
aria-hidden="true"
/>
{{ t('Chargement…') }}
</span>
</div>
</slot>
</div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import { RiLoader4Line } from '@remixicon/vue'
import { useTranslation } from '../composables/useTranslation'

const props = defineProps<{
root?: HTMLElement | null
}>()

const emit = defineEmits<{
intersect: []
}>()

const { t } = useTranslation()

const sentinelRef = useTemplateRef<HTMLElement>('sentinel')
let observer: IntersectionObserver | null = null

function setupObserver() {
observer?.disconnect()
const el = sentinelRef.value
if (!el) return
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
emit('intersect')
}
},
{ root: props.root ?? null, rootMargin: '200px' },
)
observer.observe(el)
}

onMounted(setupObserver)
watch([sentinelRef, () => props.root], setupObserver)
onUnmounted(() => observer?.disconnect())
</script>
20 changes: 10 additions & 10 deletions datagouv-components/src/components/ResourceAccordion/Preview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@
>
<BrandedButton
color="tertiary"
:icon="isSortedBy(col) && sortConfig && sortConfig.type == 'asc' ? RiArrowUpLine : RiArrowDownLine"
:icon="isSortedBy(col) && sortConfig && sortConfig.direction === 'asc' ? RiArrowUpLine : RiArrowDownLine"
icon-right
size="xs"
@click="sortByField(col)"
>
<!-- There is a weird bug with `sr-only`, I needed to add a relative parent to avoid full page x scrolling into the void… -->
<span class="relative">
{{ col }}
<span class="sr-only">{{ sortConfig && sortConfig.type == 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }}</span>
<span class="sr-only">{{ sortConfig && sortConfig.direction === 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }}</span>
</span>
</BrandedButton>
</th>
Expand Down Expand Up @@ -122,7 +122,7 @@ const rows = ref<Array<Record<string, unknown>>>([])
const columns = ref<Array<string>>([])
const loading = ref(true)
const hasError = ref(false)
const sortConfig = ref<SortConfig>(null)
const sortConfig = ref<SortConfig | null>(null)
const rowCount = ref(0)
const config = useComponentsConfig()
const pageSize = computed(() => config.tabularApiPageSize || 15)
Expand All @@ -138,7 +138,7 @@ function isSortedBy(col: string) {
/**
* Retrieve preview necessary infos
*/
async function getTableInfos(page: number, sortConfig?: SortConfig) {
async function getTableInfos(page: number, sortConfig?: SortConfig | null) {
try {
// Check that this function return wanted data
const response = await getData(config, props.resource.id, page, sortConfig)
Expand Down Expand Up @@ -172,24 +172,24 @@ function changePage(page: number) {
* Sort by a specific column
*/
function sortByField(col: string) {
if (sortConfig.value && sortConfig.value.column == col) {
if (sortConfig.value.type == 'asc') {
sortConfig.value.type = 'desc'
if (sortConfig.value && sortConfig.value.column === col) {
if (sortConfig.value.direction === 'asc') {
sortConfig.value.direction = 'desc'
}
else {
sortConfig.value.type = 'asc'
sortConfig.value.direction = 'asc'
}
}
else {
if (!sortConfig.value) {
sortConfig.value = {
column: col,
type: 'asc',
direction: 'asc',
}
}
else {
sortConfig.value.column = col
sortConfig.value.type = 'asc'
sortConfig.value.direction = 'asc'
}
}
currentPage.value = 1
Expand Down
51 changes: 51 additions & 0 deletions datagouv-components/src/components/TabularExplorer/TabularCell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<template>
<span
v-if="value == null || value === ''"
class="font-[Inconsolata,monospace] text-gray-low italic"
:class="compact ? 'text-xs' : 'text-sm'"
>null</span>
<span
v-else-if="columnType === 'boolean'"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs"
:class="isTruthy(value) ? 'bg-new-success-light text-new-success' : 'bg-new-warning-light text-new-error'"
>
<span
class="size-2 rounded-full"
:class="isTruthy(value) ? 'bg-new-success' : 'bg-new-error'"
/>
{{ isTruthy(value) ? t('Vrai') : t('Faux') }}
</span>
<span
v-else-if="columnType === 'categorical'"
class="inline-block rounded font-medium px-2 py-0.5 text-xs max-w-full truncate"
:style="categoryBadgeStyle ? { backgroundColor: categoryBadgeStyle.backgroundColor, color: categoryBadgeStyle.color } : undefined"
>{{ value }}</span>
<span
v-else-if="columnType === 'number'"
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
>{{ formatNumber(value) }}</span>
<span
v-else-if="columnType === 'date'"
:class="compact ? 'font-mono tabular-nums text-xs text-gray-title' : ''"
>{{ formatCellDate(value) }}</span>
<span
v-else
class="text-gray-title truncate block text-xs"
>{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span>
</template>

<script setup lang="ts">
import { useTranslation } from '../../composables/useTranslation'
import { useFormatTabular, isTruthy } from '../../functions/tabular'
import type { ColumnType, BadgeStyle } from './types'

defineProps<{
value: unknown
columnType: ColumnType
categoryBadgeStyle?: BadgeStyle
compact?: boolean
}>()

const { t } = useTranslation()
const { formatNumber, formatCellDate } = useFormatTabular()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<template>
<ClientOnly>
<Teleport to="#tooltips">
<div
v-if="cell"
ref="panel"
class="bg-white border border-black/10 rounded-lg shadow-md w-80 absolute z-[800]"
:style="floatingStyles"
>
<!-- Value -->
<div class="px-3 pt-3 pb-2 border-b border-gray-default">
<p class="text-[10px] text-gray-plain mb-0">
{{ t('Valeur brute') }}
</p>
<p class="text-xs text-gray-title mb-0">
{{ displayValue }}
</p>
</div>

<!-- Type -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-default">
<span class="text-[10px] text-gray-plain">{{ t('Type') }}</span>
<span class="inline-flex items-center gap-1 bg-gray-some rounded px-1.5 py-0.5 text-xs text-gray-plain">
<component
:is="typeIcon"
class="size-3"
aria-hidden="true"
/>
{{ typeLabel }}
</span>
<span class="text-[10px] text-gray-plain shrink-0">·</span>
<span class="text-[10px] text-gray-plain truncate min-w-0">{{ cell.column }}</span>
</div>

<!-- Actions -->
<div class="p-1">
<button
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
@click="filterByValue"
>
<RiFilter2Line
class="size-4"
aria-hidden="true"
/>
{{ t('Filtrer par cette valeur') }}
</button>
<button
class="flex items-center gap-2.5 w-full px-3 py-2 rounded-md text-xs font-medium hover:bg-gray-50"
@click="copyValue"
>
<RiCheckLine
v-if="copied"
class="size-4 text-green-500"
aria-hidden="true"
/>
<RiFileCopyLine
v-else
class="size-4 text-gray-plain"
aria-hidden="true"
/>
{{ copied ? t('Copié !') : t('Copier la valeur') }}
</button>
</div>
</div>
</Teleport>
</ClientOnly>
</template>

<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue'
import { flip, shift, autoUpdate, useFloating } from '@floating-ui/vue'
import { onClickOutside } from '@vueuse/core'
import {
RiFilter2Line,
RiFileCopyLine,
RiCheckLine,
} from '@remixicon/vue'
import { toast } from 'vue-sonner'
import { useTranslation } from '../../composables/useTranslation'
import { buildTypeConfig } from '../../functions/tabular'
import ClientOnly from '../ClientOnly.vue'
import type { ColumnType, ColumnFilters } from './types'

export interface CellInfo {
column: string
columnType: ColumnType
value: unknown
element: HTMLElement
}

const cell = defineModel<CellInfo | null>('cell', { default: null })
const filters = defineModel<Record<string, ColumnFilters>>('filters', { default: () => ({}) })

const { t } = useTranslation()

const panelRef = useTemplateRef<HTMLElement>('panel')
const anchorRef = ref<HTMLElement | null>(null)

watch(cell, (c) => {
anchorRef.value = c?.element ?? null
})

const { floatingStyles } = useFloating(anchorRef, panelRef, {
placement: 'bottom-start',
middleware: [flip(), shift()],
whileElementsMounted: autoUpdate,
})

const displayValue = computed(() => {
if (!cell.value) return ''
const v = cell.value.value
if (v == null || v === '') return '–'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
})

const typeConfig = buildTypeConfig(t)

const typeIcon = computed(() => cell.value ? typeConfig[cell.value.columnType].icon : typeConfig.text.icon)
const typeLabel = computed(() => cell.value ? typeConfig[cell.value.columnType].label : '')

function close() {
cell.value = null
}

function filterByValue() {
if (!cell.value) return
const val = String(cell.value.value ?? '')
const col = cell.value.column
const existing = filters.value[col] ?? {}
if (cell.value.columnType === 'categorical' || cell.value.columnType === 'text' || cell.value.columnType === 'date') {
const current = existing.in ?? []
if (!current.includes(val)) {
filters.value = { ...filters.value, [col]: { ...existing, in: [...current, val] } }
}
}
else if (cell.value.columnType === 'number') {
const num = Number(cell.value.value)
if (Number.isFinite(num)) {
filters.value = { ...filters.value, [col]: { ...existing, min: num, max: num } }
}
}
else if (cell.value.columnType === 'boolean') {
filters.value = { ...filters.value, [col]: { ...existing, exact: val } }
}
close()
}

const copied = ref(false)

async function copyValue() {
try {
await navigator.clipboard.writeText(displayValue.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 1500)
}
catch {
toast.error(t('Impossible de copier dans le presse-papier'))
}
}

onClickOutside(panelRef, (e) => {
if (!cell.value) return
const clickedCell = (e.target as HTMLElement).closest('[data-cell]')
if (clickedCell && clickedCell === cell.value.element) return
close()
})
</script>
Loading
Loading