diff options
| author | Murtaza Sultani <sultani@data-quest.de> | 2025-07-25 12:25:37 +0200 |
|---|---|---|
| committer | Murtaza Sultani <sultani@data-quest.de> | 2025-07-25 12:25:37 +0200 |
| commit | d83a8347ed60b06b360827dc8a1026a70815a483 (patch) | |
| tree | 358db42c7c3f35dc6c599c36b91baee0b8039a79 /resources | |
| parent | 1d51d3baf430da6b4573b42aae5f0db9cea838c1 (diff) | |
Resolve "Forumsuche ohne Reload"
Closes #5747
Merge request studip/studip!4388
Diffstat (limited to 'resources')
| -rw-r--r-- | resources/vue/apps/forum/discussions/Edit.vue | 2 | ||||
| -rw-r--r-- | resources/vue/apps/forum/search/Index.vue | 142 | ||||
| -rw-r--r-- | resources/vue/components/forum/topics/SelectTopicInput.vue | 2 |
3 files changed, 97 insertions, 49 deletions
diff --git a/resources/vue/apps/forum/discussions/Edit.vue b/resources/vue/apps/forum/discussions/Edit.vue index ed8ab6b..26960b1 100644 --- a/resources/vue/apps/forum/discussions/Edit.vue +++ b/resources/vue/apps/forum/discussions/Edit.vue @@ -121,7 +121,7 @@ onMounted(() => { <label class="flex-1"> <span class="sr-only">{{ $gettext('Thema') }}</span> <input type="hidden" name="topic" :value="JSON.stringify(discussionForm.topic)"> - <SelectTopicInput :options="topics" v-model="discussionForm.topic" :taggable="true" /> + <SelectTopicInput :options="topics" v-model="discussionForm.topic" :taggable="true" :required="true" /> </label> <label class="flex-1"> <span class="sr-only">{{ $gettext('Diskussionstyp') }}</span> diff --git a/resources/vue/apps/forum/search/Index.vue b/resources/vue/apps/forum/search/Index.vue index 2b5c4bb..d828e73 100644 --- a/resources/vue/apps/forum/search/Index.vue +++ b/resources/vue/apps/forum/search/Index.vue @@ -11,6 +11,8 @@ import DiscussionIndex from "@/vue/components/forum/discussions/DiscussionIndex. import StudipIcon from "../../../components/StudipIcon.vue"; import StudipSelect from "../../../components/StudipSelect.vue"; import {highlightText, removeHighlight} from "@/vue/components/forum/helpers"; +import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils"; +import StudipPagination from "../../../components/StudipPagination.vue"; const discussionStatuses = [ { @@ -30,14 +32,10 @@ const discussionStatuses = [ const CSRF = STUDIP.CSRF_TOKEN; const props = defineProps({ - search: { + filter: { type: Object, required: true }, - discussions: { - type: Array, - required: true - }, topics: { type: Array, required: true @@ -56,17 +54,20 @@ const props = defineProps({ } }); +const discussions = ref([]); +const pagination = ref({}); +const isLoading = ref(false); const isFilterVisible = ref(true); const searchForm = reactive({ - ...props.search, - begin: toDateString(props.search.begin), - end: toDateString(props.search.end), - discussion_status: discussionStatuses.find(status => status.value === props.search.discussion_status), - topics: props.topics.filter(({ topic_id }) => props.search.topic_ids.includes(topic_id)), - tags: props.tags.filter(({ id }) => props.search.tag_ids.includes(id.toString())), - types: props.discussion_types.filter(({ type_id }) => props.search.discussion_type_ids.includes(type_id.toString())), - authors: props.course_members.filter(({ user_id }) => props.search.user_ids.includes(user_id)) + ...(props.filter.keyword && { keyword: props.filter.keyword }), + ...(props.filter.begin && { begin: parseToDateString(props.filter.begin) }), + ...(props.filter.end && { end: parseToDateString(props.filter.end) }), + ...(props.filter.status && { status: discussionStatuses.find(status => status.value === props.filter.status) }), + ...(props.filter.topic_ids && { topics: props.topics.filter(({ topic_id }) => props.filter.topic_ids.includes(topic_id)) }), + ...(props.filter.tag_ids && { tags: props.tags.filter(({ id }) => props.filter.tag_ids.includes(id.toString())) }), + ...(props.filter.type_ids && { types: props.discussion_types.filter(({ type_id }) => props.filter.type_ids.includes(type_id.toString())) }), + ...(props.filter.user_ids && { authors: props.course_members.filter(({ user_id }) => props.filter.user_ids.includes(user_id)) }), }); const availableTags = computed(() => { @@ -96,12 +97,10 @@ const availableTypes = computed(() => { return props.discussion_types; }); -const actionURL = STUDIP.URLHelper.getURL(`dispatch.php/course/forum/search`); - const resetSearchForm = () => { Object.assign(searchForm, { keyword: '', - discussion_status: null, + status: null, begin: null, end: null, topics: [], @@ -109,40 +108,91 @@ const resetSearchForm = () => { types: [], authors: [] }); + + discussions.value = []; } -function toUnixTimestamp(date) { - return (new Date(date)).getTime() / 1000; +function parseToDateString(timestamp) { + if (!timestamp) { + return ''; + } + + return STUDIP.Dates.unixTimestampToISO(timestamp).split('T')[0]; } -function toDateString(unixTimestamp) { - if (!unixTimestamp) { +const filterQueryParams = computed(() => { + const filter = { + ...(searchForm.keyword && { 'keyword': searchForm.keyword }), + ...(searchForm.status && { 'status': parseInt(searchForm.status.value) }), + ...(searchForm.begin && { 'begin': STUDIP.Dates.stringToUnixTimestamp(searchForm.begin) }), + ...(searchForm.end && { 'end': STUDIP.Dates.stringToUnixTimestamp(searchForm.end) }), + ...(searchForm.types?.length && { 'type-ids': searchForm.types.map(({ type_id }) => type_id).join(',') }), + ...(searchForm.topics?.length && { 'topic-ids': searchForm.topics.map(({ topic_id }) => topic_id).join(',') }), + ...(searchForm.authors?.length && { 'user-ids': searchForm.authors.map(({ user_id }) => user_id).join(',') }), + ...(searchForm.tags?.length && { 'tag-ids': searchForm.tags.map(({ id }) => id).join(',') }), + }; + + if (Object.keys(filter).length === 0) { return ''; } - return (new Date(parseInt(unixTimestamp) * 1000)).toISOString().split('T')[0]; + return Object.entries(filter) + .map(([key, value]) => `filter[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`) + .join('&'); +}); + +const fetchDiscussions = async (_, offset = 0) => { + try { + isLoading.value = true; + + const response = await STUDIP.jsonapi.withPromises().GET( + `courses/${STUDIP.URLHelper.parameters.cid}/forum-discussions`, + { + data: { + include: `category,discussion-type,members,tags,user&fields[users]=id&${filterQueryParams.value}`, + page: { offset } + } + } + ); + + pagination.value = { + ...response.meta.page, + currentPage: response.meta.page.offset / response.meta.page.limit, + links: response.links + }; + + discussions.value = await deserializeJSONAPIResponse(response); + } catch (error) { + STUDIP.Report.error(error); + } finally { + isLoading.value = false; + } } -onMounted(() => { - if(searchForm.keyword.length > 1 && props.discussions.length) { - highlightText(searchForm.keyword, '.title'); +onMounted(async () => { + if (filterQueryParams.value) { + await fetchDiscussions(); + } + + if(searchForm.keyword.length > 1 && discussions.value.length) { + highlightText(searchForm.keyword, '.discussion-title'); // remove highlights document.getElementById("forum-search").addEventListener("click", function() { - removeHighlight('.title mark'); + removeHighlight('.discussion-title mark'); }); } -}) +}); </script> <template> <ForumApp id="forum-search"> - <form :action="actionURL" method="post" class="default search-container use-utility-classes"> + <form action="#" @submit.prevent="fetchDiscussions" method="post" class="default search-container use-utility-classes"> <input type="hidden" :name="CSRF.name" :value="CSRF.value"> <h1>{{ $gettext('Suche') }}</h1> <div class="search-controls"> <div class="search-input-container"> - <input name="keyword" type="text" :value="searchForm.keyword" :placeholder="$gettext('Diskussionen oder Beiträge')"/> + <input name="keyword" type="text" v-model="searchForm.keyword" :placeholder="$gettext('Diskussionen oder Beiträge')"/> </div> <button type="submit" @@ -217,32 +267,22 @@ onMounted(() => { <div v-if="isFilterVisible" class="filter-controls"> <label> <span class="sr-only">{{ $gettext('Thema') }}</span> - <template v-for="topic in searchForm.topics" :key="topic.topic_id"> - <input type="hidden" name="topic_ids[]" :value="topic.topic_id"> - </template> - <SelectTopicInput id="" :options="availableTopics" v-model="searchForm.topics" multiple /> + <SelectTopicInput id="" :options="availableTopics" v-model="searchForm.topics" :required="false" multiple /> </label> <label> <span class="sr-only">{{ $gettext('Diskussionstyp') }}</span> - <template v-for="type in searchForm.types" :key="type.type_id"> - <input type="hidden" name="discussion_type_ids[]" :value="type.type_id"> - </template> <SelectDiscussionType :options="availableTypes" v-model="searchForm.types" multiple /> </label> <label> <span class="sr-only">{{ $gettext('Schlagworte') }}</span> - <template v-for="tag in searchForm.tags" :key="tag.id"> - <input type="hidden" name="tag_ids[]" :value="tag.id"> - </template> <SelectTagsInput :options="availableTags" v-model="searchForm.tags" multiple /> </label> <label> <span class="sr-only">{{ $gettext('Status der Diskussion') }}</span> - <input v-if="searchForm.discussion_status" type="hidden" name="discussion_status" :value="searchForm.discussion_status.value"> <StudipSelect :options="discussionStatuses" :placeholder="$gettext('Status der Diskussion')" - v-model="searchForm.discussion_status" + v-model="searchForm.status" > <template #no-options> <div> @@ -254,15 +294,9 @@ onMounted(() => { <div class="date-inputs-container"> <input type="date" v-model="searchForm.begin" :placeholder="$gettext('Von')" :aria-label="$gettext('Von')" autocomplete="off" /> <input type="date" v-model="searchForm.end" :placeholder="$gettext('Bis')" :aria-label="$gettext('Bis')" autocomplete="off" /> - - <input type="hidden" name="begin" :value="toUnixTimestamp(searchForm.begin)" /> - <input type="hidden" name="end" :value="toUnixTimestamp(searchForm.end)" /> </div> <label> <span class="sr-only">{{ $gettext('Autor/-in') }}</span> - <template v-for="user in searchForm.authors" :key="user.user_id"> - <input type="hidden" name="user_ids[]" :value="user.user_id"> - </template> <SelectUserInput :options="course_members" multiple @@ -275,7 +309,21 @@ onMounted(() => { <div class="search-result-container"> <h2>{{ $gettext('Suchergebnisse') }}</h2> - <DiscussionIndex :discussions="discussions" :withActions="false" redirect="search" /> + <DiscussionIndex :discussions="discussions" :isLoading="isLoading" redirect="search"> + <template #pagination> + <tfoot v-if="pagination && pagination.total > pagination.limit"> + <tr> + <td colspan="7"> + <StudipPagination + :currentPage="pagination.currentPage" + :totalItems="pagination.total" + :itemsPerPage="pagination.limit" + @pageUpdated="fetchDiscussions" /> + </td> + </tr> + </tfoot> + </template> + </DiscussionIndex> </div> </ForumApp> </template> diff --git a/resources/vue/components/forum/topics/SelectTopicInput.vue b/resources/vue/components/forum/topics/SelectTopicInput.vue index 7dabc95..24c0e00 100644 --- a/resources/vue/components/forum/topics/SelectTopicInput.vue +++ b/resources/vue/components/forum/topics/SelectTopicInput.vue @@ -24,7 +24,7 @@ const selectedTopics = defineModel(); <template #search="{attributes, events}"> <input class="vs__search" - :required="!selectedTopics" + :required="!selectedTopics && $attrs.required" v-bind="attributes" v-on="events" /> |
