Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dataservices search page #516

Merged
merged 22 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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,23 @@
import { withActions } from '@storybook/addon-actions/decorator';
import type { Meta, StoryObj } from '@storybook/vue3';
import DataservicesSearch from './DataservicesSearch.vue';

const meta = {
title: 'Components/DataservicesSearch',
component: DataservicesSearch,
decorators: [withActions],
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,194 @@
<template>
<form class="fr-pt-3v" @submit.prevent="search">
<div class="fr-grid-row fr-grid-row--middle justify-between" ref="searchRef" data-cy="search">
ThibaudDauce marked this conversation as resolved.
Show resolved Hide resolved
<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="searchInput"
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 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} results", 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="" />
</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 } from "@datagouv/components";
import { ref, onMounted, computed, useId, useTemplateRef, watchEffect, 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 } from '@vueuse/core'

const { t } = useI18n();

const organization = ref<string | null>(null);
const page = ref(1);

const hasFilters = computed(() => {
return organization.value
});
const resetFilters = () => {
organization.value = '';
}

const searchId = useId();
const searchQuery = ref('');
const searchQueryDebounced = refDebounced(searchQuery, 500);
const searchInput = useTemplateRef('searchInput');
ThibaudDauce marked this conversation as resolved.
Show resolved Hide resolved

const url = computed(() => {
const filters: { organization?: string, q?: string, page?: string } = {}
if (organization.value) {
filters.organization = organization.value;
}
if (searchQueryDebounced.value) {
filters.q = searchQueryDebounced.value;
}
if (page.value && page.value !== 1) {
filters.page = page.value.toString();
}
const params = new URLSearchParams(filters);

let url = new URL(window.location.href);
url.search = params.toString();
window.history.pushState(null, "", url);

return `/dataservices?${params}`
})

const dataservices = ref<null | PaginatedArray<Dataservice>>(null);

const search = async () => {
dataservices.value = null
const response = await api.get(url.value);
ThibaudDauce marked this conversation as resolved.
Show resolved Hide resolved
dataservices.value = response.data;
};

watch(organization, () => {
page.value = 1;
})

onMounted(() => {
searchInput.value?.focus();

window.addEventListener('popstate', () => {
const url = new URL(window.location.href);

organization.value = url.searchParams.get('organization');
searchQuery.value = url.searchParams.get('q') || '';
page.value = parseInt(url.searchParams.get('page') || '1' );
});
})
watchEffect(() => {
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 @@ -36,6 +36,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 @@ -82,6 +83,7 @@ const configAndMountApp = (el: HTMLElement) => {
app.component("owner-type-icon", OwnerTypeIcon);
app.component("publishing-form", PublishingForm);
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
37 changes: 37 additions & 0 deletions udata_front/theme/gouvfr/templates/dataservice/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% extends theme("layouts/1-column.html") %}

{% block breadcrumb %}
<li>
<a class="fr-breadcrumb__link" aria-current="page">
{{ _('Dataservices') }}
</a>
</li>
{% endblock %}

{% set meta = {
'title': _('Dataservices'),
'description': _("%(site)s dataservices search", site=config['SITE_TITLE']),
'keywords': [_('search'), _('dataservices')],
'robots': 'noindex',
} %}

{% set bundle = 'search' %}

{% block main_content %}
<section
class="fr-container fr-container--search vuejs fr-mb-4w"
>
<h1 class="fr-mb-1w">{{_('Dataservices')}}</h1>
<div class="fr-grid-row fr-grid-row--middle justify-between">
<div>{{_('Search among %(count)s dataservices on %(site)s',count=current_site.metrics['dataservices']|format_number, site=current_site.title)}}</div>
<a
href="{{ url_for('datasets.list') }}"
data-q
class="fr-link fr-text--sm fr-m-0"
>
{{ _('Search datasets') }}
</a>
</div>
<dataservices-search />
</section>
{% endblock %}
10 changes: 10 additions & 0 deletions udata_front/views/dataservice.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from flask import abort, make_response, request, url_for
from feedgenerator.django.utils.feedgenerator import Atom1Feed

from jinja2 import TemplateNotFound
from udata.core.dataservices.models import Dataservice
from udata.core.dataservices.permissions import DataserviceEditPermission
from udata.core.site.models import current_site
from udata.i18n import I18nBlueprint, gettext as _
from udata.sitemap import sitemap

from udata_front import theme
from udata_front.theme import render as render_template
from udata_front.views.base import DetailView

Expand Down Expand Up @@ -44,6 +46,14 @@ def recent_feed():
return response



@blueprint.route("/", endpoint="list")
def dataservices_list():
try:
return theme.render("dataservice/list.html")
except TemplateNotFound:
abort(404)

class DataserviceView(object):
model = Dataservice
object_name = 'dataservice'
Expand Down