diff --git a/src/components/OcSearchResultCard/OcSearchResultCard.vue b/src/components/OcSearchResultCard/OcSearchResultCard.vue index fe3051a1a9bfd146802d5ac20a1510746d7b4923..a3a5f618f42cfa069261a555372be9543d00ee45 100644 --- a/src/components/OcSearchResultCard/OcSearchResultCard.vue +++ b/src/components/OcSearchResultCard/OcSearchResultCard.vue @@ -1,20 +1,36 @@ <template> <div - class="rounded-md shadow-oc" + class="rounded-md shadow-md border bg-gray-100" > <div class="p-4"> <div id="title" class="font-semibold text-2xl"> - <i :class="icon" :title="t('resourceType.'+dcatType)" class="mr-1"></i> - {{ title }} + <i :class="icon" :title="t('resourceType.'+dcatType)" class="mr-2"></i> + <OcLink + :to="{ + name: 'community.resource', + params: { + identifier: identifier, + community: props.community.name, + resource: dcatType + } + }" + > + {{ title }} + </OcLink> + </div> + <div id="subtitle" class="text-sm text-gray-600"> + {{ t('search.searchResult.id') }}: {{ identifier }} <span v-if="version">/ {{ t('search.searchResult.version') }} {{ version }}</span> </div> - <div id="subtitle" class="text-sm text-gray-600">{{ subtitle }}</div> <div id="creators" class="mt-1 mb-1">{{ creators }}</div> <div id="description"> <p class="font-medium">{{ t('search.searchResult.description') }}</p> <p class="text-justify text-sm">{{ description }}</p> </div> </div> - <div id="footer" class="pl-4 pr-4 pt-2 pb-2 bg-gray-100 rounded-b-md text-sm flex flex-row gap-2 justify-between"> + <div id="footer" class="pl-4 pr-4 pt-2 pb-2 bg-gray-200 rounded-b-md text-sm flex flex-row gap-2 justify-between"> + <span v-if="dcatType===ResourceType.CATALOG"> + {{ catalogues }} {{ t('search.searchResult.catalogues') }} {{ t('and') }} {{ datasets }} {{ t('search.searchResult.datasets') }} + </span> <span v-if="dcatType===ResourceType.DATASET">{{ distributions +' '+ t('search.searchResult.distributions')}}</span> <span v-if="parentCatalogueTitle">{{ t('search.searchResult.parentCatalogue') + ' : ' + parentCatalogueTitle }}</span> </div> @@ -22,11 +38,12 @@ </template> <script setup lang="ts"> import { useTranslateValue } from '@/composables/useTranslateValue' -import type { OcSearchResult } from '@/declarations'; +import type { OcCommunity, OcSearchResult } from '@/declarations'; import { iconsDict } from '@/helpers/icons'; import { ResourceType, type2ResourceType } from '@/helpers/resourceType'; import { computed, type PropType } from 'vue' import { useI18n } from 'vue-i18n'; +import OcLink from '../OcLink.vue'; const { t } = useI18n() const { translateValue } = useTranslateValue() @@ -35,24 +52,34 @@ const props = defineProps({ searchResult: { type: Object as PropType<OcSearchResult>, required: true + }, + community: { + type: Object as PropType<OcCommunity>, + required: true } }) const title = computed(() => translateValue(props.searchResult.title)) const dcatType = computed(() => { - const compatibleTypes = props.searchResult['@type'].filter((type) => type in type2ResourceType) - return type2ResourceType[compatibleTypes[0]] ?? ResourceType.UNKNOWN + if (Array.isArray(props.searchResult['@type'])){ + const compatibleTypes = props.searchResult['@type'].filter((type) => type in type2ResourceType) + return type2ResourceType[compatibleTypes[0]] ?? ResourceType.UNKNOWN + } else { + return type2ResourceType[props.searchResult['@type']] ?? ResourceType.UNKNOWN + } }) const icon = computed(() => { return iconsDict[dcatType.value as string] ?? '' }) -const subtitle = computed(() => { - return props.searchResult.identifier + " / Version " + "1.0.0" -}) + +const identifier = computed(() => props.searchResult.identifier) +const version = computed(() => props.searchResult.version) const creators = computed(() => props.searchResult.creator?.map((creator) => creator.name).join('; ')) const description = computed(() => translateValue(props.searchResult.description)) const parentCatalogueTitle = computed(() => translateValue(props.searchResult.parentCatalog?.title)) const parentCatalogueIdentifier = computed(() => props.searchResult.parentCatalog?.identifier) +const catalogues = computed(() => props.searchResult.catalog?.length ?? 0) +const datasets = computed(() => props.searchResult.dataset?.length ?? 0) const distributions = computed(() => props.searchResult.distribution?.length ?? 0) </script> <style scoped> diff --git a/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts b/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..52d7b9f790e912e05fee6eb033abb5bd8463c5ee --- /dev/null +++ b/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import OcSearchResultCardSkeleton from './OcSearchResultCardSkeleton.vue' + +const meta: Meta<typeof OcSearchResultCardSkeleton> = { + component: OcSearchResultCardSkeleton +} + +export default meta +type Story = StoryObj<typeof OcSearchResultCardSkeleton> + +export const Default: Story = { + render: (args) => ({ + components: { OcSearchResultCardSkeleton }, + setup() { + return { args } + }, + template: '<OcSearchResultCardSkeleton v-bind="args" />' + }), +} diff --git a/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue b/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue new file mode 100644 index 0000000000000000000000000000000000000000..7303e2663e7b6b1683aa2f06bc963adac5cbdd4e --- /dev/null +++ b/src/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue @@ -0,0 +1,25 @@ +<template> + <div class="bg-gray-100 w-full h-44 rounded-md "> + <div class="p-4 h-36"> + <div id="title" class="font-semibold text-2xl flex flex-row gap-2 mb-2"> + <p class="w-6 h-6 bg-slate-200 rounded animate-pulse"></p> + <p class="rounded bg-slate-200 w-4/5 h-6 text-white font-semibold text-2xl animate-pulse"></p> + </div> + <div id="subtitle" class="text-sm w-1/5 h-2 bg-slate-200 mb-2"></div> + <div id="creators" class="rounded bg-slate-200 w-4/5 h-4 text-white font-semibold text-2xl animate-pulse"></div> + <div id="description"> + <p class="font-medium text-gray-500 animate-pulse">{{ t('search.searchResult.description') }}</p> + <p class="rounded text-sm bg-slate-200 h-8 w-full"></p> + </div> + </div> + <div id="footer" class="bg-gray-200 rounded-b-md h-8"> + </div> + </div> +</template> +<script setup lang="ts"> +import { useI18n } from 'vue-i18n'; + +const { t } = useI18n() +</script> +<style scoped> +</style> \ No newline at end of file diff --git a/src/declarations.ts b/src/declarations.ts index 40183281a2924b37d380362e1d1b792f4cefb32f..6c2b39a1b09f9d56adf3531aa6ae40ade1d2577e 100644 --- a/src/declarations.ts +++ b/src/declarations.ts @@ -7,6 +7,7 @@ import { rolePlatformManager, roleCatalogManager } from '@/ability' +import type { type2ResourceType } from './helpers/resourceType' export type Credentials = { email: string @@ -135,12 +136,20 @@ export type OcDistributionSummary = OcResource & { * for search page results cards */ export type OcSearchResult = OcResource & { - version?: string, + version?: string creator?: Array<OcPerson | OcOrganization> + catalog?: string[] + dataset?: string[] distribution?: string[] parentCatalog?: OcCatalog } +/** A representation of a search query */ +export type OcSearchQuery = { + queryString?: string + resourceType?: Array<keyof typeof type2ResourceType> +} + export type OcTreeNode = OcResource & { children: Array<OcTreeNode> catalog?: Array<OcTreeNode | string> diff --git a/src/helpers/icons.ts b/src/helpers/icons.ts index 24daa67ecb761ff78122678428fd7da5a4790fa3..5bfb9e427b2cf87144930537c2de834b7d15e5aa 100644 --- a/src/helpers/icons.ts +++ b/src/helpers/icons.ts @@ -12,5 +12,6 @@ export const iconsDict:Record<string, string> = { catalog: 'fa-solid fa-book-open', dataset: 'fa-solid fa-table', distribution: 'fa-solid fa-file', - service: 'fa-solid fa-cube' + service: 'fa-solid fa-cube', + search: 'fa-solid fa-magnifying-glass' } diff --git a/src/locales/en.ts b/src/locales/en.ts index 03f678fe313b0f803f683b936d0a0ca5a220c267..a90d919ac5e37084095fae684c1ddd6b5a311037 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -126,7 +126,8 @@ export default { signIn: 'Sign in', signOut: 'Sign out', register: 'Request an account', - joinCommunity: 'Join a community' + joinCommunity: 'Join a community', + search: 'Search' }, back: 'Back', next: 'Next', @@ -344,10 +345,14 @@ Greetings, } }, search: { + title: "Search", + resourceType: "Resource type", searchResult: { description: "Description", + datasets: "Datasets", distributions: "Distributions", - parentCatalogue: "Parent catalogue" + parentCatalogue: "Parent catalogue", + id: "Id" } }, resourceType: { @@ -356,5 +361,6 @@ Greetings, 'distribution': 'distribution', 'service': 'service', 'unknown': 'unknown', - } + }, + and: 'and' } diff --git a/src/locales/fr.ts b/src/locales/fr.ts index a9b2b781b4064648230d27ec828414719b66e4d0..3bbad3ac77f56a79a5b7e8b49c39b4d05bfae3bb 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -127,7 +127,8 @@ export default { signIn: 'Se connecter', signOut: 'Déconnexion', register: "Demander la création d'un compte", - joinCommunity: 'Rejoindre une communauté' + joinCommunity: 'Rejoindre une communauté', + search: 'Rechercher' }, back: 'Précédent', next: 'Suivant', @@ -359,10 +360,15 @@ Cordialement, }, }, search: { + title: "Rechercher", + resourceType: "Type de ressource", searchResult: { description: "Description", + catalogues: "Sous-catalogues", + datasets: "Jeux de données", distributions: "Distributions", - parentCatalogue: "Catalogue parent" + parentCatalogue: "Catalogue parent", + id: "Id" } }, resourceType: { @@ -371,5 +377,6 @@ Cordialement, 'distribution': 'distribution', 'service': 'service', 'unknown': 'inconnu', - } + }, + and: 'et' } diff --git a/src/pages/community/[community].search.vue b/src/pages/community/[community].search.vue new file mode 100644 index 0000000000000000000000000000000000000000..4b383704ecfe447e4e844547cde8530e8363dd90 --- /dev/null +++ b/src/pages/community/[community].search.vue @@ -0,0 +1,174 @@ +<template> + <OcLayoutSimple + :breadcrumb-items="breadcrumbItems" + :is-authenticated="accountStore.isAuthenticated" + > + <div class="flex flex-row"> + <div class="p-4 pl-8 w-2/12 ml-auto mr-auto"> + <Panel :header="t('search.resourceType')" toggleable> + <template #toggleicon="toggleIconProps"> + <i v-if="toggleIconProps.collapsed" class="fa-solid fa-chevron-down text-gray-700"/> + <i v-else class="fa-solid fa-chevron-up text-gray-700"/> + </template> + <div class="flex items-center gap-2" v-for="(dcatType, uri) in type2ResourceType" v-bind:key="uri"> + <Checkbox v-model="resourceType" :inputId="uri" name="resourceType" :value="uri" /> + <label for="resourceType" class="capitalize"> {{ t('resourceType.'+dcatType) }} </label> + </div> + </Panel> + </div> + <div class="p-4 pl-8 w-9/12 ml-auto mr-auto"> + <h1 class="font-title text-4xl uppercase font-bold mb-4 text-primary"> + {{ t('search.title') }} + </h1> + <IconField class="w-full mt-8 mb-4"> + <InputText + id="search" + v-model="queryString" + fluid + size="large" + :placeholder="t('community.homepage.searchBarPlaceholder')" + /> + <InputIcon class="fa-solid fa-magnifying-glass" :onclick="search" /> + </IconField> + <div v-if="!searching"> + <template v-for="result in searchResultObjects" v-bind:key="result"> + <OcSearchResultCard :search-result="result" :community="community" class="mb-4" /> + </template> + </div> + <div v-else> + <OcSearchResultCardSkeleton + v-for="n in itemsPerPage" + v-bind:key="n" + class="mb-4" + ></OcSearchResultCardSkeleton> + </div> + <Paginator + class="mt-8" + always-show="false" + :rows="itemsPerPage" + :total-records="searchResultList.length" + v-bind:currentPage="currentPage" + v-on:page="getResultsForPage($event.page)" + /> + </div> + </div> + </OcLayoutSimple> +</template> + +<script setup lang="ts"> +import { useTranslateValue } from '@/composables/useTranslateValue' +import { useAccountData } from '@/dataLoaders/account' +import { useCommunityData } from '@/dataLoaders/community' +import type { OcBreadcrumbItem, OcSearchResult } from '@/declarations' +import OcLayoutSimple from '@/layout/OcLayoutSimple/OcLayoutSimple.vue' +import { useAccountStore } from '@/stores/account' +import { computed, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import IconField from 'primevue/iconfield' +import InputText from 'primevue/inputtext' +import InputIcon from 'primevue/inputicon' +import OcSearchResultCard from '@/components/OcSearchResultCard/OcSearchResultCard.vue' +import OcSearchResultCardSkeleton from '@/components/OcSearchResultCardSkeleton/OcSearchResultCardSkeleton.vue' +import { getSearchResults, searchResources } from '@/sparql/search' +import Paginator from 'primevue/paginator' +import Panel from 'primevue/panel' +import Checkbox from 'primevue/checkbox' +import { type2ResourceType } from '@/helpers/resourceType' + +definePage({ + name: 'community.search', + meta: { + needsAuth: false, + loaders: [useCommunityData, useAccountData] + } +}) + +const accountStore = useAccountStore() +const { t } = useI18n() + +const { translateValue } = useTranslateValue() + +const { data: community } = useCommunityData() + +const itemsPerPage = 5 +const currentPage = ref(0) + +const queryString = ref('') +const resourceType = ref([]) + +const searchResultList = ref<string[]>([]) +const searchResultObjects = ref<OcSearchResult[]>([]) + +const searching = ref(false) +const errorMessage = ref<string | null>(null) + +const search = async () => { + if (searching.value) { + return + } + + searching.value = true + currentPage.value = 0 + try { + searchResultList.value = await searchResources( + { + queryString: queryString.value, + resourceType: resourceType.value + }, + accountStore.auth + ) + } catch (e) { + console.error(e) + errorMessage.value = (e as Error).message ?? 'Error' + } + + try { + searchResultObjects.value = await getSearchResults( + searchResultList.value.slice(0, itemsPerPage), + accountStore.auth + ) + } catch (e) { + console.error(e) + errorMessage.value = (e as Error).message ?? 'Error' + } + + searching.value = false +} + +const getResultsForPage = async (pagenumber: number) => { + searching.value = true + try { + searchResultObjects.value = await getSearchResults( + searchResultList.value.slice(pagenumber * itemsPerPage, (pagenumber + 1) * itemsPerPage), + accountStore.auth + ) + } catch (e) { + console.error(e) + errorMessage.value = (e as Error).message ?? 'Error' + } + searching.value = false +} + +const breadcrumbItems = computed<OcBreadcrumbItem[]>(() => [ + { + label: translateValue(community.value.title), + key: 'community', + type: 'community', + to: { name: 'community', params: { community: community.value.name } } + }, + { + label: t('breadcrumb.search'), + key: 'search', + type: 'search' + } +]) +</script> +<style scoped> +:deep(.p-panel-header) { + @apply bg-primary rounded-t text-white mb-2 +} + +:deep(.p-panel){ + @apply bg-gray-100 +} +</style> \ No newline at end of file diff --git a/src/sparql/search.ts b/src/sparql/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..04e66d9dd7beb84cc352f6d4feb7cfea3f3f1959 --- /dev/null +++ b/src/sparql/search.ts @@ -0,0 +1,95 @@ +import type { Credentials, OcSearchQuery, OcSearchResult } from '@/declarations' +import { executeSparqlConstruct, executeSparqlSelect } from './sparql' +import type { ContextDefinition } from 'jsonld' +import { resourceContext } from './resource' + +/** + * Get results URI of a search query + */ +export const searchResources = async (query: OcSearchQuery, auth?: Credentials) => { + let typeFilter = "" + if (query.resourceType?.length){ + typeFilter = "FILTER (?type = <" + typeFilter += query.resourceType.join('> || ?type = <') + typeFilter += ">)" + } + + let queryFilter = "" + if (query.queryString){ + queryFilter = `FILTER regex(?o, "${query.queryString}", "i")` + } + + const res = await executeSparqlSelect( + ` + SELECT DISTINCT ?resource + WHERE { + ?resource rdf:type ?type. + ${typeFilter} + FILTER EXISTS { + VALUES ?p { dct:title dct:description } + ?resource ?p ?o + ${queryFilter} + } + } + `, + { + auth: auth, + }) + + return res.map((result) => result.resource.value) +} + +/** Get searchResults objects from URI list */ +export const getSearchResults = async (resourceUriList: string[], auth?: Credentials) => { + const searchResultContext: ContextDefinition = { + ...resourceContext, + catalog: { + '@id': 'http://www.w3.org/ns/dcat#catalog', + '@type': '@id', + '@container': '@set' + }, + dataset: { + '@id': 'http://www.w3.org/ns/dcat#dataset', + '@type': '@id', + '@container': '@set' + }, + distribution: { + '@id': 'http://www.w3.org/ns/dcat#distribution', + '@type': '@id', + '@container': '@set' + }, + } + + const formattedUris = '<' + resourceUriList.join('> <') + '>' + + return await executeSparqlConstruct<OcSearchResult>( + ` + CONSTRUCT { + ?s ?p ?o. + ?s oct:graph ?g. + ?s dcat:dataset ?dataset + } + WHERE { + VALUES ?s { ${formattedUris} } + VALUES ?p { + rdf:type + dct:identifier + dct:title + dct:description + dcat:version + dct:creator + dcat:catalog + dcat:dataset + dcat:distribution + } + ?s ?p ?o. + GRAPH ?g { + ?s dct:identifier ?identifier. + } + } + `, + { + context: searchResultContext, + auth: auth, + }) +} \ No newline at end of file diff --git a/typed-router.d.ts b/typed-router.d.ts index 2a41d9f69496ada04128300fab81c10a1207a3ff..bb4d634acc9ccb77c5f192111635049f9d9c8bec 100644 --- a/typed-router.d.ts +++ b/typed-router.d.ts @@ -30,6 +30,7 @@ declare module 'vue-router/auto-routes' { 'community.dashboard.dataset': RouteRecordInfo<'community.dashboard.dataset', '/:lang/community/:community/dashboard/dataset', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'community.datasets.new': RouteRecordInfo<'community.datasets.new', '/:lang/community/:community/dataset/new', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'community.join': RouteRecordInfo<'community.join', '/:lang/community/:community/join', { community: ParamValue<true> }, { community: ParamValue<false> }>, + '/:lang/community/[community].search': RouteRecordInfo<'/:lang/community/[community].search', '/:lang/community/:community/search', { community: ParamValue<true> }, { community: ParamValue<false> }>, 'connection': RouteRecordInfo<'connection', '/:lang/connection', Record<never, never>, Record<never, never>>, 'logout': RouteRecordInfo<'logout', '/:lang/logout', Record<never, never>, Record<never, never>>, 'profile': RouteRecordInfo<'profile', '/:lang/profile', Record<never, never>, Record<never, never>>,