Skip to content
Commits on Source (271)
......@@ -55,6 +55,11 @@ Added
* Priority to sort announcements
* Allow matching social accounts to local accounts by their username.
* Support RP-initiated logout for OIDC applications
* Mascot images in multiple places throughout the application.
* Support native PostgreSQL connection pooling
* Support profiling with Sentry in addition to tracing
* Make configurable which weekdays appear in the calendar
* Global school term select for limiting data to a specific school term.
Changed
~~~~~~~
......@@ -66,8 +71,8 @@ Changed
* Show only short name, if available, in announcement recipients
* [Dev] Use Django 5.
* Move "Invite person" to persons page
* Rename "People" to "Persons and Groups"
* Replace all mentions of Redis with Valkey where possible
* Show avatars of groups in all places.
Fixed
~~~~~
......@@ -82,6 +87,8 @@ Fixed
* Our own account adapter wasn't used so signup settings weren't applied correctly.
* Improve error handling in frontend and show meaningful error messages.
* [Dev] Integrate model validation mechanisms into GraphQL queries.
* [Container] Database backup failed with postgres versions 15 and 16.
* Setting images for groups did not work
Removed
~~~~~~~
......
......@@ -41,13 +41,16 @@ RUN apt-get -y update && \
libssl-dev \
locales-all \
postgresql-client-14 \
postgresql-client-15 \
postgresql-client-16 \
pspg \
python3-dev \
python3-magic \
python3-pip \
uwsgi \
uwsgi-plugin-python3 \
yarnpkg
yarnpkg \
git
# Install extra dependencies
RUN case ",$EXTRAS," in \
......@@ -78,6 +81,7 @@ CMD ["/usr/local/bin/aleksis-docker-startup"]
# Install assets
FROM core as assets
RUN eatmydata aleksis-admin vite build; \
eatmydata aleksis-admin compile_scss; \
eatmydata aleksis-admin collectstatic --no-input; \
rm -rf /usr/local/share/.cache
# FIXME Introduce deletion after we don't need materializecss anymore for SASS
......@@ -127,6 +131,7 @@ ONBUILD RUN set -e; \
eatmydata pip install $APPS; \
fi; \
eatmydata aleksis-admin vite build; \
eatmydata aleksis-admin compile_scss; \
eatmydata aleksis-admin collectstatic --no-input --clear; \
rm -rf /usr/local/share/.cache; \
eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
......
......@@ -6,12 +6,12 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from django_filters import CharFilter, FilterSet, ModelChoiceFilter, ModelMultipleChoiceFilter
from django_filters import CharFilter, FilterSet, ModelChoiceFilter
from django_select2.forms import ModelSelect2Widget
from guardian.models import GroupObjectPermission, UserObjectPermission
from material import Layout, Row
from aleksis.core.models import Group, GroupType, Person, SchoolTerm
from aleksis.core.models import Person
class MultipleCharFilter(CharFilter):
......@@ -31,19 +31,6 @@ class MultipleCharFilter(CharFilter):
super().__init__(self, *args, **kwargs)
class GroupFilter(FilterSet):
school_term = ModelChoiceFilter(queryset=SchoolTerm.objects.all())
group_type = ModelChoiceFilter(queryset=GroupType.objects.all())
parent_groups = ModelMultipleChoiceFilter(queryset=Group.objects.all())
search = MultipleCharFilter(["name__icontains", "short_name__icontains"], label=_("Search"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form.layout = Layout(Row("search"), Row("school_term", "group_type", "parent_groups"))
self.form.initial = {"school_term": SchoolTerm.current}
class PersonFilter(FilterSet):
name = MultipleCharFilter(
[
......
......@@ -34,7 +34,7 @@ function getGraphqlURL() {
// Define Apollo links for handling query operations.
const links = [
// Automatically retry failed queries
new RetryLink(),
// new RetryLink(),
// Finally, the HTTP link to the real backend (Django)
new BatchHttpLink({
uri: getGraphqlURL(),
......@@ -65,6 +65,24 @@ const apolloOpts = {
return false;
},
error: ({ graphQLErrors, networkError }, vm) => {
if (networkError) {
// Set app offline globally on network errors
// This will cause the offline logic to kick in, starting a ping check or
// similar recovery strategies depending on the app/navigator state
console.error(
"Network error:",
networkError.statusCode,
networkError,
);
if (!networkError.statusCode || networkError.statusCode >= 500) {
console.error(
"Network error during GraphQL query, setting offline state",
);
vm.$root.offline = true;
}
vm.$root.maintenance = networkError.statusCode === 503;
}
if (graphQLErrors) {
for (let err of graphQLErrors) {
console.error(
......@@ -76,23 +94,13 @@ const apolloOpts = {
}
// Add a snackbar on all errors returned by the GraphQL endpoint
// If App is offline, don't add snackbar since only the ping query is active
if (!vm.$root.offline && !vm.$root.invalidation) {
if (!vm.$root.offline) {
vm.handleError(
vm.$t("graphql.snackbar_error_message"),
errorCodes.graphQlErrorQuery,
);
}
}
if (networkError && !vm.$root.invalidation) {
// Set app offline globally on network errors
// This will cause the offline logic to kick in, starting a ping check or
// similar recovery strategies depending on the app/navigator state
console.error("Network error:", networkError);
console.error(
"Network error during GraphQL query, setting offline state",
);
vm.$root.offline = true;
}
},
fetchPolicy: "cache-and-network",
},
......
......@@ -30,6 +30,9 @@ const vuetifyOpts = {
send: "mdi-send-outline",
holidays: "mdi-calendar-weekend-outline",
home: "mdi-home-outline",
groupType: "mdi-shape-outline",
print: "mdi-printer-outline",
schoolTerm: "mdi-calendar-range-outline",
},
},
};
......
export const collections = [
{
name: "groupOverview",
type: Object,
items: [
{
tab: {
id: "default",
titleKey: "group.tabs.members_tab",
},
titleKey: "group.tabs.members",
component: () => import("./components/group/GroupMembers.vue"),
},
],
},
{
name: "groupActions",
type: Object,
},
{
name: "personWidgets",
type: Object,
},
];
export const collectionItems = {
coreGroupActions: [
{
key: "core-delete-group-action",
component: () => import("./components/group/actions/DeleteGroup.vue"),
isActive: (group) => group.canDelete || false,
},
],
};
<!-- General information about AlekSIS as a whole -->
<script setup>
import Mascot from "../generic/mascot/Mascot.vue";
</script>
<template>
<v-row class="mb-3">
<v-col cols="12">
<v-card class="d-flex flex-column">
<v-card-title>{{ $t("about.about_aleksis") }}</v-card-title>
<v-card-text>
<p class="text-body-1">
{{ $t("about.about_aleksis_1") }}
</p>
<p class="text-body-1">
{{ $t("about.about_aleksis_2") }}
</p>
</v-card-text>
<v-spacer />
<v-card-actions>
<v-btn text color="primary" href="https://aleksis.org/">
{{ $t("about.website_of_aleksis") }}
</v-btn>
<v-btn text color="primary" href="https://edugit.org/AlekSIS/">
{{ $t("about.source_code") }}
</v-btn>
</v-card-actions>
<v-card>
<v-row>
<v-col cols="12" md="9" class="d-flex flex-column">
<v-card-title>{{ $t("about.about_aleksis") }}</v-card-title>
<v-card-text>
<p class="text-body-1">
{{ $t("about.about_aleksis_1") }}
</p>
<p class="text-body-1">
{{ $t("about.about_aleksis_2") }}
</p>
</v-card-text>
<v-spacer />
<v-card-actions>
<v-btn text color="primary" href="https://aleksis.org/">
{{ $t("about.website_of_aleksis") }}
</v-btn>
<v-btn text color="primary" href="https://edugit.org/AlekSIS/">
{{ $t("about.source_code") }}
</v-btn>
</v-card-actions>
</v-col>
<v-col cols="12" md="3">
<mascot />
</v-col>
</v-row>
</v-card>
</v-col>
<v-col cols="12">
......
......@@ -13,6 +13,7 @@
short-error-message-key="browser_errors.incompatible_browser"
long-error-message-key="browser_errors.browsers_compatibility"
hide-button="true"
mascot-type="broken"
/>
<div v-else>
<side-nav
......@@ -55,6 +56,7 @@
v-if="whoAmI && whoAmI.isAuthenticated && whoAmI.person"
class="d-flex"
>
<active-school-term-select v-model="$root.activeSchoolTerm" />
<notification-list v-if="!whoAmI.person.isDummy" />
<account-menu
:account-menu="accountMenu"
......@@ -64,6 +66,9 @@
</div>
</v-app-bar>
<v-main>
<active-school-term-banner
v-if="$root.activeSchoolTerm && !$root.activeSchoolTerm.current"
/>
<div
:class="{
'main-container': true,
......@@ -71,7 +76,17 @@
'full-width': $route.meta.fullWidth,
}"
>
<message-box type="warning" v-if="$root.offline">
<message-box type="warning" v-if="$root.maintenance" class="pa-1">
<template #prepend>
<mascot type="broken" max-width="64px" max-height="64px" />
</template>
{{ $t("network_errors.service_unavailable") }}
</message-box>
<message-box type="warning" v-else-if="$root.offline" class="pa-1">
<template #prepend>
<mascot type="offline" max-width="64px" max-height="64px" />
</template>
{{ $t("network_errors.offline_notification") }}
</message-box>
......@@ -104,6 +119,7 @@
redirect-button-text-key="network_errors.back_to_start"
redirect-route-name="dashboard"
redirect-button-icon="$home"
mascot-type="not_found"
>
</error-page>
<router-view
......@@ -125,6 +141,7 @@
redirect-button-text-key="base.no_permission_redirect_text"
redirect-route-name="core.account.login"
redirect-button-icon="mdi-login-variant"
mascot-type="forbidden"
>
</error-page>
</div>
......@@ -233,6 +250,7 @@ import Splash from "./Splash.vue";
import SideNav from "./SideNav.vue";
import SnackbarItem from "./SnackbarItem.vue";
import ErrorPage from "./ErrorPage.vue";
import Mascot from "../generic/mascot/Mascot.vue";
import gqlWhoAmI from "./whoAmI.graphql";
import gqlMessages from "./messages.graphql";
......@@ -245,6 +263,8 @@ import routesMixin from "../../mixins/routes";
import error404Mixin from "../../mixins/error404";
import { browsersRegex } from "virtual:supported-browsers";
import ActiveSchoolTermSelect from "../school_term/ActiveSchoolTermSelect.vue";
import ActiveSchoolTermBanner from "../school_term/ActiveSchoolTermBanner.vue";
export default {
data() {
......@@ -312,10 +332,13 @@ export default {
},
name: "App",
components: {
ActiveSchoolTermBanner,
ActiveSchoolTermSelect,
AccountMenu,
ErrorPage,
NotificationList,
CeleryProgressBottom,
Mascot,
Splash,
SideNav,
SnackbarItem,
......
<script setup>
import Mascot from "../generic/mascot/Mascot.vue";
</script>
<template>
<div
class="d-flex justify-center align-center flex-column text-center"
id="wrapper"
>
<mascot
v-if="mascotType"
:style="{ height: '50vh', width: '50vh' }"
:type="mascotType"
rotate
/>
<h1 class="text-h2">{{ $t(shortErrorMessageKey) }}</h1>
<div>{{ $t(longErrorMessageKey) }}</div>
<v-btn
......@@ -45,6 +55,11 @@ export default {
default: false,
required: false,
},
mascotType: {
type: String,
required: false,
default: "",
},
},
};
</script>
......
query Pinf($payload: String) {
query Ping($payload: String) {
ping(payload: $payload)
}
......@@ -13,6 +13,7 @@
v-model="internalCalendarFocus"
show-week
:events="events"
:weekdays="daysOfWeek"
:type="internalCalendarType"
:event-color="getColorForEvent"
:event-text-color="getTextColorForEvent"
......@@ -62,7 +63,7 @@ import {
calendarFeedEventBarComponents,
} from "aleksisAppImporter";
import gqlCalendar from "./calendar.graphql";
import { gqlCalendar, calendarDaysPreference } from "./calendar.graphql";
export default {
name: "Calendar",
......@@ -88,6 +89,21 @@ export default {
required: false,
default: "600",
},
calendarDaysOfWeek: {
type: Array,
required: false,
default: undefined,
},
/**
* What event/time to jump to.
* Currently possible: `current` for current time, `first` for time of first visible event.
* @values current, first
*/
scrollTarget: {
type: String,
required: false,
default: "current",
},
},
data() {
return {
......@@ -113,8 +129,16 @@ export default {
},
firstTime: 1,
scrolled: false,
ready: false,
personByIdOrMe: {
id: null,
preferences: {
daysOfWeek: [1, 2, 3, 4, 5, 6, 0],
},
},
};
},
emits: ["changeCalendarType", "changeCalendarFocus", "selectEvent"],
......@@ -123,6 +147,12 @@ export default {
query: gqlCalendar,
skip: true,
},
personByIdOrMe: {
query: calendarDaysPreference,
skip() {
return this.calendarDaysOfWeek !== undefined;
},
},
},
computed: {
rangeDateTime() {
......@@ -214,6 +244,13 @@ export default {
nowY() {
return this.cal ? this.cal.timeToY(this.cal.times.now) + "px" : "-10px";
},
daysOfWeek() {
if (this.calendarDaysOfWeek !== undefined) {
return this.calendarDaysOfWeek;
}
return this.personByIdOrMe.preferences.daysOfWeek;
},
},
watch: {
params(newParams) {
......@@ -326,9 +363,18 @@ export default {
this.getMinutesAfterMidnight(event.startDateTime),
);
const minTime =
let minTime =
minuteTimes.length > 0 ? Math.min.apply(Math, minuteTimes) : 0;
// instead of first time take the previous full hour
minTime = Math.floor(Math.max(0, minTime - 1) / 60) * 60;
this.firstTime = minTime;
// When events are loaded, scroll once
if (!this.scrolled && minuteTimes.length > 0) {
this.scrollToTime();
}
},
getMinutesAfterMidnight(date) {
return 60 * date.hour + date.minute;
......@@ -453,10 +499,28 @@ export default {
: 0;
},
scrollToTime() {
const time = this.getCurrentTime();
const first = Math.max(0, time - (time % 30) - 30);
let first;
switch (this.scrollTarget) {
case "first": {
first = this.firstTime;
break;
}
case "current":
default: {
const time = this.getCurrentTime();
first = Math.max(0, time - (time % 30) - 30);
break;
}
}
if (this.startWithFirstTime) {
first = first - this.firstTime;
}
this.cal.scrollToTime(first);
this.scrolled = true;
},
updateTime() {
// TODO: is this destroyed when unloading?
......@@ -466,7 +530,6 @@ export default {
mounted() {
this.ready = true;
this.$refs.calendar.move(0);
this.scrollToTime();
this.updateTime();
},
};
......
......@@ -26,11 +26,13 @@
<!-- Actual calendar -->
<calendar
:calendar-feeds="calendarFeeds"
@changeCalendarFocus="setCalendarFocus"
@changeCalendarType="setCalendarType"
@changeCalendarFocus="handleChangeCalendarFocus"
@changeCalendarType="handleChangeCalendarType"
v-bind="$attrs"
ref="calendar"
:start-with-first-time="startWithFirstTime"
:calendar-days-of-week="calendarDaysOfWeek"
:scroll-target="scrollTarget"
/>
</div>
</template>
......@@ -48,6 +50,7 @@ export default {
CalendarControlBar,
},
mixins: [calendarMixin],
emits: ["changeCalendarFocus", "changeCalendarType", "calendarReady"],
props: {
calendarFeeds: {
type: Array,
......@@ -58,6 +61,36 @@ export default {
required: false,
default: () => true,
},
calendarDaysOfWeek: {
type: Array,
required: false,
default: undefined,
},
/**
* What event/time to jump to.
* Currently possible: `current` for current time, `first` for time of first visible event.
* @values current, first
*/
scrollTarget: {
type: String,
required: false,
default: "current",
},
},
methods: {
handleChangeCalendarFocus(val) {
this.setCalendarFocus(val);
this.$emit("changeCalendarFocus", val);
},
handleChangeCalendarType(val) {
this.setCalendarType(val);
this.$emit("changeCalendarType", val);
},
},
mounted() {
this.$nextTick(() => {
this.$emit("calendarReady");
});
},
};
</script>
query ($names: [String], $start: Date, $end: Date, $params: String) {
query gqlCalendar($names: [String], $start: Date, $end: Date, $params: String) {
calendar {
calendarFeeds(names: $names) {
name
......@@ -22,3 +22,11 @@ query ($names: [String], $start: Date, $end: Date, $params: String) {
}
}
}
query calendarDaysPreference {
personByIdOrMe {
id
preferences {
daysOfWeek
}
}
}
......@@ -256,7 +256,7 @@ export default {
dateStart: item.fullDay ? item.dateStart : undefined,
dateEnd: item.fullDay ? item.dateEnd : undefined,
recurrences: item.recurring === false ? "" : item.recurrences,
timezone: item.recurring === false ? null : DateTime.local().zoneName,
timezone: DateTime.local().zoneName,
persons: this.checkPermission(
"core.create_personal_event_with_invitations_rule",
)
......
<template>
<v-menu transition="slide-y-transition" offset-y>
<v-menu
transition="slide-y-transition"
offset-y
:close-on-content-click="closeOnContentClick"
>
<template #activator="{ on, attrs }">
<slot name="activator" v-bind="{ on, attrs }">
<v-btn outlined text v-bind="attrs" v-on="on">
......@@ -31,6 +35,11 @@ export default {
required: false,
default: "",
},
closeOnContentClick: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
......
......@@ -15,8 +15,9 @@
v-bind="$attrs"
:hide-on-scroll="hideOnScroll"
:height="computedHeight"
:color="$vuetify.theme.dark ? undefined : 'white'"
>
<v-row class="flex-wrap gap align-baseline pt-4">
<v-row class="flex-wrap gap align-baseline py-2">
<!-- @slot Insert title at beginning of header -->
<slot name="title" />
<v-toolbar-title class="d-flex flex-wrap w-sm-100 gap">
......@@ -192,6 +193,7 @@ import DialogObjectForm from "./dialogs/DialogObjectForm.vue";
import CreateButton from "./buttons/CreateButton.vue";
import DeleteDialog from "./dialogs/DeleteDialog.vue";
import crudMixin from "../../mixins/crudMixin.js";
import queryMixin from "../../mixins/queryMixin.js";
export default {
......@@ -203,7 +205,7 @@ export default {
CreateButton,
DeleteDialog,
},
mixins: [queryMixin],
mixins: [crudMixin, queryMixin],
props: {
// MAYBE: Replace with titleI18nKey - It is only used for that.
// BUT this would be a breaking change @ all callsites.
......@@ -344,16 +346,6 @@ export default {
required: false,
default: () => ({}),
},
/**
* Enable editing of items
* via the create component (defaults to DialogObjectForm)
* @values true, false
*/
enableEdit: {
type: Boolean,
required: false,
default: true,
},
/**
* Enable deletion of items
* via the delete component (defaults to DeleteDialog)
......
......@@ -4,6 +4,7 @@
v-on="$listeners"
:items="items"
:items-per-page="itemsPerPage"
:footer-props="footerProps"
:loading="loading"
:class="elevated ? 'elevation-2' : ''"
:search="search"
......@@ -95,6 +96,7 @@ import CRUDBar from "./CRUDBar.vue";
import deepSearchMixin from "../../mixins/deepSearchMixin";
import loadingMixin from "../../mixins/loadingMixin.js";
import syncSortMixin from "../../mixins/syncSortMixin.js";
import itemsPerPageMixin from "../../mixins/itemsPerPageMixin.js";
// TODO: props, data & methods are a subset of CRUDList's -> share?
......@@ -103,7 +105,7 @@ export default {
components: {
CRUDBar,
},
mixins: [deepSearchMixin, loadingMixin, syncSortMixin],
mixins: [deepSearchMixin, loadingMixin, syncSortMixin, itemsPerPageMixin],
props: {
// fixed-header behaves the same as in v-data-table where it is included by vuetify
/**
......@@ -123,15 +125,6 @@ export default {
required: false,
default: true,
},
/**
* Number of items shown per page
* @values natural number
*/
itemsPerPage: {
type: Number,
required: false,
default: 15,
},
},
emits: ["mode", "lastQuery", "deletable", "rawItems", "items", "selection"],
data() {
......
<template>
<v-data-table
v-bind="$attrs"
v-on="$listeners"
:headers="tableHeaders"
:items="items"
:items-per-page="itemsPerPage"
:footer-props="footerProps"
:loading="loading"
:class="elevated ? 'elevation-2' : ''"
:search="search"
......@@ -68,12 +71,24 @@
</c-r-u-d-bar>
</template>
<!-- Header slot template -->
<template
v-for="(_header, idx) in $attrs.headers"
#[headerSlot(_header)]="{ header }"
>
<slot :name="header.value + '.header'" :header="header">
{{ header.text }}
</slot>
</template>
<!-- Row template -->
<template
v-for="(header, idx) in $attrs.headers"
#[rowSlot(header)]="{ item }"
>
<slot :name="header.value" :item="item">{{ item[header.value] }}</slot>
<slot :name="header.value" :item="item">
{{ getKeysRecursive(header.value, item) }}
</slot>
</template>
<!-- Add a action (= btn) column -->
......@@ -104,14 +119,31 @@
</td>
</template>
<template #footer>
<slot name="footer"></slot>
</template>
<template #loading>
<slot name="loading"></slot>
</template>
<template #no-data>
<slot name="no-data"></slot>
<slot name="no-data">
<div class="d-flex flex-column align-center justify-center">
<mascot type="ready_for_items" width="33%" min-width="250px" />
<div class="mb-2">
{{ $t("$vuetify.noDataText") }}
</div>
</div>
</slot>
</template>
<template #no-results>
<slot name="no-results"></slot>
<slot name="no-results">
<div class="d-flex flex-column align-center justify-center">
<mascot type="searching" width="33%" min-width="250px" />
<div class="mb-2">
{{ $t("$vuetify.dataIterator.noResultsText") }}
</div>
</div>
</slot>
</template>
</v-data-table>
</template>
......@@ -120,10 +152,12 @@
import CRUDBar from "./CRUDBar.vue";
import EditButton from "./buttons/EditButton.vue";
import DeleteButton from "./buttons/DeleteButton.vue";
import Mascot from "./mascot/Mascot.vue";
import deepSearchMixin from "../../mixins/deepSearchMixin";
import loadingMixin from "../../mixins/loadingMixin.js";
import syncSortMixin from "../../mixins/syncSortMixin.js";
import itemsPerPageMixin from "../../mixins/itemsPerPageMixin.js";
export default {
name: "CRUDList",
......@@ -131,8 +165,9 @@ export default {
CRUDBar,
EditButton,
DeleteButton,
Mascot,
},
mixins: [deepSearchMixin, loadingMixin, syncSortMixin],
mixins: [deepSearchMixin, loadingMixin, syncSortMixin, itemsPerPageMixin],
props: {
/**
* Elevate the table?
......@@ -143,15 +178,6 @@ export default {
required: false,
default: true,
},
/**
* Number of items shown per table page
* @values natural number
*/
itemsPerPage: {
type: Number,
required: false,
default: 15,
},
/**
* Show the select checkboxes if the table contains selectable items
* @values true, false
......@@ -266,6 +292,9 @@ export default {
rowSlot(header) {
return "item." + header.value;
},
headerSlot(header) {
return "header." + header.value;
},
},
};
</script>
......@@ -54,7 +54,6 @@ import { DateTime } from "luxon";
threshold: [0, 1],
},
}"
@hook:mounted="checkFocus(date)"
:key="'day-' + date"
:date="date"
ref="days"
......@@ -211,6 +210,22 @@ export default {
methods: {
handleItems(items) {
this.groupedItems = this.groupItemsByDay(items);
// Ensure that DOM is updated so that ref to days is present
this.$nextTick(() => {
if (this.initDate) {
// Check if initDate matches any date of the fetched objects.
// If yes, scroll to the section matching this date.
// Only called when initDate is still set.
if (
this.groupedItems.some(
(i) => i.date.toMillis() === this.initDate.toMillis(),
)
) {
this.$nextTick(this.gotoDate(this.initDate.toISODate(), "instant"));
}
this.transition();
}
});
},
resetDate(toDate) {
console.log("Resetting date range", this.$route.hash);
......@@ -320,6 +335,30 @@ export default {
},
});
},
refetchDay(date, then = () => {}) {
console.log("refetching", date);
this.lastQuery.fetchMore({
variables: {
dateStart: date,
dateEnd: date,
},
// Transform the previous result with new data
// Additional operations performed are passed to this method
updateQuery: (previousResult, { fetchMoreResult }) => {
console.log("Received new");
then();
// Exclude all items with same date from previous result and merge
const filteredItems = previousResult.items.filter(
(i) =>
!DateTime.fromISO(i.datetimeStart).hasSame(
DateTime.fromISO(date),
"day",
),
);
return { items: filteredItems.concat(fetchMoreResult.items) };
},
});
},
setDate(date) {
this.currentDate = date;
// Replace the date in the URL's hash with the new date.
......@@ -365,6 +404,7 @@ export default {
document.documentElement.scrollHeight,
document.documentElement.scrollTop,
);
this.ready = true;
},
// Callback function for the case that no items are received
() => {
......@@ -441,35 +481,39 @@ export default {
if (entry.boundingClientRect.top <= this.topMargin) {
// We are in the topmost date that is intersecting but already partly out of view
// → focus on the next one
const newDate = this.findNext(date).toISODate();
console.log("@ ", newDate);
this.setDate(newDate);
// → focus on the next one, if there is a next one
const newDate = this.findNext(date)?.toISODate();
if (newDate) {
console.log("@ ", newDate);
this.setDate(newDate);
}
} else if (first) {
// The element is still fully visible and the first one on the page → focus on it
console.log("@ ", date.toISODate());
this.setDate(date.toISODate());
}
if (once && first) {
// Load items from the increment time period before the
// currently loaded date range when date group is first one.
console.log("load up", date.toISODate());
this.loadUp(
date.minus({ days: this.dayIncrement }),
date.minus({ days: 1 }),
this.dayIncrement,
);
once = false;
} else if (once && date.toMillis() === this.lastDate.toMillis()) {
// Load items from the increment time period after the
// currently loaded date range down when date group is last one.
console.log("load down", date.toISODate());
this.loadDown(
date.plus({ days: 1 }),
date.plus({ days: this.dayIncrement }),
this.dayIncrement,
);
if (once) {
if (first) {
// Load items from the increment time period before the
// currently loaded date range when date group is first one.
console.log("load up", date.toISODate());
this.loadUp(
date.minus({ days: this.dayIncrement }),
date.minus({ days: 1 }),
this.dayIncrement,
);
}
if (date.toMillis() === this.lastDate.toMillis()) {
// Load items from the increment time period after the
// currently loaded date range down when date group is last one.
console.log("load down", date.toISODate());
this.loadDown(
date.plus({ days: 1 }),
date.plus({ days: this.dayIncrement }),
this.dayIncrement,
);
}
once = false;
}
}
......@@ -533,15 +577,6 @@ export default {
this.gotoDate(next.toISODate());
}
},
checkFocus(date) {
// Go to passed date when it matches the initDate.
// Called by each day group on mount; resulting in
// scrolling to it when it represents the initDate.
if (this.initDate && this.initDate.toMillis() === date.toMillis()) {
this.$nextTick(this.gotoDate(date.toISODate(), "instant"));
this.transition();
}
},
focus(element, how) {
// Helper function used to scroll to day group.
element.$el.scrollIntoView({
......
......@@ -8,7 +8,7 @@
:item-id="itemId"
:show-create="isCreate"
:enable-edit="false"
:show-select="isCreate"
:show-select="isCreate && $attrs['show-select']"
:show-action-column="isCreate"
:lock="!isCreate"
:use-deep-search="useDeepSearch"
......@@ -41,7 +41,7 @@
<template #additionalActions>
<edit-button
v-if="isCreate"
v-if="isCreate && enableEdit"
@click="isCreate = false"
:disabled="mode || loading"
/>
......@@ -55,6 +55,18 @@
<slot name="additionalActions" />
</template>
<template #actions="actions">
<slot name="actions" v-bind="actions" />
</template>
<!-- customizable headers -->
<template
v-for="(_header, idx) in $attrs.headers"
#[headerSlot(_header)]="{ header }"
>
<slot :name="headerSlot(header)" :header="header" />
</template>
<!-- Row template -->
<template
v-for="(header, idx) in $attrs.headers"
......@@ -114,6 +126,9 @@
<template #no-results>
<slot name="no-results"></slot>
</template>
<template #createComponent="createComponentProps">
<slot name="createComponent" v-bind="createComponentProps" />
</template>
</c-r-u-d-list>
</v-form>
</template>
......@@ -124,6 +139,7 @@ import EditButton from "./buttons/EditButton.vue";
import CancelButton from "./buttons/CancelButton.vue";
import SaveButton from "./buttons/SaveButton.vue";
import crudMixin from "../../mixins/crudMixin.js";
import createOrPatchMixin from "../../mixins/createOrPatchMixin.js";
import deepSearchMixin from "../../mixins/deepSearchMixin";
......@@ -135,7 +151,7 @@ export default {
CancelButton,
SaveButton,
},
mixins: [createOrPatchMixin, deepSearchMixin],
mixins: [crudMixin, createOrPatchMixin, deepSearchMixin],
emits: ["rawItems", "items", "mode"],
data() {
return {
......@@ -181,6 +197,9 @@ export default {
fieldSlot(header) {
return header.value + ".field";
},
headerSlot(header) {
return header.value + ".header";
},
},
mounted() {
this.$on("save", (_data) => {
......