Skip to content

Commit

Permalink
Add dataservices search page (#516)
Browse files Browse the repository at this point in the history
  • Loading branch information
ThibaudDauce authored Sep 17, 2024
1 parent d515523 commit 7709968
Show file tree
Hide file tree
Showing 20 changed files with 647 additions and 240 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Current (in progress)

- Nothing yet
- Add index page with search for dataservices [#516](https://github.com/datagouv/udata-front/pull/516)

## 5.2.0 (2024-09-13)

Expand Down
290 changes: 145 additions & 145 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"typescript": "^5.2.2",
"uuid": "^9.0.1",
"vite": "^4.5.2",
"vue": "^3.4.21",
"vue": "^3.5.6",
"vue-content-loader": "^2.0.1",
"vue-i18n": "^9.13.1",
"vue-i18n-extract": "^2.0.7",
Expand Down
8 changes: 8 additions & 0 deletions udata_front/theme/gouvfr/assets/js/api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type PaginatedArray<T> = {
data: Array<T>;
next_page: string | null;
page: number;
page_size: number;
previous_page: string | null;
total: number;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import DataservicesSearch from './DataservicesSearch.vue';

const meta = {
title: 'Components/DataservicesSearch',
component: DataservicesSearch,
args: {},
} satisfies Meta<typeof DataservicesSearch>;

export default meta;

export const DataservicesSearchExample: StoryObj<typeof meta> = {
render: (args) => ({
components: { DataservicesSearch },
setup() {
return { args };
},
template: '<DataservicesSearch v-bind="args"/>',
}),
args: {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<template>
<form class="fr-pt-3v" @submit.prevent="search">
<div class="fr-grid-row fr-grid-row--middle justify-between" data-cy="search">
<section class="fr-search-bar fr-search-bar--lg w-100">
<label class="fr-label" :for="searchId">
{{ t("Search") }}
</label>
<input
:id="searchId"
type="search"
v-model="searchQuery"
ref="searchInputRef"
class="fr-input"
:aria-label="t('Search...')"
:placeholder="t('Search...')"
data-cy="search-input"
data-testid="search-input"
/>
<button class="fr-btn" type="submit">
{{ t('Search') }}
</button>
</section>
</div>
<div class="fr-grid-row fr-mt-1w fr-mt-md-5v">
<div class="fr-col-12 fr-col-md-4 fr-col-lg-3">
<nav class="fr-sidemenu" aria-labelledby="fr-sidemenu-title">
<div class="fr-sidemenu__inner">
<button
class="fr-sidemenu__btn fr-mt-1w"
hidden
aria-controls="fr-sidemenu-wrapper"
aria-expanded="false"
>
{{ t('Filter results') }}
</button>
<div class="fr-collapse" id="fr-sidemenu-wrapper">
<div class="fr-sidemenu__title fr-mb-3v" id="fr-sidemenu-title">{{ t('Filters') }}</div>
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12">
<MultiSelect
:placeholder="t('Organizations')"
:searchPlaceholder="t('Search an organization...')"
:allOption="t('All organizations')"
listUrl="/organizations/?sort=-followers"
suggestUrl="/organizations/suggest/"
entityUrl="/organizations/"
:values="organization"
@change="(value: string) => organization = value"
:isBlue="true"
/>
</div>
<div class="fr-col-12">
<div class="fr-select-group">
<label class="fr-label" :for="isRestrictedId">
{{ t("Access terms") }}
</label>
<select class="fr-select" :id="isRestrictedId" v-model="isRestricted" :class="{
'text-mention-grey': isRestricted === null,
}">
<option :value="null">{{ t("All access terms") }}</option>
<option :value="false">{{ t("Open APIs to everyone") }}</option>
<option :value="true">{{ t("Restricted access APIs") }}</option>
</select>
</div>
</div>
<div class="fr-col-12 fr-mb-3w text-align-center" v-if="hasFilters">
<button
class="fr-btn fr-btn--secondary fr-icon-close-circle-line fr-btn--icon-left justify-center w-100"
@click="resetFilters"
>
{{ t('Reset filters') }}
</button>
</div>
</div>
</div>
</div>
</nav>
</div>
<section class="fr-col-12 fr-col-md-8 fr-col-lg-9 fr-mt-2w fr-mt-md-0 search-results">
<div v-if="dataservices === null">
<Loader />
</div>
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle justify-between fr-pb-1w" v-if="dataservices !== null">
<p class="fr-col-auto fr-my-0" role="status">
{{ t("{count} API", dataservices.total) }}
</p>
</div>
<div v-if="dataservices !== null && dataservices.data.length">
<ul class="fr-mt-1w border-default-grey border-top relative z-2">
<li v-for="dataservice in dataservices.data" :key="dataservice.id">
<DataserviceCard :dataservice :dataserviceUrl="dataservice.self_web_url" />
</li>
</ul>
<Pagination
v-if="dataservices !== null && dataservices.total > dataservices.page_size"
:page="page"
:pageSize="dataservices.page_size"
:totalResults="dataservices.total"
@change="changePage"
class="fr-mt-2w"
/>
</div>
<div v-if="dataservices !== null && dataservices.data.length === 0" class="fr-mt-2w">
<ActionCard
:title="t('No result found for your search')"
:icon="franceWithMagnifyingGlassIcon"
type="primary"
>
<p class="fr-mt-1v fr-mb-3v">
{{ t("Try to reset filters to widen your search.") }}<br/>
{{ t("You can also give us more details with our feedback form.") }}
</p>
<template v-slot:actions>
<button @click="resetFilters" class="fr-btn fr-btn--secondary">
{{ t("Reset filters") }}
</button>
<a :href="data_search_feedback_form_url" class="fr-btn fr-btn--tertiary-no-outline fr-btn--icon-left fr-icon-lightbulb-line fr-ml-1w">
{{ t("Tell us what you are looking for") }}
</a>
</template>
</ActionCard>
</div>
</section>
</div>
</form>
</template>

<script setup lang="ts">
import { Dataservice, DataserviceCard, Pagination, useToast } from "@datagouv/components/ts";
import { ref, onMounted, computed, useId, useTemplateRef, watch } from "vue";
import { useI18n } from 'vue-i18n';
import Loader from "../dataset/loader.vue";
import MultiSelect from "../MultiSelect/MultiSelect.vue";
import ActionCard from "../Form/ActionCard/ActionCard.vue";
import { data_search_feedback_form_url } from "../../config";
import { api } from "../../plugins/api";
import franceWithMagnifyingGlassIcon from "../../../../templates/svg/illustrations/france_with_magnifying_glass.svg";
import { PaginatedArray } from "../../api/types";
import { refDebounced, watchIgnorable } from '@vueuse/core'
import axios from "axios";
const { t } = useI18n();
const { toast } = useToast();
const organization = ref<string | null>(null);
watch(organization, () => page.value = 1)
const isRestricted = ref<boolean | null>(null);
watch(isRestricted, () => page.value = 1)
const isRestrictedId = useId();
const page = ref(1);
const hasFilters = computed(() => {
return organization.value || isRestricted.value !== null
});
const resetFilters = () => {
organization.value = '';
isRestricted.value = null;
}
const searchId = useId();
const searchQuery = ref('');
const searchQueryDebounced = refDebounced(searchQuery, 500);
const searchInput = useTemplateRef('searchInputRef');
const params = computed(() => {
const filters: { organization?: string, q?: string, is_restricted?: string, page?: string } = {}
if (organization.value) {
filters.organization = organization.value;
}
if (searchQueryDebounced.value) {
filters.q = searchQueryDebounced.value;
}
if (isRestricted.value !== null) {
filters.is_restricted = isRestricted.value.toString();
}
if (page.value && page.value !== 1) {
filters.page = page.value.toString();
}
return new URLSearchParams(filters);
})
const { ignoreUpdates } = watchIgnorable(params, () => {
let url = new URL(window.location.href);
url.search = params.value.toString();
window.history.pushState(null, "", url);
});
const url = computed(() => `/dataservices?${params.value}`)
const dataservices = ref<null | PaginatedArray<Dataservice>>(null);
const abortController = ref<AbortController | null>(null);
watch(searchQuery, () => {
// We want to cancel the ongoing request as soon as the user start typing
// again.
if (abortController.value) {
abortController.value.abort();
}
})
const search = async () => {
dataservices.value = null
abortController.value = new AbortController();
try {
const response = await api.get(url.value, { signal: abortController.value.signal });
abortController.value = null;
dataservices.value = response.data;
} catch(e) {
if (! axios.isCancel(e)) {
toast.error(t("Error getting search results."));
}
}
};
const populateFromUrl = () => {
const url = new URL(window.location.href);
organization.value = url.searchParams.get('organization');
searchQuery.value = url.searchParams.get('q') || '';
if (url.searchParams.get('is_restricted') === "true") {
isRestricted.value = true;
} else if (url.searchParams.get('is_restricted') === "false") {
isRestricted.value = false;
} else {
isRestricted.value = null;
}
page.value = parseInt(url.searchParams.get('page') || '1' );
}
onMounted(() => {
populateFromUrl();
search()
searchInput.value?.focus();
window.addEventListener('popstate', () => {
// We don't want to trigger the watcher that
// push url history on this change (otherwise we create a new
// history step each time we use the back button and we cannot
// go forward anymore)
ignoreUpdates(() => {
populateFromUrl();
})
});
})
watch(url, () => {
search()
})
const changePage = (newPage: number) => {
page.value = newPage
};
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export default defineComponent({
},
content: {
type: String,
required: true,
},
type: {
type: String,
Expand Down
2 changes: 2 additions & 0 deletions udata_front/theme/gouvfr/assets/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import EventBus from "./plugins/eventbus.ts";
import Auth from "./plugins/auth.ts";
import InitSentry from "./sentry.ts";
import ReportModalButton from "./components/Report/ReportModalButton.vue";
import DataservicesSearch from "./components/DataservicesSearch/DataservicesSearch.vue";

setupComponents({
admin_root,
Expand Down Expand Up @@ -84,6 +85,7 @@ const configAndMountApp = (el: HTMLElement) => {
app.component("publishing-form", PublishingForm);
app.component("reuse-card", ReuseCard);
app.component("search", Search);
app.component("dataservices-search", DataservicesSearch);
app.component("toggletip", Toggletip);
app.component("user-dataset-list", UserDatasetList);
app.component("user-reuse-list", UserReuseList);
Expand Down
7 changes: 6 additions & 1 deletion udata_front/theme/gouvfr/assets/js/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,10 @@
"Member since": "Member since",
"Last connection": "Last connection",
"No connection": "No connection",
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset"
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset",
"Access terms": "",
"Open APIs to everyone": "",
"Restricted access APIs": "",
"{count} API": "",
"All access terms": ""
}
7 changes: 6 additions & 1 deletion udata_front/theme/gouvfr/assets/js/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,10 @@
"Member since": "Member since",
"Last connection": "Last connection",
"No connection": "No connection",
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset"
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset",
"Access terms": "Access terms",
"Open APIs to everyone": "Open APIs to everyone",
"Restricted access APIs": "Restricted access APIs",
"{count} API": "{count} API",
"All access terms": "All access terms"
}
7 changes: 6 additions & 1 deletion udata_front/theme/gouvfr/assets/js/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,10 @@
"Member since": "Member since",
"Last connection": "Last connection",
"No connection": "No connection",
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset"
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset",
"Access terms": "",
"Open APIs to everyone": "",
"Restricted access APIs": "",
"{count} API": "",
"All access terms": ""
}
7 changes: 6 additions & 1 deletion udata_front/theme/gouvfr/assets/js/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,10 @@
"Member since": "Membre depuis",
"Last connection": "Dernière connexion",
"No connection": "Aucune connection",
"You are seeing a specific community resource of this dataset": "Vous consultez une ressource communautaire spécifique sur ce jeu de données"
"You are seeing a specific community resource of this dataset": "Vous consultez une ressource communautaire spécifique sur ce jeu de données",
"Access terms": "",
"Open APIs to everyone": "",
"Restricted access APIs": "",
"{count} API": "",
"All access terms": ""
}
7 changes: 6 additions & 1 deletion udata_front/theme/gouvfr/assets/js/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,10 @@
"Member since": "Member since",
"Last connection": "Last connection",
"No connection": "No connection",
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset"
"You are seeing a specific community resource of this dataset": "You are seeing a specific community resource of this dataset",
"Access terms": "",
"Open APIs to everyone": "",
"Restricted access APIs": "",
"{count} API": "",
"All access terms": ""
}
Loading

0 comments on commit 7709968

Please sign in to comment.