diff --git a/datagouv-components/src/components/InfiniteLoader.vue b/datagouv-components/src/components/InfiniteLoader.vue new file mode 100644 index 000000000..67244faed --- /dev/null +++ b/datagouv-components/src/components/InfiniteLoader.vue @@ -0,0 +1,53 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue b/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue new file mode 100644 index 000000000..55e1c1fde --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularCellPopover.vue @@ -0,0 +1,172 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue b/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue new file mode 100644 index 000000000..84389cf5e --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularExplorer.vue @@ -0,0 +1,677 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue b/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue new file mode 100644 index 000000000..41a5d236b --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/TabularFilterPopover.vue @@ -0,0 +1,341 @@ + + + diff --git a/datagouv-components/src/components/TabularExplorer/types.ts b/datagouv-components/src/components/TabularExplorer/types.ts new file mode 100644 index 000000000..739083dcd --- /dev/null +++ b/datagouv-components/src/components/TabularExplorer/types.ts @@ -0,0 +1,77 @@ +/** 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[] + min?: number + max?: number + contains?: string + null?: 'only' | 'exclude' +} + +export type SortDirection = 'asc' | 'desc' + +export interface SortConfig { + column: string + direction: SortDirection +} diff --git a/datagouv-components/src/functions/api.ts b/datagouv-components/src/functions/api.ts index dc037e6d4..c70485f1e 100644 --- a/datagouv-components/src/functions/api.ts +++ b/datagouv-components/src/functions/api.ts @@ -16,6 +16,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') @@ -28,35 +29,37 @@ export async function useFetch( status.value = 'pending' try { data.value = await ofetch(urlValue, { - baseURL: config.apiBase, + ...(!isRaw && { baseURL: config.apiBase }), params: params ?? query, - onRequest(param) { - if (config.onRequest) { - if (Array.isArray(config.onRequest)) { - config.onRequest.forEach(r => r(param)) + ...(!isRaw && { + onRequest(param) { + if (config.onRequest) { + if (Array.isArray(config.onRequest)) { + config.onRequest.forEach(r => r(param)) + } + else { + config.onRequest(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) } - } - 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 = {} + if (locale) { + if (!options.params) { + options.params = {} + } + options.params['lang'] = locale } - options.params['lang'] = locale - } - }, - onRequestError: config.onRequestError, - onResponse: config.onResponse, - onResponseError: config.onResponseError, + }, + onRequestError: config.onRequestError, + onResponse: config.onResponse, + onResponseError: config.onResponseError, + }), }) status.value = 'success' } diff --git a/datagouv-components/src/functions/api.types.ts b/datagouv-components/src/functions/api.types.ts index e72731e12..695b2caaa 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/main.ts b/datagouv-components/src/main.ts index 6e8d7fd25..6800cd79d 100644 --- a/datagouv-components/src/main.ts +++ b/datagouv-components/src/main.ts @@ -93,6 +93,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' @@ -316,4 +318,6 @@ export { SearchInput, SearchableSelect, SelectGroup, + InfiniteLoader, + TabularExplorer, } diff --git a/figma-prototype.png b/figma-prototype.png new file mode 100644 index 000000000..a93556d3a Binary files /dev/null and b/figma-prototype.png differ diff --git a/filter-popup.png b/filter-popup.png new file mode 100644 index 000000000..d0c68fe36 Binary files /dev/null and b/filter-popup.png differ diff --git a/header-detail.png b/header-detail.png new file mode 100644 index 000000000..71b115c71 Binary files /dev/null and b/header-detail.png differ diff --git a/header-zoom.png b/header-zoom.png new file mode 100644 index 000000000..523793c40 Binary files /dev/null and b/header-zoom.png differ diff --git a/pages/design/tabular-explorer.vue b/pages/design/tabular-explorer.vue new file mode 100644 index 000000000..ab128f9f3 --- /dev/null +++ b/pages/design/tabular-explorer.vue @@ -0,0 +1,60 @@ + + + diff --git a/server/routes/tabular/[...path].get.ts b/server/routes/tabular/[...path].get.ts new file mode 100644 index 000000000..1011840b2 --- /dev/null +++ b/server/routes/tabular/[...path].get.ts @@ -0,0 +1,8 @@ +export default defineEventHandler(async (event) => { + const path = getRouterParam(event, 'path') + const query = getQuery(event) + const config = useRuntimeConfig() + const qs = new URLSearchParams(query as Record).toString() + const url = `${config.public.tabularApiUrl}/api/${path}/${qs ? `?${qs}` : ''}` + return $fetch(url) +}) diff --git a/utils/api.ts b/utils/api.ts index 8e0c8da25..1b138d862 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -18,29 +18,32 @@ export async function useAPI( const redirectOn404 = options && 'redirectOn404' in options && options.redirectOn404 const redirectOnSlug = options && 'redirectOnSlug' in options && options.redirectOnSlug + const isRaw = options && 'raw' in options && options.raw const response = await useFetch(url, { ...options, - $fetch: redirectOn404 ? useNuxtApp().$apiWith404 : useNuxtApp().$api, + ...(!isRaw && { $fetch: redirectOn404 ? useNuxtApp().$apiWith404 : useNuxtApp().$api }), }) - const data = toValue(response.data) || {} + if (!isRaw) { + const data = toValue(response.data) || {} - 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 (redirectOnSlug && redirectOnSlug in route.params && 'slug' in data && route.params[redirectOnSlug] !== data.slug) { + const newParams = { ...route.params } + newParams[redirectOnSlug] = data.slug as string - await nuxtApp.runWithContext(() => navigateTo({ name: route.name, params: newParams, query: route.query, hash: route.hash }, { redirectCode: 301 })) - } - - 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) + } } }