Coursebook.vue 10.10 KiB
<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"
@items="docsByDay = groupDocsByDay($event)"
@lastQuery="lastQuery = $event"
ref="iterator"
disable-pagination
hide-default-footer
use-deep-search
>
<template #additionalActions="{ attrs, on }">
<coursebook-filters v-model="filters" />
</template>
<template #default>
<v-list-item
v-for="day in listDocsByDay(docsByDay)"
two-line
:key="'day-' + day[0]"
:id="'documentation_' + day[0].toISODate()"
>
<v-list-item-content>
<v-subheader class="text-h6">{{
$d(day[0], "dateWithWeekday")
}}</v-subheader>
<v-list max-width="100%" class="pt-0 mt-n1">
<v-list-item
v-for="doc in day.slice(1)"
: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>
<date-select-footer :value="$route.hash.substring(1)" />
</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 DocumentationModal from "./documentation/DocumentationModal.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,
DocumentationModal,
},
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,
knownDates: {},
docsByDay: {},
lastQuery: null,
// Placeholder values while query isn't completed yet
groups: [],
courses: [],
incomplete: false,
};
},
computed: {
// Assertion: Should only fire on page load or selection change.
// Resets date range.
gqlQueryArgs() {
console.log('computing gqlQueryArgs');
const dateRange = this.resetDate();
return {
own: this.filterType === "all" ? false : true,
objId: this.objId ? Number(this.objId) : undefined,
objType: this.objType?.toUpperCase(),
dateStart: dateRange[0].toISODate(),
dateEnd: dateRange[1].toISODate(),
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,
});
}
},
},
},
methods: {
resetDate() {
// Assure current date
console.log('Resetting date range', this.$route.hash);
if (!this.$route.hash) {
console.log('Set default date');
this.$router.replace({ hash: DateTime.now().toISODate() })
}
// Resetting known dates to dateRange around current date
this.knownDates = {};
const dateRange = this.dateRange(DateTime.fromISO(this.$route.hash.substring(1)))
dateRange.forEach((ts) => this.knownDates[ts] = true);
const lastIdx = dateRange.length - 1;
// Returning a dateRange each around first & last date for the initial query
return [this.dateRange(dateRange[0])[0], this.dateRange(dateRange[lastIdx])[lastIdx]];
},
// => {dt: [dt doc ...] ...}
groupDocsByDay(docs) {
return docs.reduce((byDay, doc) => {
// This works with dummy. Does actual doc have dateStart instead?
const day = DateTime.fromISO(doc.datetimeStart).startOf("day");
byDay[day] ??= [day];
byDay[day].push(doc);
return byDay;
}, {});
},
// => [[dt doc ...] ...]
listDocsByDay(docsByDay) {
return Object.keys(docsByDay)
.sort()
.map((key) => docsByDay[key]);
},
debounce(fn, delay) {
let timer;
return () => {
console.log('debounce');
clearTimeout(timer);
timer = setTimeout(fn, delay);
}
},
// Adapted from
// https://github.com/vuejs/vuepress/blob/38e98634af117f83b6a32c8ff42488d91b66f663/packages/%40vuepress/plugin-active-header-links/clientRootMixin.js
setCurrentDay() {
const days = Array.from(document.querySelectorAll("[id^='documentation_']"));
const scrollTop = Math.max(
window.pageYOffset,
document.documentElement.scrollTop,
document.body.scrollTop
);
for (let i = 0; i < days.length; i++) {
const day = days[i];
const nextDay =days[i + 1];
if ((scrollTop >= day.offsetTop + 10 || i == 0) && (!nextDay || scrollTop < nextDay.offsetTop - 10)) {
const date = day.id.split("_")[1];
if (date !== this.$route.hash.substring(1)) {
this.gotoDate(date);
}
return
}
}
},
/**
* @param {"prev"|"next"} direction
*/
handleDateMove(direction) {
const dateStartParsed = DateTime.fromISO(this.dateStart);
const dateEndParsed = DateTime.fromISO(this.dateEnd);
const dateParsed = DateTime.fromISO(this.date);
const newDate =
direction === "prev"
? dateParsed.minus({ days: 1 })
: dateParsed.plus({ days: 1 });
/*
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",
params: {
filterType: this.filterType,
objType: this.objType,
objId: this.objId,
},
});
// Define the function to find the nearest ID
const ids = Array.from(
document.querySelectorAll("[id^='documentation_']"),
).map((el) => el.id);
// TODO: This should only be done after loading the new data
const nearestId = this.findNearestId(newDate, direction, ids);
this.$vuetify.goTo("#" + nearestId);
},
findNearestId(targetDate, direction, ids) {
const sortedIds = ids
.map((id) => DateTime.fromISO(id.split("_")[1]))
.sort((a, b) => a - b);
if (direction === "prev") {
sortedIds.reverse();
}
const nearestId =
sortedIds.find((id) =>
direction === "next" ? id >= targetDate : id <= targetDate,
) || sortedIds[sortedIds.length - 1];
return "documentation_" + nearestId.toISODate();
},
dateRange(date) {
return Interval
.fromDateTimes(date.minus({ days: 3 }), date.plus({ days: 4 }))
.splitBy({ days: 1 })
.map((ts) => ts.start);
},
// docsByDay: {dt: [dt doc ...] ...}
assureDate(date) {
if (!this.knownDates[date]) {
// find missing & fetch missing range
const missing = this.dateRange(date).filter((ts) => !this.docsByDay[ts] );
// ask for first to last
this.lastQuery.fetchMore({
variables: {
dateStart: missing[0].toISODate(),
dateEnd: missing[missing.length - 1].toISODate(),
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
return {
tagsPage: {
__typename: previousResult.tagsPage.__typename,
// Merging the tag list
tags: [...previousResult.tagsPage.tags, ...newTags],
hasMore,
},
}
},
})
// integrate into docsByDay
}
},
gotoDate(date, scroll) {
// show
this.$router.replace({ hash: date })
console.log('hash', this.$route.hash);
// assure
this.assureDate(DateTime.fromISO(date));
// scroll
},
},
mounted() {
window.addEventListener('scroll', this.debounce(this.setCurrentDay, 300));
},
};
</script>
<style>
.max-width {
max-width: 25rem;
}
</style>