diff --git a/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.stories.ts b/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.stories.ts index 48d3870395eb38603593be3e9b551d56a4c7706a..08c2009885358aa98de04f572aecf923dd6ae7f7 100644 --- a/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.stories.ts +++ b/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/vue3'; import OcDatasetFormStep1 from './OcDatasetFormStep1.vue'; -import type { OcOrganization, OcPerson, OcVocabulary } from '@/declarations'; +import type { OcOrganization, OcPerson, OcConcept } from '@/declarations'; const meta: Meta<typeof OcDatasetFormStep1> = { component: OcDatasetFormStep1, @@ -22,22 +22,22 @@ export const Default: Story = { modelValue: {}, personSearchCallback: (query: string) => [{ '@id': `${query}-person-id`, '@type': ['http://xmlns.com/foaf/0.1/Person'], familyName: `${query} name`, givenName: `${query} given name` } as OcPerson], organizationSearchCallback: (query: string) => [{ '@id': `${query}-orga-id`, '@type': ['http://xmlns.com/foaf/0.1/Organization'], name: { en: `${query} name` } } as OcOrganization], - themeSearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} theme` } } as OcVocabulary], - typeSearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} type` } } as OcVocabulary], + themeVocabularies: ["http://publications.europa.eu/resource/authority/data-theme"], + typeVocabularies: ["http://publications.europa.eu/resource/authority/dataset-type"], frequencyList: [ { '@id': `freq-1-id`, prefLabel: { en: `freq 1` } }, { '@id': `freq-2-id`, prefLabel: { en: `freq 2` } }, - ] as OcVocabulary[], + ] as OcConcept[], licenseList: [ { '@id': `lic-1-id`, prefLabel: { en: `license 1` } }, { '@id': `lic-2-id`, prefLabel: { en: `license 2` } }, - ] as OcVocabulary[], + ] as OcConcept[], conformsToList: [ { '@id': `conforms-1-id`, prefLabel: { en: `conforms 1` } }, { '@id': `conforms-2-id`, prefLabel: { en: `conforms 2` } }, - ] as OcVocabulary[], - languageSearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} lang` } } as OcVocabulary], + ] as OcConcept[], + languageVocabularies: ["http://publications.europa.eu/resource/authority/language"], keywordSearchCallback: (query: string) => [`${query} keyword 1`, `${query} keyword 2`], - spatialSearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} spatial` } } as OcVocabulary], + spatialVocabularies: ["http://publications.europa.eu/resource/authority/continent", "http://publications.europa.eu/resource/authority/country"], }, }; diff --git a/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.vue b/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.vue index 5c6a89075b7edcef12a608a07d024eee23ccb6be..23f349d4d6b53f42ab1ccaaacc7146a404e7134b 100644 --- a/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.vue +++ b/src/components/DatasetMultiStep/OcDatasetFormStep1/OcDatasetFormStep1.vue @@ -87,13 +87,14 @@ <Message v-if="errorMessage" severity="error">{{ errorMessage }}</Message> </Field> <Field name="theme" v-slot="{ value, handleChange, errorMessage }"> - <OcField for="theme" :metadata="formMetadata.theme"> - <OcVocabularyAutocomplete + <OcField for="theme" :metadata="formMetadata.theme" :show-fair-badges="showFair"> + <OcConceptAutocomplete inputId="theme" :model-value="value" @update:model-value="handleChange" - :vocabulary-search-callback="themeSearchCallback" + :vocabulariesUriList="themeVocabularies" :invalid="!!errorMessage" + with-browser required fluid /> @@ -115,14 +116,15 @@ <Message v-if="errorMessage" severity="error">{{ errorMessage }}</Message> </Field> <Field name="type" v-slot="{ value, handleChange, errorMessage }"> - <OcField for="type" :metadata="formMetadata.type"> - <OcVocabularyAutocomplete + <OcField for="type" :metadata="formMetadata.type" :show-fair-badges="showFair"> + <OcConceptAutocomplete inputId="type" :model-value="value" @update:model-value="handleChange" - :vocabulary-search-callback="typeSearchCallback" + :vocabulariesUriList="typeVocabularies" :invalid="!!errorMessage" :multiple="false" + with-browser required fluid /> @@ -130,12 +132,12 @@ <Message v-if="errorMessage" severity="error">{{ errorMessage }}</Message> </Field> <Field name="language" v-slot="{ value, errorMessage, handleChange }"> - <OcField for="language" :metadata="formMetadata.language"> - <OcVocabularyAutocomplete + <OcField for="language" :metadata="formMetadata.language" :show-fair-badges="showFair"> + <OcConceptAutocomplete inputId="language" :model-value="value" @update:model-value="handleChange" - :vocabulary-search-callback="languageSearchCallback" + :vocabulariesUriList="languageVocabularies" :invalid="!!errorMessage" required fluid @@ -149,7 +151,7 @@ inputId="status" :model-value="value" @update:model-value="handleChange" - :optionLabel="(item: OcVocabulary) => translateValue(item.prefLabel)" + :optionLabel="(item: OcConcept) => translateValue(item.prefLabel)" dataKey="@id" :options="statusList" :invalid="!!errorMessage" @@ -174,14 +176,15 @@ <Message v-if="errorMessage" severity="error">{{ errorMessage }}</Message> </Field> <Field name="spatial" v-slot="{ value, errorMessage, handleChange }" v-if="showFair"> - <OcField for="spatial" :metadata="formMetadata.spatial"> - <OcVocabularyAutocomplete + <OcField for="spatial" :metadata="formMetadata.spatial" :show-fair-badges="showFair"> + <OcConceptAutocomplete inputId="spatial" :model-value="value" @update:model-value="handleChange" - :vocabulary-search-callback="spatialSearchCallback" + :vocabulariesUriList="spatialVocabularies" :invalid="!!errorMessage" :multiple="false" + with-browser required fluid /> @@ -194,7 +197,7 @@ inputId="accrualPeriodicity " :model-value="value" @update:model-value="handleChange" - :optionLabel="(item: OcVocabulary) => translateValue(item.prefLabel)" + :optionLabel="(item: OcConcept) => translateValue(item.prefLabel)" dataKey="@id" :options="frequencyList" :invalid="!!errorMessage" @@ -229,7 +232,7 @@ inputId="license" :model-value="value" @update:model-value="handleChange" - :optionLabel="(item: OcVocabulary) => translateValue(item.prefLabel)" + :optionLabel="(item: OcConcept) => translateValue(item.prefLabel)" dataKey="@id" :options="licenseList" :invalid="!!errorMessage" @@ -246,7 +249,7 @@ inputId="conformsTo" :model-value="value" @update:model-value="handleChange" - :optionLabel="(item: OcVocabulary) => translateValue(item.prefLabel)" + :optionLabel="(item: OcConcept) => translateValue(item.prefLabel)" dataKey="@id" :options="conformsToList" :invalid="!!errorMessage" @@ -278,7 +281,7 @@ <script setup lang="ts"> import { Field, Form, type GenericObject } from 'vee-validate' import * as yup from 'yup' -import type { OcDataset, OcOrganization, OcPerson, OcVocabulary } from '@/declarations' +import type { OcDataset, OcOrganization, OcPerson, OcConcept } from '@/declarations' import Button from 'primevue/button' import { useI18n } from 'vue-i18n' import DatePicker from 'primevue/datepicker' @@ -287,7 +290,7 @@ import Textarea from 'primevue/textarea' import Message from 'primevue/message' import OcField from '@/components/FormInputs/OcField/OcField.vue' import OcAgentAutocomplete from '@/components/FormInputs/OcAgentAutocomplete/OcAgentAutocomplete.vue' -import OcVocabularyAutocomplete from '@/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.vue' +import OcConceptAutocomplete from '@/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.vue' import Select from 'primevue/select' import { useTranslateValue } from '@/composables/translateValue' import { datasetsMetadata } from '@/modelMetadata/datasets' @@ -309,36 +312,36 @@ defineProps({ type: Function as PropType<(query: string) => OcOrganization[]>, required: true }, - themeSearchCallback: { - type: Function as PropType<(query: string) => OcVocabulary[]>, + themeVocabularies: { + type: Array<string>, required: true }, - typeSearchCallback: { - type: Function as PropType<(query: string) => OcVocabulary[]>, + typeVocabularies: { + type: Array<string>, required: true }, frequencyList: { - type: Array as PropType<OcVocabulary[]>, + type: Array as PropType<OcConcept[]>, required: true }, statusList: { - type: Array as PropType<OcVocabulary[]>, + type: Array as PropType<OcConcept[]>, required: true }, - languageSearchCallback: { - type: Function as PropType<(query: string) => OcVocabulary[]>, + languageVocabularies: { + type: Array<string>, required: true }, - spatialSearchCallback: { - type: Function as PropType<(query: string) => OcVocabulary[]>, + spatialVocabularies: { + type: Array<string>, required: true }, licenseList: { - type: Array as PropType<OcVocabulary[]>, + type: Array as PropType<OcConcept[]>, required: true }, conformsToList: { - type: Array as PropType<OcVocabulary[]>, + type: Array as PropType<OcConcept[]>, required: true }, keywordSearchCallback: { @@ -376,36 +379,34 @@ const validationSchema = toTypedSchema( .object<OcOrganization>() .required() .label(translateValue(formMetadata.publisher.label)), - accrualPeriodicity: yup - .object<OcVocabulary>() - .required() - .label(translateValue(formMetadata.accrualPeriodicity.label)), contactPoint: yup .mixed<OcPerson | OcOrganization>() .required() .label(translateValue(formMetadata.contactPoint.label)), - type: yup.mixed<OcVocabulary>().required().label(translateValue(formMetadata.type.label)), + type: yup.mixed<OcConcept>().required().label(translateValue(formMetadata.type.label)), theme: yup .array() - .of(yup.object<OcVocabulary>()) - .ensure() + .of(yup.object<OcConcept>()) .min(1) .label(translateValue(formMetadata.theme.label)), - status: yup.mixed<OcVocabulary>().label(translateValue(formMetadata.status.label)), + accrualPeriodicity: yup + .object<OcConcept>() + .required() + .label(translateValue(formMetadata.accrualPeriodicity.label)), + status: yup.mixed<OcConcept>().label(translateValue(formMetadata.status.label)), issued: yup.date().required().label(translateValue(formMetadata.issued.label)), modified: yup.date().label(translateValue(formMetadata.modified.label)), language: yup .array() - .of(yup.object<OcVocabulary>()) - .ensure() + .of(yup.object<OcConcept>()) .min(1) .label(translateValue(formMetadata.language.label)), keyword: yup.array().of(yup.string()).label(translateValue(formMetadata.keyword.label)), temporal: yup.mixed<[Date, Date]>().label(translateValue(formMetadata.temporal.label)), - spatial: yup.mixed<OcVocabulary>().label(translateValue(formMetadata.spatial.label)), + spatial: yup.mixed<OcConcept>().label(translateValue(formMetadata.spatial.label)), landingPage: yup.string().url().label(translateValue(formMetadata.landingPage.label)), - license: yup.mixed<OcVocabulary>().label(translateValue(formMetadata.license.label)), - conformsTo: yup.mixed<OcVocabulary>().label(translateValue(formMetadata.conformsTo.label)), + license: yup.mixed<OcConcept>().label(translateValue(formMetadata.license.label)), + conformsTo: yup.mixed<OcConcept>().label(translateValue(formMetadata.conformsTo.label)), version: yup.string().label(translateValue(formMetadata.version.label)) }) ) diff --git a/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.stories.ts b/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3232531bfbaf82b123871bc69f017fcafd858d3 --- /dev/null +++ b/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/vue3'; + +import OcConceptAutocomplete from './OcConceptAutocomplete.vue'; + +const meta: Meta<typeof OcConceptAutocomplete> = { + component: OcConceptAutocomplete, +}; + +export default meta; +type Story = StoryObj<typeof OcConceptAutocomplete>; + +export const Default: Story = { + render: (args) => ({ + components: { OcConceptAutocomplete }, + setup() { + return { args }; + }, + template: '<OcConceptAutocomplete v-bind="args" />', + }), + args: { + vocabulariesUriList: ['https://data.archives-ouvertes.fr/subject', 'http://publications.europa.eu/resource/authority/country'], + }, +}; + +export const NoneMultiple: Story = { + render: (args) => ({ + components: { OcConceptAutocomplete }, + setup() { + return { args }; + }, + template: '<OcConceptAutocomplete v-bind="args" />', + }), + args: { + vocabulariesUriList: ['https://data.archives-ouvertes.fr/subject', 'http://publications.europa.eu/resource/authority/country'], + multiple: false + }, +}; + +export const WithBrowser: Story = { + render: (args) => ({ + components: { OcConceptAutocomplete }, + setup() { + return { args }; + }, + template: '<OcConceptAutocomplete v-bind="args" />', + }), + args: { + vocabulariesUriList: ['https://data.archives-ouvertes.fr/subject', 'http://publications.europa.eu/resource/authority/country'], + withBrowser: true + }, +}; \ No newline at end of file diff --git a/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.vue b/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.vue new file mode 100644 index 0000000000000000000000000000000000000000..4482829bad719e6293b9eeb6852754b8262027c0 --- /dev/null +++ b/src/components/FormInputs/OcConceptAutocomplete/OcConceptAutocomplete.vue @@ -0,0 +1,106 @@ +<template> + <div class="flex flex-col w-full"> + <div class="flex flex-row w-full gap-2"> + <AutoComplete + :inputId="$attrs.inputId" + v-model="model" + :suggestions="items" + :optionLabel="displayName" + :multiple="multiple" + :forceSelection="forceSelection" + :typeahead="forceSelection" + :minLength="1" + @complete="search($event.query)" + :loading="loading" + :pt:pcInput:root:class="{ 'w-full': true, 'border-amber-200': !!errorMessage }" + class="w-full" + /> + <Button + v-if="withBrowser" + class="p-button-secondary rounded-md whitespace-nowrap h-fit" + @click="isBrowserVisible = !isBrowserVisible" + > + {{ t('conceptAutocomplete.browseTerms') }} + </Button> + <Dialog + v-if="withBrowser" + v-model:visible="isBrowserVisible" + dismissable-mask + modal + class="max-w-[800px]" + :header="t('conceptAutocomplete.browseTerms')" + > + <OcConceptBrowser + v-model="model" + :vocabularies-uri-list="vocabulariesUriList" + :multiple="multiple" + /> + </Dialog> + </div> + <Message class="mt-2" v-if="!!errorMessage" severity="warn"> + {{ t('form.error.autocomplete') }} + </Message> + </div> +</template> + +<script setup lang="ts"> +import { useTranslateValue } from '@/composables/translateValue' +import type { OcConcept } from '@/declarations' +import AutoComplete from 'primevue/autocomplete' +import Message from 'primevue/message' +import Button from 'primevue/button'; +import Dialog from 'primevue/dialog'; +import OcConceptBrowser from '@/components/FormInputs/OcConceptBrowser/OcConceptBrowser.vue' +import { ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { queryVocabulariesList } from '@/sparql/vocabularies'; + +const { t, locale } = useI18n() +const { translateValue } = useTranslateValue() + +const props = defineProps({ + vocabulariesUriList: { + type: Array<string>, + required: true + }, + multiple: { + type: Boolean, + required: false, + default: true + }, + forceSelection: { + type: Boolean, + required: false, + default: true + }, + withBrowser: { + type: Boolean, + required: false, + default: false + } +}) +const model = defineModel<OcConcept[] | undefined>() + +const loading = ref<boolean>(false) +const errorMessage = ref<string | null>(null) +const items = ref<OcConcept[]>([]) + +const isBrowserVisible = ref(false) + +const displayName = (item: OcConcept) => translateValue(item.prefLabel) + +async function search(query: string) { + loading.value = true + errorMessage.value = null + + try { + items.value = (await queryVocabulariesList(props.vocabulariesUriList, query, locale.value)).graph + } catch (e) { + items.value = [] + errorMessage.value = (e as Error).message + console.error(e) + } + + loading.value = false +} +</script> diff --git a/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.stories.ts b/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a0293de8d969c9d4e828467df7d6bbc60363055 --- /dev/null +++ b/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.stories.ts @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import OcConceptBrowser from './OcConceptBrowser.vue' + +const meta: Meta<typeof OcConceptBrowser> = { + component: OcConceptBrowser +} + +export default meta +type Story = StoryObj<typeof OcConceptBrowser> + +export const Default: Story = { + render: (args) => ({ + components: { OcConceptBrowser }, + setup() { + return { args } + }, + template: '<OcConceptBrowser v-bind="args" />' + }), + args: { + vocabulariesUriList: ['https://data.archives-ouvertes.fr/subject', 'http://publications.europa.eu/resource/authority/country'], + } +} \ No newline at end of file diff --git a/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.vue b/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.vue new file mode 100644 index 0000000000000000000000000000000000000000..b4a1440c8a316f4f582288b9eaab7d010804328f --- /dev/null +++ b/src/components/FormInputs/OcConceptBrowser/OcConceptBrowser.vue @@ -0,0 +1,174 @@ +<template> + <div> + <Select + v-if="vocabulariesListSelectOptions.length > 1" + v-model="selectedVocabulary" + :options="vocabulariesListSelectOptions" + optionLabel="name" + placeholder="Select a vocabulary" + class="w-full" + /> + <Message class="mt-2" v-if="!!errorMessage" severity="warn"> + {{ t('form.error.autocomplete') }} + </Message> + <Tree + v-if="!loading" + :value="nodes" + v-model:selectionKeys="selectedKeys" + @node-select="onNodeSelect" + @node-unselect="onNodeUnSelect" + @node-expand="onNodeExpend" + :selectionMode="multiple ? 'multiple' : 'single'" + :loading="loading" + loading-mode="icon" + class="w-full" + /> + <ProgressSpinner v-if="loading" class="block m-auto"/> + </div> +</template> + +<script setup lang="ts"> +import Tree from 'primevue/tree' +import Select from 'primevue/select'; +import ProgressSpinner from 'primevue/progressspinner'; +import { useTranslateValue } from '@/composables/translateValue' +import type { OcConcept, OcConceptScheme } from '@/declarations' +import { computed, onBeforeMount, onMounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import type { TreeNode } from 'primevue/treenode' +import { getConceptChildren, getVocabulariesInformations, getVocabularyRootConcepts } from '@/sparql/vocabularies'; + +const { t } = useI18n() +const { translateValue } = useTranslateValue() + +const props = defineProps({ + vocabulariesUriList: { + type: Array<string>, + required: true + }, + multiple: { + type: Boolean, + required: false, + default: true + }, +}) + +const model = defineModel<OcConcept[] | undefined>() +const selectedKeys = ref() +const nodes = ref<TreeNode[]>([]) + +const selectedVocabulary = ref() +const vocabulariesList = ref<OcConceptScheme[]>([]) +const vocabulariesListSelectOptions = computed(() => vocabulariesList.value.map((vocabulary) => ({ 'code': vocabulary['@id'], 'name': translateValue(vocabulary['prefLabel']) }))) + +const loading = ref<boolean>(false) +const errorMessage = ref<string | null>(null) + +onBeforeMount(async () => { + if (props.vocabulariesUriList.length){ + loading.value = true + try { + vocabulariesList.value = (await getVocabulariesInformations(props.vocabulariesUriList)).graph + } catch (e) { + vocabulariesList.value = [] + errorMessage.value = (e as Error).message + console.error(e) + } + loading.value = false + } + if (vocabulariesListSelectOptions.value.length == 1){ + selectedVocabulary.value = vocabulariesListSelectOptions.value[0] + } +}) + +const onNodeSelect = (node: TreeNode) => { + if (props.multiple) { + if (model.value === undefined) { + model.value = [ + { + '@id': node.key, + prefLabel: node.label + } + ] + } else { + model.value = [ + ...model.value, + { + '@id': node.key, + prefLabel: node.label + } + ] + } + } else { + model.value = { + '@id': node.key, + prefLabel: node.label + } + } +} + +const onNodeUnSelect = (node: TreeNode) => { + if (!model.value) { + model.value = [] + } + model.value = model.value.filter((item) => item['@id'] != node.key) +} + +const onNodeExpend = async (node: TreeNode) => { + if (!node.children && !node.leaf) { + node.loading = true + try { + node.children = (await getConceptChildren(selectedVocabulary.value.code, node.key)).graph.map( + (term) => OcConcept2TreeNode(term) + ).sort((a, b) => a.label >= b.label) + } catch (e) { + node.children = [] + errorMessage.value = (e as Error).message + console.error(e) + } + node.loading = false + } +} + +watch(model, () => { + if (Array.isArray(model.value)) { + selectedKeys.value = model.value?.reduce((a, v) => ({ ...a, [v['@id']]: true }), {}) + } else if (model.value) { + selectedKeys.value = { [model.value['@id']]: true } + } +}) + +onMounted(() => { + if (Array.isArray(model.value)) { + selectedKeys.value = model.value?.reduce((a, v) => ({ ...a, [v['@id']]: true }), {}) + } else if (model.value) { + selectedKeys.value = { [model.value['@id']]: true } + } +}) + +watch(selectedVocabulary, async () => { + if (selectedVocabulary.value) { + loading.value = true + try { + nodes.value = (await getVocabularyRootConcepts(selectedVocabulary.value.code)).graph.map( + (term) => OcConcept2TreeNode(term) + ).sort((a, b) => a.label >= b.label) + } catch (e) { + nodes.value = [] + errorMessage.value = (e as Error).message + console.error(e) + } + + loading.value = false + } +}) + +const OcConcept2TreeNode = (term:OcConcept): TreeNode => { + return { + key: term['@id'], + label: translateValue(term['prefLabel']), + leaf: 'narrower' in term ? false : true, + } +} + +</script> diff --git a/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.stories.ts b/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.stories.ts deleted file mode 100644 index 1a5abffae00053f46d0dddd8ecea44d6f1b1bb82..0000000000000000000000000000000000000000 --- a/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.stories.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3'; - -import OcVocabularyAutocomplete from './OcVocabularyAutocomplete.vue'; -import type { OcVocabulary } from '@/declarations'; - -const meta: Meta<typeof OcVocabularyAutocomplete> = { - component: OcVocabularyAutocomplete, -}; - -export default meta; -type Story = StoryObj<typeof OcVocabularyAutocomplete>; - -export const Default: Story = { - render: (args) => ({ - components: { OcVocabularyAutocomplete }, - setup() { - return { args }; - }, - template: '<OcVocabularyAutocomplete v-bind="args" />', - }), - args: { - vocabularySearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} vocab` } } as OcVocabulary], - }, -}; - -export const NoneMultiple: Story = { - render: (args) => ({ - components: { OcVocabularyAutocomplete }, - setup() { - return { args }; - }, - template: '<OcVocabularyAutocomplete v-bind="args" />', - }), - args: { - vocabularySearchCallback: (query: string) => [{ '@id': `${query}-id`, '@type': ['https://semiceu.github.io/DCAT-AP/releases/3.0.0/#Concept'], prefLabel: { en: `${query} vocab` } } as OcVocabulary], - multiple: false - }, -}; - diff --git a/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.vue b/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.vue deleted file mode 100644 index 74f4e5f9719cc569322c94f91e657dee2c1525f9..0000000000000000000000000000000000000000 --- a/src/components/FormInputs/OcVocabularyAutocomplete/OcVocabularyAutocomplete.vue +++ /dev/null @@ -1,72 +0,0 @@ -<template> - <div class="flex flex-col w-full"> - <AutoComplete - :inputId="$attrs.inputId" - v-model="model" - :suggestions="items" - :optionLabel="displayName" - :multiple="multiple" - :forceSelection="forceSelection" - :typeahead="forceSelection" - :minLength="1" - @complete="search($event.query)" - :loading="loading" - :pt:pcInput:root:class="{ 'w-full': true, 'border-amber-200': !!errorMessage }" - /> - <Message class="mt-2" v-if="!!errorMessage" severity="warn"> - {{ t('form.error.autocomplete') }} - </Message> - </div> -</template> - -<script setup lang="ts"> -import { useTranslateValue } from '@/composables/translateValue' -import type { OcVocabulary } from '@/declarations' -import AutoComplete from 'primevue/autocomplete' -import Message from 'primevue/message' -import type { PropType } from 'vue' -import { ref } from 'vue' -import { useI18n } from 'vue-i18n' - -const { t } = useI18n() -const { translateValue } = useTranslateValue() - -const props = defineProps({ - vocabularySearchCallback: { - type: Function as PropType<(query: string) => OcVocabulary[]>, - required: true - }, - multiple: { - type: Boolean, - required: false, - default: true - }, - forceSelection: { - type: Boolean, - required: false, - default: true - } -}) -const model = defineModel<OcVocabulary[] | undefined>() - -const loading = ref<boolean>(false) -const errorMessage = ref<string | null>(null) -const items = ref<OcVocabulary[]>([]) - -const displayName = (item: OcVocabulary) => translateValue(item.prefLabel) - -async function search(query: string) { - loading.value = true - errorMessage.value = null - - try { - items.value = await props.vocabularySearchCallback(query) - } catch (e) { - items.value = [] - errorMessage.value = (e as Error).message - console.error(e) - } - - loading.value = false -} -</script> diff --git a/src/declarations.ts b/src/declarations.ts index 98504a0da2db27b949fc768a1ad23c708db1c9ee..1ee6e8a11a1990acb646d19da72dddedb18b4fbe 100644 --- a/src/declarations.ts +++ b/src/declarations.ts @@ -114,28 +114,34 @@ export type OcDataset = { creator?: Array<OcPerson | OcOrganization>, publisher?: OcOrganization, contactPoint?: OcPerson | OcOrganization, - type?: OcVocabulary, - theme?: OcVocabulary[], - accrualPeriodicity?: OcVocabulary, + type?: OcConcept, + theme?: OcConcept[], + accrualPeriodicity?: OcConcept, issued?: Date, - language?: OcVocabulary[], + language?: OcConcept[], keyword?: string[], - status?: OcVocabulary, + status?: OcConcept, modified?: Date, temporal?: [Date, Date], spatial?: any, // @todo .? landingPage?: string, - license?: OcVocabulary, - conformsTo?: OcVocabulary, + license?: OcConcept, + conformsTo?: OcConcept, version?: string, catalog?: OcCatalog, } -export type OcVocabulary = { +export type OcConceptScheme = { "@id": string prefLabel: LocalizedProperty } +export type OcConcept = { + "@id": string + prefLabel: LocalizedProperty + narrower?: string[] +} + export type OcFieldMetadata = { label: LocalizedProperty, propertyUri?: string, diff --git a/src/locales/en.ts b/src/locales/en.ts index 254a66b45642094994ba3f2cc2df937f04a5715f..f2834d90e56f7447e6268cff35500dd1a86b969a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -177,5 +177,11 @@ export default { }, } } + }, + conceptAutocomplete: { + search: 'Search for a term', + browseTerms: 'Browse terms', + vocabularies: 'Vocabularies', + selectedTerms: 'Selected terms', } } diff --git a/src/locales/fr.ts b/src/locales/fr.ts index c4bc5a61f45196945f6b0cf1fd5b30cc66c27c30..cee97fe59303e1daf4b0721098d482713f47c0b3 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -177,5 +177,11 @@ export default { }, } } + }, + conceptAutocomplete: { + search: 'Rechercher un terme', + browseTerms: 'Parcourir les termes', + vocabularies: 'Vocabulaires', + selectedTerms: 'Termes sélectionnés', } } diff --git a/src/modelMetadata/datasets.ts b/src/modelMetadata/datasets.ts index 5035ad3b7aa2205272ee68d56016089cf82821ff..8db1e7824e8a27164bdcd83b2f60d9f698f954dc 100644 --- a/src/modelMetadata/datasets.ts +++ b/src/modelMetadata/datasets.ts @@ -182,7 +182,7 @@ export const datasetsMetadata: OcModelMetadata<OcDataset> = { en: "The value SHOULD be taken from a well governed and broadly recognised controlled vocabulary.", fr: "La valeur DEVRAIT être tirée d'un vocabulaire contrôlé bien géré et largement reconnu.", }, - vocabularies: ["urn:group:dct:type:dataset"], + vocabularies: ["http://publications.europa.eu/resource/authority/dataset-type"], }, status: { label: { @@ -248,7 +248,7 @@ export const datasetsMetadata: OcModelMetadata<OcDataset> = { propertyUri: "http://purl.org/dc/terms/spatial", dereferencement: "https://www.w3.org/TR/vocab-dcat-3/#Property:dataset_spatial", fair: ['f'], - vocabularies: ["urn:group:dct:spatial"], + vocabularies: ["http://publications.europa.eu/resource/authority/continent", "http://publications.europa.eu/resource/authority/country"], comment: { en: "The spatial coverage of a dataset may be encoded as an instance of dcterms:Location, or may be indicated using an IRI reference (link) to a resource describing a location. It is recommended that links are to entries in a well maintained gazetteer such as Geonames.", fr: "La couverture spatiale d'un jeu de données peut être encodée comme une instance de dcterms:Location, ou peut être indiquée en utilisant une référence IRI (lien) vers une ressource décrivant une localisation. Il est recommandé que les liens renvoient à des entrées d'un répertoire toponymique bien tenu, tel que Geonames.", diff --git a/src/pages/community/[community].datasets/new.vue b/src/pages/community/[community].datasets/new.vue index 7f8efe7a95e3b25ef14d6d5c58e4577a129de435..45c14d759bc853411744197e0f946694969ab748 100644 --- a/src/pages/community/[community].datasets/new.vue +++ b/src/pages/community/[community].datasets/new.vue @@ -61,7 +61,7 @@ <script setup lang="ts"> import OcLayoutSimple from '@/layout/OcLayoutSimple/OcLayoutSimple.vue' -import type { OcBreadcrumbItem, OcCatalog, OcDataset, OcVocabulary } from '@/declarations' +import type { OcBreadcrumbItem, OcDataset, OcConcept } from '@/declarations' import { useAccountStore } from '@/stores/account' import { computed } from 'vue' import { useI18n } from 'vue-i18n' @@ -77,7 +77,7 @@ import OcDatasetFormStep3 from '@/components/DatasetMultiStep/OcDatasetFormStep3 import OcDatasetFormStep4 from '@/components/DatasetMultiStep/OcDatasetFormStep4/OcDatasetFormStep4.vue' import OcDatasetFormStep5 from '@/components/DatasetMultiStep/OcDatasetFormStep5/OcDatasetFormStep5.vue' import { queryOrganizations, queryPersons } from '@/sparql/agents' -import { getVocabulariesList, queryVocabulariesList } from '@/sparql/vocabularies' +import { getVocabulariesList } from '@/sparql/vocabularies' import { onMounted } from 'vue' import { ref } from 'vue' import { insertDataset } from '@/sparql/datasets' @@ -110,9 +110,9 @@ const dataset = ref<Partial<OcDataset>>({ } }) -const frequencyList = ref<OcVocabulary[]>([]) -const statusList = ref<OcVocabulary[]>([]) -const conformsToList = ref<OcVocabulary[]>([]) +const frequencyList = ref<OcConcept[]>([]) +const statusList = ref<OcConcept[]>([]) +const conformsToList = ref<OcConcept[]>([]) onMounted(async () => { getVocabulariesList( datasetsMetadata.accrualPeriodicity.vocabularies ?? [], @@ -134,7 +134,7 @@ onMounted(async () => { ) }) -const licenseList = ref<OcVocabulary[]>([]) +const licenseList = ref<OcConcept[]>([]) onMounted(async () => { const { graph } = await getVocabulariesList( datasetsMetadata.conformsTo.vocabularies ?? [], @@ -161,18 +161,6 @@ const send = () => { ) } -const getVocabulariesCallback = (vocabularies: string[]) => { - return async (query: string) => { - const { graph } = await queryVocabulariesList( - vocabularies, - query, - locale.value, - accountStore.auth - ) - return graph - } -} - const activeStep = '1' const steps = computed(() => { return { @@ -189,18 +177,16 @@ const steps = computed(() => { const { graph } = await queryOrganizations(query, accountStore.auth) return graph }, - themeSearchCallback: getVocabulariesCallback(datasetsMetadata.theme.vocabularies ?? []), - typeSearchCallback: getVocabulariesCallback(datasetsMetadata.type.vocabularies ?? []), + themeVocabularies: datasetsMetadata.theme.vocabularies ?? [], + typeVocabularies: datasetsMetadata.type.vocabularies ?? [], frequencyList: frequencyList.value, statusList: statusList.value, licenseList: licenseList.value, conformsToList: conformsToList.value, - languageSearchCallback: getVocabulariesCallback( - datasetsMetadata.language.vocabularies ?? [] - ), + languageVocabularies: datasetsMetadata.language.vocabularies ?? [], keywordSearchCallback: async (query: string) => queryKeyword(query, locale.value, accountStore.auth), - spatialSearchCallback: getVocabulariesCallback(datasetsMetadata.spatial.vocabularies ?? []) + spatialVocabularies: datasetsMetadata.spatial.vocabularies ?? [] }, model: dataset.value, back: undefined, diff --git a/src/sparql/vocabularies.ts b/src/sparql/vocabularies.ts index 4b5e1b3130197720ba2e6ba6e159e4200af6e1aa..8e16045a9adf324298dc69f65ea22a318c40a158 100644 --- a/src/sparql/vocabularies.ts +++ b/src/sparql/vocabularies.ts @@ -1,8 +1,8 @@ -import type { Credentials, OcJsonLdDocument, OcVocabulary } from "@/declarations" +import type { Credentials, OcJsonLdDocument, OcConcept, OcConceptScheme } from "@/declarations" import { executeSparqlConstruct } from "./sparql" import type { ContextDefinition } from "jsonld" -const vocabularyContext: ContextDefinition = { +const conceptContext: ContextDefinition = { "identifier": { "@id": "http://purl.org/dc/terms/identifier", "@type": "http://www.w3.org/2000/01/rdf-schema#Literal" @@ -11,60 +11,147 @@ const vocabularyContext: ContextDefinition = { "@id": "http://www.w3.org/2004/02/skos/core#prefLabel", "@container": "@language" }, + "narrower": { + "@id": "http://www.w3.org/2004/02/skos/core#narrower" + } } -export const getVocabulariesList = async (schemes: string[], auth?: Credentials): Promise<OcJsonLdDocument<OcVocabulary>> => { +const schemeContext: ContextDefinition = { + "identifier": { + "@id": "http://purl.org/dc/terms/identifier", + "@type": "http://www.w3.org/2000/01/rdf-schema#Literal" + }, + "prefLabel": { + "@id": "http://www.w3.org/2004/02/skos/core#prefLabel", + "@container": "@language" + } +} + +export const getVocabulariesList = async (schemes: string[], auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { const promiseList = schemes.map(scheme => getVocabularyList(scheme, auth)) return Promise.all(promiseList).then((values) => { return { - context: vocabularyContext, - graph: ([] as OcVocabulary[]).concat(...values.map(value => value.graph)) + context: conceptContext, + graph: ([] as OcConcept[]).concat(...values.map(value => value.graph)) } }) } -export const getVocabularyList = async (scheme: string, auth?: Credentials): Promise<OcJsonLdDocument<OcVocabulary>> => { - return executeSparqlConstruct<OcVocabulary>( +export const getVocabularyList = async (scheme: string, auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { + return executeSparqlConstruct<OcConcept>( ` - WITH <${scheme}> CONSTRUCT {?s skos:prefLabel ?label.} WHERE { ?s a skos:Concept; - skos:prefLabel ?label. + skos:prefLabel ?label; + skos:inScheme <${scheme}>. } `, { auth: auth, - context: vocabularyContext, + context: conceptContext, } ) } -export const queryVocabulariesList = async (schemes: string[], query: string, locale: string, auth?: Credentials): Promise<OcJsonLdDocument<OcVocabulary>> => { +export const queryVocabulariesList = async (schemes: string[], query: string, locale: string, auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { const promiseList = schemes.map(scheme => queryVocabulary(scheme, query, locale, auth)) return Promise.all(promiseList).then((values) => { return { - context: vocabularyContext, - graph: ([] as OcVocabulary[]).concat(...values.map(value => value.graph)) + context: conceptContext, + graph: ([] as OcConcept[]).concat(...values.map(value => value.graph)) } }) } -export const queryVocabulary = async (scheme: string, query: string, locale: string, auth?: Credentials): Promise<OcJsonLdDocument<OcVocabulary>> => { - return executeSparqlConstruct<OcVocabulary>( +export const queryVocabulary = async (scheme: string, query: string, locale: string, auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { + return executeSparqlConstruct<OcConcept>( ` - WITH <${scheme}> CONSTRUCT {?s skos:prefLabel ?label.} WHERE { ?s a skos:Concept; - skos:prefLabel ?label. + skos:prefLabel ?label; + skos:inScheme <${scheme}>. FILTER regex(?label, "${query}", "i"). FILTER(LANG(?label) = "${locale}"). } `, { auth: auth, - context: vocabularyContext, + context: conceptContext, + } + ) +} + +export const getConceptChildren = async (scheme: string, parentTerm?: string, auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { + return executeSparqlConstruct<OcConcept>( + ` + CONSTRUCT { + ?s skos:prefLabel ?label. + ?s skos:narrower ?narrower. + } + WHERE { + ?s a skos:Concept; + skos:prefLabel ?label; + skos:broader|^skos:narrower <${parentTerm}>; + skos:inScheme <${scheme}>. + OPTIONAL { + ?s skos:narrower|^skos:broader ?narrower + } + } + `, + { + auth: auth, + context: conceptContext, + } + ) +} + +export const getVocabularyRootConcepts = async (scheme: string, auth?: Credentials): Promise<OcJsonLdDocument<OcConcept>> => { + const result = await executeSparqlConstruct<OcConcept>( + ` + CONSTRUCT { + ?s skos:prefLabel ?label. + ?s skos:narrower ?narrower. + } + WHERE { + ?s a skos:Concept; + skos:prefLabel ?label; + skos:topConceptOf|^skos:hasTopConcept <${scheme}>. + OPTIONAL { + ?s skos:narrower|^skos:broader ?narrower + } + } + `, + { + auth: auth, + context: conceptContext, + } + ) + + if (result.graph.length){ + return result + } else { + // Si il n'y a pas de résulats il peut s'agir d'un vocabulaire sans arborescence et donc on utilise `getVocabularyList` + return getVocabularyList(scheme, auth) + } +} + +export const getVocabulariesInformations = async (schemes: string[]) => { + const schemesValues = '<' + schemes.join('> <') + '>' + return executeSparqlConstruct<OcConceptScheme>( + ` + CONSTRUCT { + ?s ?p ?o. + } + WHERE { + VALUES ?s { ${schemesValues} } + VALUES ?p { skos:prefLabel } + ?s ?p ?o. + } + `, + { + context: schemeContext, } ) } \ No newline at end of file