diff --git a/components/GristTableViewer/GristTableViewer.vue b/components/GristTableViewer/GristTableViewer.vue index a7007e9c3..e9c023fbc 100644 --- a/components/GristTableViewer/GristTableViewer.vue +++ b/components/GristTableViewer/GristTableViewer.vue @@ -97,7 +97,7 @@ diff --git a/datagouv-components/src/components/ResourceAccordion/Preview.vue b/datagouv-components/src/components/ResourceAccordion/Preview.vue index 71505774e..c8b3bae9b 100644 --- a/datagouv-components/src/components/ResourceAccordion/Preview.vue +++ b/datagouv-components/src/components/ResourceAccordion/Preview.vue @@ -48,7 +48,7 @@ > {{ col }} - {{ sortConfig && sortConfig.type == 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }} + {{ sortConfig && sortConfig.direction === 'desc' ? t("Trier par ordre croissant") : t("Trier par ordre décroissant") }} @@ -122,7 +122,7 @@ const rows = ref>>([]) const columns = ref>([]) const loading = ref(true) const hasError = ref(false) -const sortConfig = ref(null) +const sortConfig = ref(null) const rowCount = ref(0) const config = useComponentsConfig() const pageSize = computed(() => config.tabularApiPageSize || 15) @@ -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) @@ -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 diff --git a/datagouv-components/src/components/TabularExplorer/TabularCell.vue b/datagouv-components/src/components/TabularExplorer/TabularCell.vue new file mode 100644 index 000000000..d69abbb88 --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularCell.vue @@ -0,0 +1,51 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue b/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue new file mode 100644 index 000000000..73e1b391e --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue @@ -0,0 +1,170 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue b/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue new file mode 100644 index 000000000..2d01d52b3 --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue @@ -0,0 +1,870 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularFilterContent.vue b/datagouv-components/src/components/TabularExplorer/TabularFilterContent.vue new file mode 100644 index 000000000..23a0ca2f3 --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularFilterContent.vue @@ -0,0 +1,351 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue b/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue new file mode 100644 index 000000000..8a944f382 --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue @@ -0,0 +1,111 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/types.ts b/datagouv-components/src/components/TabularExplorer/types.ts new file mode 100644 index 000000000..9c9fe0657 --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/types.ts @@ -0,0 +1,83 @@ +/** Response from /api/resources/{rid}/data/ */ +export interface TabularDataResponse { + data: TabularRow[] + meta: { + page: number + page_size: number + total: number + } + links: { + profile: string + swagger: string + next: string | null + prev: string | null + } +} + +export type TabularRow = Record & { __id: number } + +/** Response from /api/resources/{rid}/profile/ */ +export interface TabularProfileResponse { + profile: TabularProfile +} + +export interface TabularProfile { + header: string[] + columns: Record + formats: Record + profile: Record + encoding: string + separator: string + categorical: string[] + total_lines: number + nb_duplicates: number + columns_fields: unknown + columns_labels: unknown + header_row_idx: number + heading_columns: number + trailing_columns: number +} + +export interface TabularColumnInfo { + score: number + format: string + python_type: string +} + +export interface TabularColumnProfile { + tops: TabularTopValue[] + nb_distinct: number + nb_missing_values: number + min?: number + max?: number + std?: number + mean?: number +} + +export interface TabularTopValue { + value: string + count: number +} + +export type ColumnType = 'number' | 'categorical' | 'text' | 'date' | 'boolean' + +export interface ColumnFilters { + in?: string[] + exact?: string + min?: number + max?: number + contains?: string + null?: 'only' | 'exclude' +} + +export type SortDirection = 'asc' | 'desc' + +export interface SortConfig { + column: string + direction: SortDirection +} + +export interface BadgeStyle { + backgroundColor: string + color: string +} diff --git a/datagouv-components/src/functions/api.ts b/datagouv-components/src/functions/api.ts index f27303cf6..51c878117 100644 --- a/datagouv-components/src/functions/api.ts +++ b/datagouv-components/src/functions/api.ts @@ -24,6 +24,7 @@ export async function useFetch( return await config.customUseFetch(url, options) } + const isRaw = options?.raw const data: Ref = ref(null) const error: Ref = ref(null) const status = ref('idle') @@ -35,37 +36,39 @@ export async function useFetch( const query = deepToValue(options?.query) status.value = 'pending' try { - data.value = await ofetch(urlValue, { - baseURL: config.apiBase, - params: params ?? query, - onRequest(param) { - if (config.onRequest) { - if (Array.isArray(config.onRequest)) { - config.onRequest.forEach(r => r(param)) - } - else { - config.onRequest(param) - } - } - const { options } = param - options.headers.set('Content-Type', 'application/json') - options.headers.set('Accept', 'application/json') - options.credentials = 'include' - if (config.devApiKey) { - options.headers.set('X-API-KEY', config.devApiKey) - } + data.value = isRaw + ? await ofetch(urlValue, { params: params ?? query }) + : await ofetch(urlValue, { + baseURL: config.apiBase, + params: params ?? query, + onRequest(param) { + if (config.onRequest) { + if (Array.isArray(config.onRequest)) { + config.onRequest.forEach(r => r(param)) + } + else { + config.onRequest(param) + } + } + const { options } = param + options.headers.set('Content-Type', 'application/json') + options.headers.set('Accept', 'application/json') + options.credentials = 'include' + if (config.devApiKey) { + options.headers.set('X-API-KEY', config.devApiKey) + } - if (locale) { - if (!options.params) { - options.params = {} - } - options.params['lang'] = locale - } - }, - onRequestError: config.onRequestError, - onResponse: config.onResponse, - onResponseError: config.onResponseError, - }) + if (locale) { + if (!options.params) { + options.params = {} + } + options.params['lang'] = locale + } + }, + onRequestError: config.onRequestError, + onResponse: config.onResponse, + onResponseError: config.onResponseError, + }) status.value = 'success' } catch (e) { @@ -90,9 +93,7 @@ export async function useFetch( return { data, - refresh: async () => { - execute() - }, + refresh: () => execute(), execute, clear: () => { data.value = null diff --git a/datagouv-components/src/functions/api.types.ts b/datagouv-components/src/functions/api.types.ts index d38f2fff7..058e7c21e 100644 --- a/datagouv-components/src/functions/api.types.ts +++ b/datagouv-components/src/functions/api.types.ts @@ -20,6 +20,7 @@ export type UseFetchOptions = { transform?: (input: DataT) => DataT | Promise pick?: string[] watch?: WatchSource[] | false + raw?: boolean } export type AsyncData = { diff --git a/datagouv-components/src/functions/tabular.ts b/datagouv-components/src/functions/tabular.ts new file mode 100644 index 000000000..2fb5233e3 --- /dev/null +++ b/datagouv-components/src/functions/tabular.ts @@ -0,0 +1,60 @@ +import type { Component } from 'vue' +import { + RiHashtag, + RiPriceTag3Line, + RiText, + RiCalendarLine, + RiCheckboxLine, +} from '@remixicon/vue' +import { useTranslation } from '../composables/useTranslation' +import type { ColumnFilters, ColumnType } from '../components/TabularExplorer/types' + +export function hasFilterForColumn(filters: Record, column: string): boolean { + const f = filters[column] + if (!f) return false + return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null) +} + +export function buildTypeConfig(t: (s: string) => string): Record { + return { + number: { icon: RiHashtag, label: t('Nombre') }, + categorical: { icon: RiPriceTag3Line, label: t('Catégoriel') }, + text: { icon: RiText, label: t('Texte') }, + date: { icon: RiCalendarLine, label: t('Date') }, + boolean: { icon: RiCheckboxLine, label: t('Booléen') }, + } +} + +export function useFormatTabular() { + const { locale } = useTranslation() + + function formatNumber(value: unknown): string { + const num = Number(value) + if (Number.isNaN(num)) return String(value) + return num.toLocaleString(locale) + } + + function formatCellDate(value: unknown): string { + if (value == null || value === '') return '–' + const d = new Date(String(value)) + if (Number.isNaN(d.getTime())) return String(value) + return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d) + } + + return { formatNumber, formatCellDate } +} + +const TRUTHY_VALUES = ['true', '1', 'oui', 'yes'] +const FALSY_VALUES = ['false', '0', 'non', 'no'] + +export function isTruthy(value: unknown): boolean { + if (typeof value === 'boolean') return value + if (typeof value === 'string') return TRUTHY_VALUES.includes(value.toLowerCase()) + return Boolean(value) +} + +export function isFalsy(value: unknown): boolean { + if (typeof value === 'boolean') return !value + if (typeof value === 'string') return FALSY_VALUES.includes(value.toLowerCase()) + return !value +} diff --git a/datagouv-components/src/functions/tabularApi.ts b/datagouv-components/src/functions/tabularApi.ts index 1881cc341..379d803a1 100644 --- a/datagouv-components/src/functions/tabularApi.ts +++ b/datagouv-components/src/functions/tabularApi.ts @@ -1,18 +1,16 @@ import { ofetch } from 'ofetch' import { useComponentsConfig, type PluginConfig } from '../config' +import type { SortConfig } from '../components/TabularExplorer/types' -export type SortConfig = { - column: string - type: string -} | null +export type { SortConfig } /** * Call Tabular-api to get table content */ -export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig) { +export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig | null) { let url = `${config.tabularApiUrl}/api/resources/${id}/data/?page=${page}&page_size=${config.tabularApiPageSize || 15}` if (sortConfig) { - url = url + `&${sortConfig.column}__sort=${sortConfig.type}` + url = url + `&${sortConfig.column}__sort=${sortConfig.direction}` } return await ofetch(url) } diff --git a/datagouv-components/src/main.ts b/datagouv-components/src/main.ts index 47abe01a2..615e75c03 100644 --- a/datagouv-components/src/main.ts +++ b/datagouv-components/src/main.ts @@ -94,6 +94,8 @@ import GlobalSearch from './components/Search/GlobalSearch.vue' import SearchInput from './components/Search/SearchInput.vue' import SearchableSelect from './components/Form/SearchableSelect.vue' import SelectGroup from './components/Form/SelectGroup.vue' +import InfiniteLoader from './components/InfiniteLoader.vue' +import TabularExplorer from './components/TabularExplorer/TabularExplorer.vue' import type { UseFetchFunction } from './functions/api.types' import { configKey, useComponentsConfig, type PluginConfig } from './config.js' @@ -120,6 +122,7 @@ export * from './functions/pagination' export * from './functions/resources' export * from './functions/reuses' export * from './functions/schemas' +export * from './functions/tabular' export * from './functions/users' export * from './types/access_types' @@ -318,4 +321,6 @@ export { SearchInput, SearchableSelect, SelectGroup, + InfiniteLoader, + TabularExplorer, } diff --git a/pages/explore.vue b/pages/explore.vue new file mode 100644 index 000000000..dd95a8a08 --- /dev/null +++ b/pages/explore.vue @@ -0,0 +1,148 @@ + + + diff --git a/utils/api.ts b/utils/api.ts index 8e0c8da25..84c3daaa6 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -6,7 +6,7 @@ import type { ApiFetch, PaginatedArray } from '~/types/types' */ export async function useAPI( url: MaybeRefOrGetter, - options?: UseFetchOptions & { redirectOn404?: boolean, redirectOnSlug?: string }, + options?: UseFetchOptions & { redirectOn404?: boolean, redirectOnSlug?: string, raw?: boolean }, ) { const { setCurrentOrganization, setCurrentUser } = useCurrentOwned() const isAdmin = isMeAdmin() @@ -18,29 +18,33 @@ export async function useAPI( const redirectOn404 = options && 'redirectOn404' in options && options.redirectOn404 const redirectOnSlug = options && 'redirectOnSlug' in options && options.redirectOnSlug - const response = await useFetch(url, { - ...options, - $fetch: redirectOn404 ? useNuxtApp().$apiWith404 : useNuxtApp().$api, - }) - - const data = toValue(response.data) || {} + const isRaw = options?.raw + const fetchOptions = { ...options } + if (!isRaw) { + fetchOptions.$fetch = redirectOn404 ? useNuxtApp().$apiWith404 : useNuxtApp().$api + } + const response = await useFetch(url, fetchOptions) - if (redirectOnSlug && redirectOnSlug in route.params && 'slug' in data && route.params[redirectOnSlug] !== data.slug) { - const newParams = { ...route.params } - newParams[redirectOnSlug] = data.slug as string + if (!isRaw) { + const data = toValue(response.data) || {} - await nuxtApp.runWithContext(() => navigateTo({ name: route.name, params: newParams, query: route.query, hash: route.hash }, { redirectCode: 301 })) - } + if (redirectOnSlug && redirectOnSlug in route.params && 'slug' in data && route.params[redirectOnSlug] !== data.slug) { + const newParams = { ...route.params } + newParams[redirectOnSlug] = data.slug as string - if (isAdmin) { - // Check the response to see if an `organization` or an `owner` is present - // to add this organization/user to the menu. - if ('organization' in data && data.organization) { - setCurrentOrganization(data.organization as OrganizationReference) + await nuxtApp.runWithContext(() => navigateTo({ name: route.name, params: newParams, query: route.query, hash: route.hash }, { redirectCode: 301 })) } - if ('owner' in data && data.owner) { - setCurrentUser(data.owner as User) + if (isAdmin) { + // Check the response to see if an `organization` or an `owner` is present + // to add this organization/user to the menu. + if ('organization' in data && data.organization) { + setCurrentOrganization(data.organization as OrganizationReference) + } + + if ('owner' in data && data.owner) { + setCurrentUser(data.owner as User) + } } }