Skip to content

Commit ee30a41

Browse files
committed
feat: new resources explorer
1 parent d9ad265 commit ee30a41

File tree

5 files changed

+808
-82
lines changed

5 files changed

+808
-82
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<template>
2+
<div v-if="allResources.length">
3+
<div class="flex gap-6">
4+
<div class="flex-1 min-w-0">
5+
<ResourceExplorerViewer
6+
v-if="selectedResource"
7+
:key="selectedResource.id"
8+
:dataset
9+
:resource="selectedResource"
10+
/>
11+
</div>
12+
<ResourceExplorerSidebar
13+
:resources="allResources"
14+
:selected-resource-id="selectedResource?.id ?? null"
15+
:collapsed="sidebarCollapsed"
16+
@select="selectResource"
17+
@update:collapsed="sidebarCollapsed = $event"
18+
/>
19+
</div>
20+
</div>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { RESOURCE_TYPE, type DatasetV2, type Resource, type ResourceType } from '@datagouv/components-next'
25+
import type { PaginatedArray } from '~/types/types'
26+
import type { ResourceGroup } from './ResourceExplorerSidebar.vue'
27+
28+
const props = defineProps<{
29+
dataset: DatasetV2
30+
}>()
31+
32+
const route = useRoute()
33+
const router = useRouter()
34+
const { $api } = useNuxtApp()
35+
36+
const sidebarCollapsed = ref(false)
37+
38+
const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
39+
40+
const rawResourcesByTypes = await Promise.all(
41+
RESOURCE_TYPE.map(type =>
42+
useAPI<PaginatedArray<Resource>>(url, {
43+
query: { type, page_size: 50 },
44+
}),
45+
),
46+
)
47+
48+
const allResources = computed<ResourceGroup[]>(() => {
49+
return RESOURCE_TYPE
50+
.map((type, index) => ({
51+
type: type as ResourceType,
52+
items: rawResourcesByTypes[index].data.value?.data ?? [],
53+
}))
54+
.filter(group => group.items.length > 0)
55+
})
56+
57+
const flatResources = computed(() =>
58+
allResources.value.flatMap(g => g.items),
59+
)
60+
61+
const selectedResource = ref<Resource | null>(null)
62+
const selectionInitialized = ref(false)
63+
64+
const selectResource = (resource: Resource) => {
65+
selectedResource.value = resource
66+
selectionInitialized.value = true
67+
router.replace({
68+
query: { ...route.query, resource_id: resource.id },
69+
})
70+
}
71+
72+
// Sélection initiale : resource_id de l'URL ou premier fichier du premier groupe (dans l'ordre RESOURCE_TYPE)
73+
watchEffect(async () => {
74+
if (selectionInitialized.value) return
75+
76+
const resourceId = route.query.resource_id as string | undefined
77+
if (resourceId) {
78+
const existing = flatResources.value.find(r => r.id === resourceId)
79+
if (existing) {
80+
selectedResource.value = existing
81+
selectionInitialized.value = true
82+
return
83+
}
84+
try {
85+
const fetched = await $api<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${resourceId}/`)
86+
selectedResource.value = fetched
87+
selectionInitialized.value = true
88+
return
89+
}
90+
catch {
91+
// fallback ci-dessous
92+
}
93+
}
94+
95+
// Prend le 1er élément du 1er groupe non-vide (dans l'ordre RESOURCE_TYPE : main d'abord)
96+
if (flatResources.value.length > 0) {
97+
selectedResource.value = flatResources.value[0]
98+
selectionInitialized.value = true
99+
}
100+
})
101+
</script>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<template>
2+
<aside
3+
v-if="!collapsed"
4+
class="w-72 shrink-0 border-l border-gray-default pl-4"
5+
>
6+
<div class="flex items-center justify-between mb-3">
7+
<h3 class="text-sm font-bold uppercase mb-0">
8+
{{ $t('Ressources') }}
9+
</h3>
10+
<button
11+
type="button"
12+
:title="$t('Masquer le panneau')"
13+
class="p-1 hover:bg-gray-100 rounded"
14+
@click="$emit('update:collapsed', true)"
15+
>
16+
<RiArrowRightSLine class="size-5" />
17+
</button>
18+
</div>
19+
20+
<div class="mb-3">
21+
<label
22+
:for="searchId"
23+
class="sr-only"
24+
>{{ $t('Rechercher') }}</label>
25+
<input
26+
:id="searchId"
27+
v-model="search"
28+
type="search"
29+
class="w-full border border-gray-default rounded px-2.5 py-1.5 text-sm"
30+
:placeholder="$t('Rechercher')"
31+
>
32+
</div>
33+
34+
<div class="space-y-4 overflow-y-auto max-h-[60vh]">
35+
<div
36+
v-for="group in filteredGroups"
37+
:key="group.type"
38+
>
39+
<div class="text-xs text-gray-plain font-bold uppercase mb-1.5 border-b border-gray-default pb-1">
40+
{{ getResourceLabel(group.type, group.items.length) }}
41+
</div>
42+
<ul class="list-none p-0 m-0 space-y-0.5">
43+
<li
44+
v-for="resource in group.items"
45+
:key="resource.id"
46+
>
47+
<button
48+
type="button"
49+
class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center gap-1.5 hover:bg-gray-100"
50+
:class="{
51+
'font-bold bg-blue-50': resource.id === selectedResourceId,
52+
}"
53+
@click="$emit('select', resource)"
54+
>
55+
<ResourceIcon
56+
:resource
57+
class="size-3.5 shrink-0"
58+
/>
59+
<span class="truncate">{{ resource.title || $t('Fichier sans nom') }}</span>
60+
</button>
61+
</li>
62+
</ul>
63+
</div>
64+
</div>
65+
</aside>
66+
67+
<div
68+
v-else
69+
class="shrink-0 flex items-start pt-1"
70+
>
71+
<button
72+
type="button"
73+
:title="$t('Afficher le panneau des ressources')"
74+
class="p-1 hover:bg-gray-100 rounded border border-gray-default"
75+
@click="$emit('update:collapsed', false)"
76+
>
77+
<RiArrowLeftSLine class="size-5" />
78+
</button>
79+
</div>
80+
</template>
81+
82+
<script setup lang="ts">
83+
import { computed, useId } from 'vue'
84+
import { ResourceIcon, getResourceLabel, type Resource, type ResourceType } from '@datagouv/components-next'
85+
import { RiArrowRightSLine, RiArrowLeftSLine } from '@remixicon/vue'
86+
87+
export interface ResourceGroup {
88+
type: ResourceType
89+
items: Resource[]
90+
}
91+
92+
const props = defineProps<{
93+
resources: ResourceGroup[]
94+
selectedResourceId: string | null
95+
collapsed: boolean
96+
}>()
97+
98+
defineEmits<{
99+
'select': [resource: Resource]
100+
'update:collapsed': [value: boolean]
101+
}>()
102+
103+
const searchId = useId()
104+
const search = ref('')
105+
106+
const filteredGroups = computed(() => {
107+
const q = search.value.toLowerCase().trim()
108+
if (!q) return props.resources.filter(g => g.items.length > 0)
109+
return props.resources
110+
.map(group => ({
111+
...group,
112+
items: group.items.filter(r =>
113+
(r.title || '').toLowerCase().includes(q),
114+
),
115+
}))
116+
.filter(g => g.items.length > 0)
117+
})
118+
</script>

0 commit comments

Comments
 (0)