<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 }"> <coursebook-filters v-model="filters" /> </template> <template #default="{ items }"> <coursebook-day v-for="{ date, docs, idx, lastIdx } in groupDocsByDay(items)" v-intersect="{ handler: intersectHandler(date, idx, lastIdx), 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" /> <date-select-footer :value="$route.hash.substring(1)" @input="gotoDate" @prev="gotoPrev" @next="gotoNext" /> </template> <template #loading> <CoursebookLoader /> </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 { documentationsForCoursebook } from "./coursebook.graphql"; import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; export default { name: "Coursebook", components: { CoursebookEmptyMessage, CoursebookFilters, 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, }; }, 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, }; }, filters: { get() { return { objType: this.objType, objId: this.objId, filterType: this.filterType, incomplete: this.incomplete, }; }, set(selectedFilters) { if (Object.hasOwn(selectedFilters, "incomplete")) { this.incomplete = selectedFilters.incomplete; } else if ( Object.hasOwn(selectedFilters, "filterType") || Object.hasOwn(selectedFilters, "objId") || Object.hasOwn(selectedFilters, "objType") ) { this.$router.push({ name: "alsijil.coursebook", params: { filterType: selectedFilters.filterType ? selectedFilters.filterType : this.filterType, objType: selectedFilters.objType, objId: selectedFilters.objId, }, hash: this.$route.hash, }); // computed should not have side effects // but this was actually done before filters was refactored into // its own component this.resetDate(); // might skip query until both set = atomic } }, }, }, methods: { // TODO: Scroll to actual first date! 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(); }, 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.idx = idx; day.lastIdx = length - 1; 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) { 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, idx, lastIdx) { let once = true; return (entries) => { const entry = entries[0]; if (entry.isIntersecting) { // TODO: Make 165 a var? if (entry.boundingClientRect.top <= 165) { console.log('@', date.toISODate()); this.setDate(date.toISODate()); } if (once && this.ready && idx === 0) { 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 && idx === lastIdx) { 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) { 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>