diff --git a/components/Datasets/FileEditModal.vue b/components/Datasets/FileEditModal.vue index 7532bfd2b..4167068eb 100644 --- a/components/Datasets/FileEditModal.vue +++ b/components/Datasets/FileEditModal.vue @@ -192,7 +192,7 @@ diff --git a/datagouv-components/src/components/Chart/ChartViewerWrapper.vue b/datagouv-components/src/components/Chart/ChartViewerWrapper.vue new file mode 100644 index 000000000..7123f6a70 --- /dev/null +++ b/datagouv-components/src/components/Chart/ChartViewerWrapper.vue @@ -0,0 +1,194 @@ + + + diff --git a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue index b22148f7d..20f11b71b 100644 --- a/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue +++ b/datagouv-components/src/components/ResourceAccordion/ResourceAccordion.vue @@ -390,6 +390,7 @@ import { getResourceFormatIcon, getResourceTitleId, detectOgcService } from '../ import BrandedButton from '../BrandedButton.vue' import { getResourceExternalUrl, getResourceFilesize } from '../../functions/datasets' import { useTranslation } from '../../composables/useTranslation' +import { useHasTabularData } from '../../composables/useHasTabularData' import Metadata from './Metadata.vue' import SchemaBadge from './SchemaBadge.vue' import ResourceIcon from './ResourceIcon.vue' @@ -426,6 +427,7 @@ const DatafairPreview = defineAsyncComponent(() => import('./Datafair.client.vue const { t } = useTranslation() const { formatRelativeIfRecentDate } = useFormatDate() +const checkTabularData = useHasTabularData() const hasPreview = computed(() => { // For JSON, PDF, and XML files, show preview. @@ -435,13 +437,7 @@ const hasPreview = computed(() => { return format === 'json' || format === 'pdf' || format === 'xml' }) -const hasTabularData = computed(() => { - // Determines if we should show the "Données" tab for tabular files AND the "Structure des données" tab (for tabular data structure) - return config.tabularApiUrl - && props.resource.extras['analysis:parsing:parsing_table'] - && !props.resource.extras['analysis:parsing:error'] - && (config.tabularAllowRemote || props.resource.filetype === 'file') -}) +const hasTabularData = computed(() => checkTabularData(props.resource)) const hasPmtiles = computed(() => { return props.resource.extras['analysis:parsing:pmtiles_url'] || props.resource.format === 'pmtiles' diff --git a/datagouv-components/src/composables/useHasTabularData.ts b/datagouv-components/src/composables/useHasTabularData.ts new file mode 100644 index 000000000..57dc1aed6 --- /dev/null +++ b/datagouv-components/src/composables/useHasTabularData.ts @@ -0,0 +1,23 @@ +import { useComponentsConfig } from '../config' +import type { Resource } from '../types/resources' + +/** + * Composable to determine if a resource has tabular data. + * This is used to show the "Données" tab for tabular files AND the "Structure des données" tab (for tabular data structure). + * + * @returns A function to check if a resource has tabular data. + */ +export const useHasTabularData = () => { + const config = useComponentsConfig() + + const hasTabularData = (resource: Resource) => { + return ( + config.tabularApiUrl + && resource.extras['analysis:parsing:parsing_table'] + && !resource.extras['analysis:parsing:error'] + && (config.tabularAllowRemote || resource.filetype === 'file') + ) + } + + return hasTabularData +} diff --git a/datagouv-components/src/composables/useResourceCapabilities.ts b/datagouv-components/src/composables/useResourceCapabilities.ts index e0c294820..8fef7644c 100644 --- a/datagouv-components/src/composables/useResourceCapabilities.ts +++ b/datagouv-components/src/composables/useResourceCapabilities.ts @@ -1,6 +1,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue' import { useComponentsConfig } from '../config' import { useTranslation } from './useTranslation' +import { useHasTabularData } from './useHasTabularData' import { detectOgcService } from '../functions/resources' import { isOrganizationCertified } from '../functions/organizations' import type { Resource } from '../types/resources' @@ -15,6 +16,7 @@ export function useResourceCapabilities( ) { const config = useComponentsConfig() const { t } = useTranslation() + const checkTabularData = useHasTabularData() const hasPreview = computed(() => { const format = toValue(resource).format?.toLowerCase() @@ -23,10 +25,7 @@ export function useResourceCapabilities( const hasTabularData = computed(() => { const r = toValue(resource) - return config.tabularApiUrl - && r.extras['analysis:parsing:parsing_table'] - && !r.extras['analysis:parsing:error'] - && (config.tabularAllowRemote || r.filetype === 'file') + return checkTabularData(r) }) const hasPmtiles = computed(() => { diff --git a/datagouv-components/src/functions/tabularApi.ts b/datagouv-components/src/functions/tabularApi.ts index 1881cc341..b6abd3aa9 100644 --- a/datagouv-components/src/functions/tabularApi.ts +++ b/datagouv-components/src/functions/tabularApi.ts @@ -6,15 +6,92 @@ export type SortConfig = { type: string } | null +export type TabularDataResponse = { + data: Array> + meta: { total: number } +} + +export type TabularAggregateType = 'avg' | 'sum' | 'count' | 'min' | 'max' + +export type FetchTabularDataOptions = { + resourceId: string + page?: number + pageSize?: number + columns?: Array | undefined + sort?: SortConfig + groupBy?: string | undefined + aggregation?: { + column: string + type: TabularAggregateType + } | undefined +} + +export type TabularProfileResponse = { + profile: { + header: Array + columns: Record + formats: Record> + profile: Record + nb_distinct: number + nb_missing_values: number + min?: number + max?: number + std?: number + mean?: number + }> + encoding: string + separator: string + categorical: Array + total_lines: number + nb_duplicates: number + columns_fields: Record + columns_labels: Record + header_row_idx: number + heading_columns: number + trailing_columns: number + } + deleted_at: string | null + dataset_id: string + indexes: null +} + /** - * Call Tabular-api to get table content + * Call Tabular-api to get table content with options object */ -export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig) { - let url = `${config.tabularApiUrl}/api/resources/${id}/data/?page=${page}&page_size=${config.tabularApiPageSize || 15}` - if (sortConfig) { - url = url + `&${sortConfig.column}__sort=${sortConfig.type}` +export async function fetchTabularData(config: PluginConfig, options: FetchTabularDataOptions): Promise { + const page = options.page ?? 1 + const pageSize = options.pageSize ?? config.tabularApiPageSize ?? 15 + let url = `${config.tabularApiUrl}/api/resources/${options.resourceId}/data/?page=${page}&page_size=${pageSize}` + if (options.columns) { + url += `&columns=${options.columns.join(',')}` + } + if (options.sort) { + url += `&${options.sort.column}__sort=${options.sort.type}` } - return await ofetch(url) + if (options.groupBy && options.aggregation?.type) { + url += `&${options.groupBy}__groupby&${options.aggregation.column}__${options.aggregation.type}` + } + return await ofetch(url) +} + +/** + * Call Tabular-api to get table content + */ +export function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig) { + return fetchTabularData(config, { resourceId: id, page, sort: sortConfig }) } /** @@ -22,5 +99,5 @@ export async function getData(config: PluginConfig, id: string, page: number, so */ export function useGetProfile() { const config = useComponentsConfig() - return (id: string) => ofetch(`${config.tabularApiUrl}/api/resources/${id}/profile/`) + return (id: string) => ofetch(`${config.tabularApiUrl}/api/resources/${id}/profile/`) } diff --git a/datagouv-components/src/main.ts b/datagouv-components/src/main.ts index 06b800670..e14fbf383 100644 --- a/datagouv-components/src/main.ts +++ b/datagouv-components/src/main.ts @@ -23,6 +23,7 @@ import type { Site } from './types/site' import type { Weight, WellType } from './types/ui' import type { User, UserReference } from './types/users' import type { Report, ReportSubject, ReportReason } from './types/reports' +import type { Chart, ChartForm, FilterCondition, Filter, AndFilters, GenericFilter, XAxisType, XAxisSortBy, SortDirection, XAxis, UnitPosition, YAxis, DataSeriesType, AggregateType, DataSeries } from './types/visualizations' import type { GlobalSearchConfig, SearchType, SortOption } from './types/search' import { getDefaultDatasetConfig, getDefaultDataserviceConfig, getDefaultReuseConfig, getDefaultGlobalSearchConfig, defaultDatasetSortOptions, defaultDataserviceSortOptions, defaultReuseSortOptions } from './types/search' @@ -98,9 +99,11 @@ import { configKey, useComponentsConfig, type PluginConfig } from './config.js' export { Toaster, toast } from 'vue-sonner' export * from './composables/useActiveDescendant' +export * from './composables/useDebouncedRef' export * from './composables/useMetrics' export * from './composables/useReuseType' export * from './composables/useTranslation' +export * from './composables/useHasTabularData' export * from './functions/activities' export * from './functions/datasets' @@ -118,6 +121,7 @@ export * from './functions/resources' export * from './functions/reuses' export * from './functions/schemas' export * from './functions/users' +export * from './functions/tabularApi' export * from './types/access_types' export type { @@ -215,6 +219,21 @@ export type { ValidataError, Weight, WellType, + Chart, + ChartForm, + FilterCondition, + Filter, + AndFilters, + GenericFilter, + XAxisType, + XAxisSortBy, + SortDirection, + XAxis, + UnitPosition, + YAxis, + DataSeriesType, + AggregateType, + DataSeries, } export { diff --git a/datagouv-components/src/types/visualizations.ts b/datagouv-components/src/types/visualizations.ts new file mode 100644 index 000000000..ce12dac34 --- /dev/null +++ b/datagouv-components/src/types/visualizations.ts @@ -0,0 +1,86 @@ +import type { TabularAggregateType } from '../functions/tabularApi' +import type { Owned, OwnedWithId } from './owned' + +// Filter types +export type FilterCondition = 'equal' | 'greater' + +export type Filter = { + column: string + condition: FilterCondition + value?: string +} + +export type AndFilters = { + filters: Array +} + +export type GenericFilter = Filter | AndFilters + +// Axis types +export type XAxisType = 'discrete' | 'continuous' + +export type XAxisSortBy = 'axis_x' | 'axis_y' + +export type SortDirection = 'asc' | 'desc' + +export type XAxis = { + column_x: string + sort_x_by?: XAxisSortBy + sort_x_direction?: SortDirection + type: XAxisType +} + +export type UnitPosition = 'prefix' | 'suffix' + +export type YAxis = { + min?: number + max?: number + label?: string + unit?: string + unit_position?: UnitPosition +} + +// Data series types +export type DataSeriesType = 'line' | 'histogram' + +export type DataSeries = { + type: DataSeriesType + column_y: string + aggregate_y: TabularAggregateType | null + resource_id: string + column_x_name_override: string | null + filters: GenericFilter | null +} + +// Chart form fields (for POST/PATCH requests) +export type ChartForm = OwnedWithId & { + title: string + description: string + private: boolean + x_axis: Partial + y_axis: Partial + series: Array> + extras: Record +} + +// Chart read fields (API responses) +export type Chart = Owned & { + id: string + title: string + slug: string + description: string + private: boolean + created_at: string + last_modified: string + deleted_at: string | null + uri: string + page: string + x_axis: XAxis + y_axis: YAxis + series: Array + extras: Record + permissions: { delete: boolean, edit: boolean, read: boolean } + metrics: { + views: number + } +} diff --git a/design-system/ChartConfigurator.vue b/design-system/ChartConfigurator.vue new file mode 100644 index 000000000..3c181cf4d --- /dev/null +++ b/design-system/ChartConfigurator.vue @@ -0,0 +1,579 @@ +