<template> <c-r-u-d-iterator i18n-key="alsijil.coursebook" :gql-query="gqlQuery" :gql-additional-query-args="gqlQueryArgs" :enable-create="false" :enable-edit="false" :elevated="false" @lastQuery="lastQuery = $event" ref="iterator" fixed-header disable-pagination hide-default-footer use-deep-search > <template #additionalActions="{ attrs, on }"> <div class="d-flex flex-grow-1 justify-end"> <v-autocomplete :items="selectable" item-text="name" clearable return-object filled dense hide-details :placeholder="$t('alsijil.coursebook.filter.filter_for_obj')" :loading="selectLoading" :value="currentObj" @input="changeSelection" @click:clear="changeSelection" class="max-width" /> <div class="ml-6"> <v-switch :loading="selectLoading" :label="$t('alsijil.coursebook.filter.own')" :input-value="filterType === 'my'" @change=" changeSelection({ filterType: $event ? 'my' : 'all', type: objType, id: objId, }) " dense inset hide-details /> <v-switch :loading="selectLoading" :label="$t('alsijil.coursebook.filter.missing')" v-model="incomplete" dense inset hide-details /> </div> </div> </template> <template #default="{ items }"> <coursebook-loader /> <coursebook-day v-for="{ date, docs, first, last } in groupDocsByDay(items)" v-intersect="{ handler: intersectHandler(date, first, last), options: { rootMargin: '-165px 0px 0px 0px', threshold: [0, 1], }, }" :date="date" :docs="docs" :lastQuery="lastQuery" :focus-on-mount="initDate && (initDate.toMillis() === date.toMillis())" @init="transition" ref="days" /> <coursebook-loader /> <date-select-footer :value="$route.hash.substring(1)" @input="gotoDate" @prev="gotoPrev" @next="gotoNext" /> </template> <template #loading> <coursebook-loader :number-of-days="10" :number-of-docs="5" /> </template> <template #no-data> <CoursebookEmptyMessage icon="mdi-book-off-outline"> {{ $t("alsijil.coursebook.no_data") }} </CoursebookEmptyMessage> </template> <template #no-results> <CoursebookEmptyMessage icon="mdi-book-alert-outline"> {{ $t("alsijil.coursebook.no_results", { search: $refs.iterator.search }) }} </CoursebookEmptyMessage> </template> </c-r-u-d-iterator> </template> <script> import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; import DateSelectFooter from "aleksis.core/components/generic/DateSelectFooter.vue"; import CoursebookDay from "./CoursebookDay.vue"; import { DateTime, Interval } from "luxon"; import { coursesOfPerson, documentationsForCoursebook, groupsByPerson, } from "./coursebook.graphql"; import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; export default { name: "Coursebook", components: { CoursebookEmptyMessage, CoursebookLoader, CRUDIterator, DateSelectFooter, CoursebookDay, }, props: { filterType: { type: String, required: true, }, objId: { type: [Number, String], required: false, default: null, }, objType: { type: String, required: false, default: null, }, }, data() { return { gqlQuery: documentationsForCoursebook, lastQuery: null, dateStart: "", dateEnd: "", // Placeholder values while query isn't completed yet groups: [], courses: [], incomplete: false, ready: false, initDate: false, }; }, apollo: { groups: { query: groupsByPerson, }, courses: { query: coursesOfPerson, }, }, computed: { // Assertion: Should only fire on page load or selection change. // Resets date range. gqlQueryArgs() { console.log('computing gqlQueryArgs'); return { own: this.filterType === "all" ? false : true, objId: this.objId ? Number(this.objId) : undefined, objType: this.objType?.toUpperCase(), dateStart: this.dateStart, dateEnd: this.dateEnd, incomplete: !!this.incomplete, }; }, selectable() { return [ { header: this.$t("alsijil.coursebook.filter.groups") }, ...this.groups.map((group) => ({ type: "group", ...group })), { header: this.$t("alsijil.coursebook.filter.courses") }, ...this.courses.map((course) => ({ type: "course", ...course })), ]; }, currentObj() { return this.selectable.find( (o) => o.type === this.objType && o.id === this.objId, ); }, selectLoading() { return ( this.$apollo.queries.groups.loading || this.$apollo.queries.courses.loading ); }, }, methods: { resetDate() { // Assure current date console.log('Resetting date range', this.$route.hash); if (!this.$route.hash) { console.log('Set default date'); this.setDate(DateTime.now().toISODate()); } const date = DateTime.fromISO(this.$route.hash.substring(1)); this.initDate = date; this.dateStart = date.minus({ days: 3 }).toISODate(); this.dateEnd = date.plus({ days: 4 }).toISODate(); }, changeSelection(selection) { this.$router.push({ name: "alsijil.coursebook", params: { filterType: selection.filterType ? selection.filterType : this.filterType, objType: selection.type, objId: selection.id, }, hash: this.$route.hash, }); this.resetDate(); // might skip query until both set = atomic }, transition() { this.initDate = false this.ready = true }, groupDocsByDay(docs) { // => {dt: {date: dt, docs: doc ...} ...} const docsByDay = docs.reduce((byDay, doc) => { // This works with dummy. Does actual doc have dateStart instead? const day = DateTime.fromISO(doc.datetimeStart).startOf("day"); byDay[day] ??= {date: day, docs: []}; byDay[day].docs.push(doc); return byDay; }, {}); // => [{date: dt, docs: doc ..., idx: idx, lastIdx: last-idx} ...] // sorting is necessary since backend can send docs unordered return Object.keys(docsByDay) .sort() .map((key, idx, {length}) => { const day = docsByDay[key]; day.first = idx === 0; const lastIdx = length - 1; day.last = idx === lastIdx; return day; }); }, // docsByDay: {dt: [dt doc ...] ...} fetchMore(from, to, then) { console.log('fetching', from, to); this.lastQuery.fetchMore({ variables: { dateStart: from, dateEnd: to, }, // Transform the previous result with new data updateQuery: (previousResult, { fetchMoreResult }) => { console.log('previousResult', previousResult); console.log('fetchMoreResult', fetchMoreResult); then(); return { items: previousResult.items.concat(fetchMoreResult.items) }; } }); }, setDate(date) { if (!(this.$route.hash.substring(1) === date)) { this.$router.replace({ hash: date }) } }, fixScrollPos(height, top) { console.log('fix @', top, document.documentElement.scrollTop, height, document.documentElement.scrollHeight); this.$nextTick(() => { console.log('fix @', top, document.documentElement.scrollTop, height, document.documentElement.scrollHeight); if (height < document.documentElement.scrollHeight) { console.log('fixingTop'); document.documentElement.scrollTop = document.documentElement.scrollHeight - height + top; this.ready = true; } else { // Update top, could have changed in the meantime. this.fixScrollPos(height, document.documentElement.scrollTop); } }); }, intersectHandler(date, first, last) { let once = true; return (entries) => { const entry = entries[0]; if (entry.isIntersecting) { // TODO: Make 165 a var? if ((entry.boundingClientRect.top <= 165) || first || last) { console.log('@', date.toISODate()); this.setDate(date.toISODate()); } if (once && this.ready && first) { console.log('load up', date.toISODate()); this.ready = false; this.fetchMore(date.minus({ days: 5 }).toISODate(), date.minus({ days: 1 }).toISODate(), () => { this.fixScrollPos(document.documentElement.scrollHeight, document.documentElement.scrollTop); }); once = false; } else if (once && this.ready && last) { console.log('load down', date.toISODate()); this.ready = false; this.fetchMore(date.plus({ days: 1 }).toISODate(), date.plus({ days: 5 }).toISODate(), () => { this.ready = true }); once = false; } } }; }, // TODO: only load the else if out of range / not just not present gotoDate(date) { const present = this.$refs.days .find((day) => day.date.toISODate() === date); if (present) { // React immediatly -> smoother navigation // Also intersect handler does not always react to scrollIntoView this.setDate(date); present.focus("smooth"); } else { this.setDate(date); this.resetDate(); } }, // TODO: Disable navigation while loading! gotoPrev() { const current = this.$route.hash.substring(1); const pref = this.$refs.days .map((day) => day.date.toISODate()) .sort() .reverse() .find((date) => date < current); this.gotoDate(pref); }, gotoNext() { const current = this.$route.hash.substring(1); const next = this.$refs.days .map((day) => day.date.toISODate()) .sort() .find((date) => date > current); this.gotoDate(next); }, }, created() { this.resetDate(); }, }; </script> <style> .max-width { max-width: 25rem; } </style>