Skip to content
Snippets Groups Projects
Commit c03aa1f5 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch 'rebased2-michael-scrollt' into '256-add-simple-course-book-list'

Implement infinite scrolling and by date navigation for coursebook

See merge request !355
parents 2d153af2 189bc40f
No related branches found
No related tags found
2 merge requests!355Implement infinite scrolling and by date navigation for coursebook,!350Resolve "Add simple course book list"
Pipeline #181825 failed
...@@ -6,9 +6,10 @@ ...@@ -6,9 +6,10 @@
:enable-create="false" :enable-create="false"
:enable-edit="false" :enable-edit="false"
:elevated="false" :elevated="false"
:items-per-page="-1"
@lastQuery="lastQuery = $event" @lastQuery="lastQuery = $event"
ref="iterator" ref="iterator"
fixed-header
disable-pagination
hide-default-footer hide-default-footer
use-deep-search use-deep-search
> >
...@@ -16,33 +17,35 @@ ...@@ -16,33 +17,35 @@
<coursebook-filters v-model="filters" /> <coursebook-filters v-model="filters" />
</template> </template>
<template #default="{ items }"> <template #default="{ items }">
<v-list-item <coursebook-loader />
v-for="day in groupDocsByDay(items)" <coursebook-day
two-line v-for="{ date, docs, first, last } in groupDocsByDay(items)"
:key="'day-' + day[0]" v-intersect="{
> handler: intersectHandler(date, first, last),
<v-list-item-content :id="'documentation_' + day[0].toISODate()"> options: {
<v-subheader class="text-h6">{{ rootMargin: '-' + topMargin + 'px 0px 0px 0px',
$d(day[0], "dateWithWeekday") threshold: [0, 1],
}}</v-subheader> },
<v-list max-width="100%" class="pt-0 mt-n1"> }"
<v-list-item :date="date"
v-for="doc in day.slice(1)" :docs="docs"
:key="'documentation-' + (doc.oldId || doc.id)" :lastQuery="lastQuery"
> :focus-on-mount="initDate && initDate.toMillis() === date.toMillis()"
<documentation-modal @init="transition"
:documentation="doc" :key="'day-' + date"
:affected-query="lastQuery" ref="days"
/> />
</v-list-item> <coursebook-loader />
</v-list>
</v-list-item-content>
</v-list-item>
<date-select-footer :value="date" @click="handleDateMove" /> <date-select-footer
:value="currentDate"
@input="gotoDate"
@prev="gotoPrev"
@next="gotoNext"
/>
</template> </template>
<template #loading> <template #loading>
<CoursebookLoader /> <coursebook-loader :number-of-days="10" :number-of-docs="5" />
</template> </template>
<template #no-data> <template #no-data>
...@@ -64,8 +67,8 @@ ...@@ -64,8 +67,8 @@
<script> <script>
import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue";
import DateSelectFooter from "aleksis.core/components/generic/DateSelectFooter.vue"; import DateSelectFooter from "aleksis.core/components/generic/DateSelectFooter.vue";
import DocumentationModal from "./documentation/DocumentationModal.vue"; import CoursebookDay from "./CoursebookDay.vue";
import { DateTime } from "luxon"; import { DateTime, Interval } from "luxon";
import { documentationsForCoursebook } from "./coursebook.graphql"; import { documentationsForCoursebook } from "./coursebook.graphql";
import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookFilters from "./CoursebookFilters.vue";
import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookLoader from "./CoursebookLoader.vue";
...@@ -79,7 +82,7 @@ export default { ...@@ -79,7 +82,7 @@ export default {
CoursebookLoader, CoursebookLoader,
CRUDIterator, CRUDIterator,
DateSelectFooter, DateSelectFooter,
DocumentationModal, CoursebookDay,
}, },
props: { props: {
filterType: { filterType: {
...@@ -96,36 +99,51 @@ export default { ...@@ -96,36 +99,51 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
// ISODate /**
date: { * Number of consecutive to load at once
type: String, * This number of days is initially loaded and loaded
* incrementally while scrolling.
*/
dayIncrement: {
type: Number,
required: false, required: false,
default: "", default: 7,
},
/**
* Margin from coursebook list to top of viewport in pixels
*/
topMargin: {
type: Number,
required: false,
default: 165,
}, },
}, },
data() { data() {
return { return {
gqlQuery: documentationsForCoursebook, gqlQuery: documentationsForCoursebook,
lastQuery: null, lastQuery: null,
dateStart: "",
dateEnd: "",
// Placeholder values while query isn't completed yet // Placeholder values while query isn't completed yet
groups: [], groups: [],
courses: [], courses: [],
dateStart: null,
dateEnd: null,
incomplete: false, incomplete: false,
ready: false,
initDate: false,
currentDate: "",
hashUpdater: false,
}; };
}, },
computed: { computed: {
// Assertion: Should only fire on page load or selection change.
// Resets date range.
gqlQueryArgs() { gqlQueryArgs() {
return { return {
// Assure courseId is a number
own: this.filterType === "all" ? false : true, own: this.filterType === "all" ? false : true,
objId: this.objId ? Number(this.objId) : null, objId: this.objId ? Number(this.objId) : undefined,
objType: this.objType?.toUpperCase(), objType: this.objType?.toUpperCase(),
dateStart: this.dateStart ?? this.date, dateStart: this.dateStart,
dateEnd: dateEnd: this.dateEnd,
this.dateEnd ??
DateTime.fromISO(this.date).plus({ weeks: 1 }).toISODate(),
incomplete: !!this.incomplete, incomplete: !!this.incomplete,
}; };
}, },
...@@ -147,103 +165,196 @@ export default { ...@@ -147,103 +165,196 @@ export default {
Object.hasOwn(selectedFilters, "objType") Object.hasOwn(selectedFilters, "objType")
) { ) {
this.$router.push({ this.$router.push({
name: "alsijil.coursebook_by_type_and_date", name: "alsijil.coursebook",
params: { params: {
filterType: selectedFilters.filterType filterType: selectedFilters.filterType
? selectedFilters.filterType ? selectedFilters.filterType
: this.filterType, : this.filterType,
objType: selectedFilters.objType, objType: selectedFilters.objType,
objId: selectedFilters.objId, objId: selectedFilters.objId,
date: this.date,
}, },
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: { methods: {
// => [[dt doc ...] ...] resetDate(toDate) {
// Assure current date
console.log("Resetting date range", this.$route.hash);
this.currentDate = toDate || this.$route.hash?.substring(1);
if (!this.currentDate) {
console.log("Set default date");
this.setDate(DateTime.now().toISODate());
}
const date = DateTime.fromISO(this.currentDate);
this.initDate = date;
this.dateStart = date.minus({ days: this.dayIncrement }).toISODate();
this.dateEnd = date.plus({ days: this.dayIncrement }).toISODate();
},
transition() {
this.initDate = false;
this.ready = true;
},
groupDocsByDay(docs) { groupDocsByDay(docs) {
const byDay = docs.reduce((byDay, doc) => { // => {dt: {date: dt, docs: doc ...} ...}
const docsByDay = docs.reduce((byDay, doc) => {
// This works with dummy. Does actual doc have dateStart instead? // This works with dummy. Does actual doc have dateStart instead?
const day = DateTime.fromISO(doc.datetimeStart).startOf("day"); const day = DateTime.fromISO(doc.datetimeStart).startOf("day");
byDay[day] ??= [day]; byDay[day] ??= { date: day, docs: [] };
byDay[day].push(doc); byDay[day].docs.push(doc);
return byDay; return byDay;
}, {}); }, {});
// => [{date: dt, docs: doc ..., idx: idx, lastIdx: last-idx} ...]
return Object.keys(byDay) // sorting is necessary since backend can send docs unordered
return Object.keys(docsByDay)
.sort() .sort()
.map((key) => byDay[key]); .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 ...] ...}
* @param {"prev"|"next"} direction fetchMore(from, to, then) {
*/ console.log("fetching", from, to);
handleDateMove(direction) { this.lastQuery.fetchMore({
const dateStartParsed = DateTime.fromISO(this.dateStart); variables: {
const dateEndParsed = DateTime.fromISO(this.dateEnd); dateStart: from,
const dateParsed = DateTime.fromISO(this.date); dateEnd: to,
},
const newDate = // Transform the previous result with new data
direction === "prev" updateQuery: (previousResult, { fetchMoreResult }) => {
? dateParsed.minus({ days: 1 }) console.log("Received more");
: dateParsed.plus({ days: 1 }); then();
return { items: previousResult.items.concat(fetchMoreResult.items) };
/*
TODO:
Everything below this line is also needed for when a date is selected via the calendar.
→ probably move this into a different function and create a second event listener for the input event.
*/
// Load 3 days into the future/past
if (dateStartParsed >= newDate) {
this.dateStart = newDate.minus({ days: 3 }).toISODate();
}
if (dateEndParsed <= newDate) {
this.dateEnd = newDate.plus({ days: 3 }).toISODate();
}
this.$router.push({
name: "alsijil.coursebook_by_type_and_date",
params: {
filterType: this.filterType,
objType: this.objType,
objId: this.objId,
date: newDate.toISODate(),
}, },
}); });
},
// Define the function to find the nearest ID setDate(date) {
const ids = Array.from( this.currentDate = date;
document.querySelectorAll("[id^='documentation_']"), if (!this.hashUpdater) {
).map((el) => el.id); this.hashUpdater = window.requestIdleCallback(() => {
if (!(this.$route.hash.substring(1) === this.currentDate)) {
// TODO: This should only be done after loading the new data this.$router.replace({ hash: this.currentDate });
const nearestId = this.findNearestId(newDate, direction, ids); }
this.$vuetify.goTo("#" + nearestId); this.hashUpdater = false;
}, });
findNearestId(targetDate, direction, ids) {
const sortedIds = ids
.map((id) => DateTime.fromISO(id.split("_")[1]))
.sort((a, b) => a - b);
if (direction === "prev") {
sortedIds.reverse();
} }
},
fixScrollPos(height, top) {
this.$nextTick(() => {
if (height < document.documentElement.scrollHeight) {
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) {
if (entry.boundingClientRect.top <= this.topMargin || first) {
console.log("@ ", date.toISODate());
this.setDate(date.toISODate());
}
const nearestId = if (once && this.ready && first) {
sortedIds.find((id) => console.log("load up", date.toISODate());
direction === "next" ? id >= targetDate : id <= targetDate, this.ready = false;
) || sortedIds[sortedIds.length - 1]; this.fetchMore(
date.minus({ days: this.dayIncrement }).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: this.dayIncrement }).toISODate(),
() => {
this.ready = true;
},
);
once = false;
}
}
};
},
// Improve me?
// The navigation logic could be a bit simpler if the current days
// where known as a sorted array (= result of groupDocsByDay) But
// then the list would need its own component and this gets rather
// complicated. Then the calendar could also show the present days
// / gray out the missing.
//
// Next two: arg date is ts object
findPrev(date) {
return this.$refs.days
.map((day) => day.date)
.sort()
.reverse()
.find((date2) => date2 < date);
},
findNext(date) {
return this.$refs.days
.map((day) => day.date)
.sort()
.find((date2) => date2 > date);
},
gotoDate(date) {
const present = this.$refs.days.find(
(day) => day.date.toISODate() === date,
);
return "documentation_" + nearestId.toISODate(); if (present) {
// React immediatly -> smoother navigation
// Also intersect handler does not always react to scrollIntoView
this.setDate(date);
present.focus("smooth");
} else if (
!this.findPrev(DateTime.fromISO(date)) ||
!this.findNext(DateTime.fromISO(date))
) {
this.resetDate(date);
}
},
gotoPrev() {
const prev = this.findPrev(DateTime.fromISO(this.currentDate));
if (prev) {
this.gotoDate(prev.toISODate());
}
},
gotoNext() {
const next = this.findNext(DateTime.fromISO(this.currentDate));
if (next) {
this.gotoDate(next.toISODate());
}
}, },
}, },
mounted() { created() {
this.dateStart = this.date; this.resetDate();
this.dateEnd = DateTime.fromISO(this.dateStart)
.plus({ weeks: 1 })
.toISODate();
}, },
}; };
</script> </script>
......
<template>
<v-list-item :style="{ scrollMarginTop: '145px' }" two-line>
<v-list-item-content>
<v-subheader class="text-h6">{{
$d(date, "dateWithWeekday")
}}</v-subheader>
<v-list max-width="100%" class="pt-0 mt-n1">
<v-list-item
v-for="doc in docs"
:key="'documentation-' + (doc.oldId || doc.id)"
>
<documentation-modal
:documentation="doc"
:affected-query="lastQuery"
/>
</v-list-item>
</v-list>
</v-list-item-content>
</v-list-item>
</template>
<script>
import DocumentationModal from "./documentation/DocumentationModal.vue";
export default {
name: "CoursebookDay",
components: {
DocumentationModal,
},
props: {
date: {
type: Object,
required: true,
},
docs: {
type: Array,
required: true,
},
lastQuery: {
type: Object,
required: true,
},
focusOnMount: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["init"],
methods: {
focus(how) {
this.$el.scrollIntoView({
behavior: how,
block: "start",
inline: "nearest",
});
console.log("focused @", this.date.toISODate());
},
},
mounted() {
if (this.focusOnMount) {
this.$nextTick(this.focus("instant"));
this.$emit("init");
}
},
};
</script>
<template> <template>
<div> <div>
<v-list-item v-for="i in 10" :key="'i-' + i"> <v-list-item v-for="i in numberOfDays" :key="'i-' + i">
<v-list-item-content> <v-list-item-content>
<v-list-item-title> <v-list-item-title>
<v-skeleton-loader type="heading" /> <v-skeleton-loader type="heading" />
</v-list-item-title> </v-list-item-title>
<v-list max-width="100%"> <v-list max-width="100%">
<v-list-item v-for="j in 5" :key="'j-' + j"> <v-list-item v-for="j in numberOfDocs" :key="'j-' + j">
<DocumentationLoader /> <DocumentationLoader />
</v-list-item> </v-list-item>
</v-list> </v-list>
...@@ -20,5 +20,17 @@ import DocumentationLoader from "./documentation/DocumentationLoader.vue"; ...@@ -20,5 +20,17 @@ import DocumentationLoader from "./documentation/DocumentationLoader.vue";
export default { export default {
name: "CoursebookLoader", name: "CoursebookLoader",
components: { DocumentationLoader }, components: { DocumentationLoader },
props: {
numberOfDays: {
type: Number,
required: false,
default: 1,
},
numberOfDocs: {
type: Number,
required: false,
default: 1,
},
},
}; };
</script> </script>
...@@ -60,14 +60,14 @@ export default { ...@@ -60,14 +60,14 @@ export default {
component: () => import("./components/coursebook/Coursebook.vue"), component: () => import("./components/coursebook/Coursebook.vue"),
redirect: () => { redirect: () => {
return { return {
name: "alsijil.coursebook_by_type_and_date", name: "alsijil.coursebook",
params: { params: {
date: DateTime.now().toISODate(),
filterType: "my", filterType: "my",
}, },
hash: "#" + DateTime.now().toISODate(),
}; };
}, },
name: "alsijil.coursebook", name: "alsijil.coursebook_landing",
props: true, props: true,
meta: { meta: {
inMenu: true, inMenu: true,
...@@ -79,9 +79,9 @@ export default { ...@@ -79,9 +79,9 @@ export default {
}, },
children: [ children: [
{ {
path: ":date(\\d\\d\\d\\d-\\d\\d-\\d\\d)/:filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/", path: ":filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/",
component: () => import("./components/coursebook/Coursebook.vue"), component: () => import("./components/coursebook/Coursebook.vue"),
name: "alsijil.coursebook_by_type_and_date", name: "alsijil.coursebook",
meta: { meta: {
titleKey: "alsijil.coursebook.menu_title", titleKey: "alsijil.coursebook.menu_title",
toolbarTitle: "alsijil.coursebook.menu_title", toolbarTitle: "alsijil.coursebook.menu_title",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment