Commit a1381c50 authored by Sandro Lutz's avatar Sandro Lutz
Browse files

Add FilteredListPage class

parent 4e8f8482
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"language.de": "Deutsch", "language.de": "Deutsch",
"language.en": "Englisch", "language.en": "Englisch",
"loading": "Laden...", "loading": "Laden...",
"loading_error": "Ein Fehler ist während dem Laden der Daten aufgetreten.",
"load_more": "Mehr laden",
"load_more_error": "Es ist ein Fehler aufgetreten beim Laden der Daten. Nochmal versuchen?",
"AMIV": "AMIV", "AMIV": "AMIV",
"About AMIV": "Über den AMIV", "About AMIV": "Über den AMIV",
"Board": "Vorstand", "Board": "Vorstand",
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"language.de": "German", "language.de": "German",
"language.en": "English", "language.en": "English",
"loading": "Loading...", "loading": "Loading...",
"loading_error": "Error while loading data.",
"load_more": "Load more",
"load_more_error": "There was an error while loading the data. Try again?",
"AMIV": "AMIV", "AMIV": "AMIV",
"About AMIV": "About AMIV", "About AMIV": "About AMIV",
"Board": "Board", "Board": "Board",
......
import m from 'mithril'; import m from 'mithril';
import { i18n, currentLanguage } from '../../models/language'; import { i18n, currentLanguage } from '../../models/language';
import { EventController } from '../../models/events'; import { EventController } from '../../models/events';
import { FilterView } from '../../components';
import { log } from '../../models/log';
import EventDetails from './eventDetails'; import EventDetails from './eventDetails';
import { FilteredListPage, FilteredListDataStore } from '../filteredListPage';
const controller = new EventController({}, true); const controller = new EventController({}, true);
const stickyPositionTop = { filterView: 0, detailsView: 0 }; const dataStore = new FilteredListDataStore();
let lastScrollPosition = 0;
let filterValues;
let listState = 'loading';
let loadMoreState = 'idle';
let eventLoaded = false;
function renderEventListItem(event, className = '') {
return m(
'div',
{
class: `list-item ${className}`,
onclick: () => {
m.route.set(`/${currentLanguage()}/events/${event._id}`);
},
},
[m('h2', event.getTitle()), m('span', event.time_start), m('span', event.price)]
);
}
/** /**
* EventList class * EventList class
* *
* Used to show the events page including the FilterView and the event details page. * Used to show the events page including the FilterView and the event details page.
*/ */
export default class EventList { export default class EventList extends FilteredListPage {
constructor(vnode) { constructor() {
document.addEventListener('scroll', EventList.onscroll); super('event', dataStore, true);
window.addEventListener('resize', EventList.onscroll);
if (vnode.attrs.eventId) {
controller
.loadEvent(vnode.attrs.eventId)
.then(() => {
eventLoaded = true;
})
.catch(err => {
eventLoaded = true;
log(err);
});
}
} }
static _reload() { oninit(vnode) {
listState = 'loading'; super.oninit(vnode, vnode.attrs.eventId);
controller
.refresh()
.then(() => {
listState = 'loaded';
})
.catch(err => {
log(err);
listState = 'error';
});
} }
static onscroll() { // eslint-disable-next-line class-methods-use-this
const filterView = document.getElementById('eventListFilterView'); _loadItem(eventId) {
const detailsView = document.getElementById('eventListDetailsView'); return controller.loadEvent(eventId);
EventList.updateViewPosition(filterView, 'filterView');
EventList.updateViewPosition(detailsView, 'detailsView');
lastScrollPosition = document.documentElement.scrollTop;
} }
static updateViewPosition(element, positionKey) { // eslint-disable-next-line class-methods-use-this
const windowHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); _reloadData() {
const scrollDelta = document.documentElement.scrollTop - lastScrollPosition; return controller.refresh();
const maxPosition = Math.min(windowHeight - element.scrollHeight, 0);
stickyPositionTop[positionKey] = Math.min(
0,
Math.max(stickyPositionTop[positionKey] - scrollDelta, maxPosition)
);
// eslint-disable-next-line no-param-reassign
element.style.top = `${stickyPositionTop[positionKey]}px`;
} }
view(vnode) { get _filterViewAttributes() {
let detailView; return {
if (vnode.attrs.eventId) { fields: [
if (eventLoaded) {
detailView = m(
'div.details',
{
id: 'eventListDetailsView',
style: {
top: `${stickyPositionTop.detailsView}px`,
},
},
m(EventDetails, { controller })
);
} else {
// Do not show anything on details panel when event data has not been loaded.
detailView = m('');
}
} else {
detailView = m(
'div.details',
{ {
id: 'eventListDetailsView', type: 'text',
style: { key: 'title',
top: `${stickyPositionTop.detailsView}px`, label: i18n('events.searchfield'),
}, min_length: 3,
}, },
m('h1', i18n('events.no_selection')) {
); type: 'button',
} label: i18n('search'),
},
return m('div#event-list', [ {
m('div', this.constructor.filterView), type: 'checkbox',
m('div.content', this.constructor.listView), key: 'price',
m('div', detailView), label: i18n('events.price'),
]); default: ['free', 'small_fee'],
} values: [
{ value: 'free', label: i18n('events.free') },
static get filterView() { { value: 'small_fee', label: i18n('events.small_fee') },
return m( ],
'div.filter',
{
id: 'eventListFilterView',
style: {
top: `${stickyPositionTop.filterView}px`,
}, },
{
type: 'radio',
label: i18n('events.restrictions'),
key: 'signup_restrictions',
default: 'members_only',
values: [
{ label: i18n('events.open_for_all'), value: 'all' },
{ label: i18n('events.open_for_amiv_members_only'), value: 'members_only' },
],
},
],
onchange: values => {
const query = {};
this.dataStore.filterValues = values;
Object.keys(values).forEach(key => {
const value = values[key];
if (key === 'price' && value.length === 1) {
const conditions = [];
if (value.includes('free')) {
conditions.push({ price: null }, { price: 0 });
}
if (value.includes('small_fee')) {
conditions.push({ price: { $gt: 0 } });
}
if (conditions.length > 0) {
query.$and = [{ $or: conditions }];
}
} else if (key === 'signup_restrictions') {
if (value === 'all') {
query.allow_email_signup = true;
}
} else if (key === 'title' && value.length > 0) {
query.title_en = { $regex: `^(?i).*${value}.*` };
query.title_de = { $regex: `^(?i).*${value}.*` };
query.catchphrase_en = { $regex: `^(?i).*${value}.*` };
query.catchphrase_de = { $regex: `^(?i).*${value}.*` };
query.description_en = { $regex: `^(?i).*${value}.*` };
query.description_de = { $regex: `^(?i).*${value}.*` };
}
});
controller.setQuery({ where: query });
}, },
m(FilterView, { };
fields: [ }
{
type: 'text',
key: 'title',
label: i18n('events.searchfield'),
min_length: 3,
},
{
type: 'button',
label: i18n('search'),
},
{
type: 'checkbox',
key: 'price',
label: i18n('events.price'),
default: ['free', 'small_fee'],
values: [
{ value: 'free', label: i18n('events.free') },
{ value: 'small_fee', label: i18n('events.small_fee') },
],
},
{
type: 'radio',
label: i18n('events.restrictions'),
key: 'signup_restrictions',
default: 'members_only',
values: [
{ label: i18n('events.open_for_all'), value: 'all' },
{ label: i18n('events.open_for_amiv_members_only'), value: 'members_only' },
],
},
],
onchange: values => {
const query = {};
filterValues = values;
Object.keys(values).forEach(key => {
const value = values[key];
if (key === 'price' && value.length === 1) { get _listView() {
const conditions = []; return [
m(
'div.registration',
controller.openRegistrationEvents.map(page =>
page.map(event => this.constructor._renderEventListItem(event, 'registration'))
)
),
m(
'div.upcoming',
controller.upcomingEvents.map(page =>
page.map(event => this.constructor._renderEventListItem(event, 'upcoming'))
)
),
m(
'div.past',
controller.pastEvents.map(page =>
page.map(event => this.constructor._renderEventListItem(event, 'past'))
)
),
];
}
if (value.includes('free')) { // eslint-disable-next-line class-methods-use-this
conditions.push({ price: null }, { price: 0 }); get _detailsView() {
} return m(EventDetails, { controller });
if (value.includes('small_fee')) {
conditions.push({ price: { $gt: 0 } });
}
if (conditions.length > 0) {
query.$and = [{ $or: conditions }];
}
} else if (key === 'signup_restrictions') {
if (value === 'all') {
query.allow_email_signup = true;
}
} else if (key === 'title' && value.length > 0) {
query.title_en = { $regex: `^(?i).*${value}.*` };
query.title_de = { $regex: `^(?i).*${value}.*` };
query.catchphrase_en = { $regex: `^(?i).*${value}.*` };
query.catchphrase_de = { $regex: `^(?i).*${value}.*` };
query.description_en = { $regex: `^(?i).*${value}.*` };
query.description_de = { $regex: `^(?i).*${value}.*` };
}
});
controller.setQuery({ where: query });
},
values: filterValues,
})
);
} }
static get listView() { // eslint-disable-next-line class-methods-use-this
let listView; get _detailsPlaceholderView() {
if (listState === 'loading') { return m('h1', i18n('events.no_selection'));
listView = m('span', i18n('events.loading'));
} else if (listState === 'loaded') {
listView = [
m(
'div.registration',
controller.openRegistrationEvents.map(page =>
page.map(event => renderEventListItem(event, 'registration'))
)
),
m(
'div.upcoming',
controller.upcomingEvents.map(page =>
page.map(event => renderEventListItem(event, 'upcoming'))
)
),
m(
'div.past',
controller.pastEvents.map(page => page.map(event => renderEventListItem(event, 'past')))
),
EventList.loadMoreView,
];
} else {
listView = m('span', 'Error while loading events.');
}
return listView;
} }
static get loadMoreView() { // eslint-disable-next-line class-methods-use-this
if (loadMoreState === 'loading') { async _loadNextPage() {
return m('div.load-more-items', i18n('events.loading')); const newPage = controller.pastEvents.lastLoadedPage + 1;
} else if ( if (newPage <= controller.pastEvents.totalPages) {
loadMoreState === 'noMorePages' || await controller.pastEvents.loadPageData(newPage);
controller.pastEvents.lastLoadedPage === controller.pastEvents.totalPages
) {
return m('');
} }
}
static _renderEventListItem(event, className = '') {
return m( return m(
'div.load-more-items.active', 'div',
{ {
class: `list-item ${className}`,
onclick: () => { onclick: () => {
const newPage = controller.pastEvents.lastLoadedPage + 1; m.route.set(`/${currentLanguage()}/events/${event._id}`);
if (newPage <= controller.pastEvents.totalPages) {
loadMoreState = 'loading';
controller.pastEvents.loadPageData(newPage).then(() => {
loadMoreState = 'idle';
m.redraw();
});
}
}, },
}, },
i18n('events.load_more') [m('h2', event.getTitle()), m('span', event.time_start), m('span', event.price)]
); );
} }
} }
EventList._reload();
import m from 'mithril';
import { error } from '../models/log';
import { i18n } from '../models/language';
import { FilterView } from '../components';
/**
* FilteredListDataStore class
*
* The instance of this class should be a file-global variable (static)
* in order to survive any change in the URL.
*/
export class FilteredListDataStore {
constructor() {
this._positionTop = {};
this.filterViewPositionTop = 0;
this.detailsViewPositionTop = 0;
this.lastScrollPosition = 0;
this.listState = 'loading';
this.loadMoreState = 'idle';
this.detailsLoaded = false;
this.filterValues = {};
this.initialized = false;
}
get listState() {
return this._listState;
}
set listState(state) {
if (!['loading', 'loaded', 'error'].includes(state)) {
throw new Error(`Invalid state '${state}' for 'listState'`);
}
this._listState = state;
}
get loadMoreState() {
return this._loadMoreState;
}
set loadMoreState(state) {
if (!['idle', 'loading', 'noMorePages', 'error'].includes(state)) {
throw new Error(`Invalid state '${state}' for 'loadMoreState'`);
}
this._loadMoreState = state;
}
get detailsLoaded() {
return this._detailsLoaded;
}
set detailsLoaded(value) {
this._detailsLoaded = value;
}
get filterValues() {
return this._filterValues;
}
set filterValues(values) {
this._filterValues = values;
}
get lastScrollPosition() {
return this._lastScrollPosition;
}
set lastScrollPosition(position) {
this._lastScrollPosition = position;
}
get isInitialized() {
return this._isInitialized;
}
setIsInitialized() {
this._isInitialized = true;
}
getPositionTop(key) {
return this._positionTop[key] || 0;
}
setPositionTop(key, position) {
if (position < 0) {
this._positionTop[key] = position;
} else {
this._positionTop[key] = 0;
}
}
}
/**
* FilteredListPage class
*
* This is an abstract base class to create a filtered list page.
*/
export class FilteredListPage {
/**
* Constructor
*
* @param {String} name identifier of this page (used to specify unique element IDs)
* @param {FilteredListDataStore} dataStore persistent data store
* @param {boolean} hasDetailsPage specify whether there is a details view or not
*/
constructor(name, dataStore, hasDetailsPage = true) {
this.name = name;
this.dataStore = dataStore;
this.hasDetailsPage = hasDetailsPage;
}
/**
* @param {object} vnode
* @param {String} itemId id of the item to be shown on the details page
*/
oninit(vnode, itemId) {
document.addEventListener('scroll', () => this.onscroll());
window.addEventListener('resize', () => this.onscroll());
if (!this.dataStore.isInitialized) {
this.reload().then(() => {
this.dataStore.setIsInitialized();
});
}
if (this.hasDetailsPage && itemId) {
this.detailsItemId = itemId;
this._loadItem(itemId)
.then(() => {
this.dataStore.detailsLoaded = true;
})
.catch(() => {
this.dataStore.detailsLoaded = true;
});
}
}
/* eslint-disable class-methods-use-this, no-unused-vars */
/**
* Used to load a single item for the details view
*
* *This is an abstract function!
* Implementation in child class is mandatory if details page is enabled.*
*
* @param {String} itemId id of the item to be shown on the details page
* @return {Promise}
* @protected
*/
_loadItem(itemId) {
throw new Error('_loadItem() not implemented');
}
/**
* Used to reload the items list
*
* *This is a dummy function!
* Implementation in child class is highly recommended.*
*
* @return {Promise}
* @protected
*/
_reloadData() {
// Implementation needed in child class.
}
/**
* Gives the configuration of the FilterView
*
* *This is a dummy function!
* Implementation in child class is highly recommended.*
*
* @return {object} FilterView attributes
* @protected
*/
get _filterViewAttributes() {
return { fields: [], onchange: () => {} };