Verified Commit d7b23fa2 authored by Sandro Lutz's avatar Sandro Lutz
Browse files

Add QuickFilter to studydocuments page

parent a4b03000
......@@ -17,7 +17,7 @@ ButtonCSS.addStyle('.red-button', {
});
ButtonCSS.addStyle('.flat-button', {
color_light_background: 'white',
color_light_background: 'transparent',
color_light_text: 'black',
});
......
......@@ -311,7 +311,16 @@ export default class FilterViewComponent {
return field.content;
}
view() {
view({ attrs: { values } }) {
const argValuesJson = JSON.stringify(values);
const curValuesJson = JSON.stringify(this.values);
if (argValuesJson !== curValuesJson && argValuesJson !== this.previousValues) {
this.previousValues = curValuesJson;
this.values = values;
this.notify();
}
const views = [];
m('div#filter-page-style', [
......
import m from 'mithril';
import Stream from 'mithril/stream';
import { List } from 'polythene-mithril-list';
import { ListTile } from 'polythene-mithril-list-tile';
import { Search } from 'polythene-mithril-search';
import { IconButton } from 'polythene-mithril-icon-button';
import { Toolbar, ToolbarTitle } from 'polythene-mithril-toolbar';
import debounce from 'amiv-web-ui-components/src/debounce';
import icons from '../images/icons';
const BackButton = {
view: ({ attrs }) =>
m(IconButton, {
icon: { svg: m.trust(icons.back) },
ink: false,
events: { onclick: attrs.leave },
}),
};
const ClearButton = {
view: ({ attrs }) =>
m(IconButton, {
icon: { svg: m.trust(icons.clear) },
ink: false,
events: { onclick: attrs.clear },
}),
};
class SearchField {
// copy-paste from polythene-mithril examples
oninit() {
this.value = Stream('');
this.setInputState = Stream();
this.clear = () => this.value('');
this.leave = () => this.value('');
}
view({ attrs }) {
// incoming value and focus added for result list example:
const value = attrs.value !== undefined ? attrs.value : this.value();
return m(
Search,
Object.assign(
{},
{
textfield: {
label: attrs.placeholderText,
onChange: newState => {
this.value(newState.value);
this.setInputState(newState.setInputState);
// onChange callback added for result list example:
if (attrs.onChange) attrs.onChange(newState, this.setInputState);
},
value,
defaultValue: attrs.defaultValue,
},
buttons: {
focus: {
before: m(BackButton, { leave: this.leave }),
},
focus_dirty: {
before: m(BackButton, { leave: this.leave }),
after: m(ClearButton, { clear: this.clear }),
},
dirty: {
before: m(BackButton, { leave: this.leave }),
after: m(ClearButton, { clear: this.clear }),
},
},
},
attrs
)
);
}
}
export default class ListSelect {
/**
* A selection field where the value can be choosen from a large list of items
* loaded from an API resource (e.g. select a user or an event).
*
* @param {object} attrs.options Array containing the selectable items
* * as a list of strings (value and label will be the same)
* Example: `['item1', 'item2']`
* * as a list of objects (set label and value independently)
* Example: `[{ label: 'label1', value: 'value1' }, { label: 'label2', value: 'value2', disabled: true }]`
* @param {string} attrs.placeholderText placeholder text (default: 'type here')
* Shown when searchfield is empty
* @param {function} attrs.listTileAttrs funtion(item)
* Function that maps an API object to attributes of a polythene 'ListTile' to display the
* possibilities out of which the user can select one.
* @param {function} attrs.onSelect function(item)
* Callback when an item is selected
* @param {object} attrs.toolbarAttrs attributes passed to the toolbar component
* @param {string} attrs.toolbarAttrs.background background color of the toolbar/searchfield
* (default: 'rgb(78, 242, 167)')
* @param {object} attrs.listAttrs attributes passed to the list component
* @param {string} attrs.listAttrs.background background color of the search result list
* (default: 'white')
* @param {string|null} attrs.listAttrs.height height of the search result list.
* Unrestricted if set to null. (default: '400px')
* @param {boolean} attrs.listAttrs.permanent show the search result list permanently
* This is independent of focus state of the searchfield. (default: false)
*/
constructor({ attrs: { options, listTileAttrs, onSelect = false } }) {
this.showList = false;
this.searchValue = '';
this.listTileAttrs = listTileAttrs;
// initialize the Selection
this.selected = null;
this.onSelect = onSelect;
this.options = options;
this.filteredOptions = options;
this.debouncedSearch = debounce(search => {
if (search) {
const regex = RegExp(`.*(${search}).*`, 'gi');
this.filteredOptions = options.filter(item => regex.test(item));
} else {
this.filteredOptions = this.options;
}
}, 100);
}
onupdate({ attrs: { options, selection = null } }) {
// make it possible to change the selection from outside, e.g. to set the field to an
// existing group moderator
if (selection) this.selected = selection;
this.options = options;
}
view({ attrs: { placeholderText = 'type here', toolbarAttrs, listAttrs } }) {
const toolbarAppliedAttrs = { background: 'rgb(78, 242, 167)', ...toolbarAttrs };
const listAppliedAttrs = {
background: 'white',
height: '400px',
permanent: false,
...listAttrs,
};
return m('div', [
m(
Toolbar,
{
...toolbarAppliedAttrs,
compact: true,
style: { ...toolbarAppliedAttrs.style, background: toolbarAppliedAttrs.background },
},
this.selected
? [
m(IconButton, {
icon: { svg: m.trust(icons.clear) },
ink: false,
events: {
onclick: () => {
if (this.onSelect) {
this.onSelect(null);
}
this.selected = null;
},
},
}),
m(ToolbarTitle, { text: this.selected }),
]
: [
m(
SearchField,
Object.assign(
{},
{
placeholderText,
style: { background: toolbarAppliedAttrs.background },
onChange: ({ value, focus }) => {
// onChange is called either if the value or focus of the SearchField
// changes.
// At value change we want to update the search
// at focus change we hide the list of results. As focus change also
// happens while clicking on an item in the list of results, the list
// is hidden after a short Timeout that has to be sufficiently long
// to register the onclick of the listitem. Can be a problem for different
// OS and browsers.
if (focus) {
this.showList = true;
} else if (!focus) {
// don't close the list immidiately, as 'out of focus' could
// also mean that the user is clicking on a list item
setTimeout(() => {
this.showList = false;
m.redraw();
}, 500);
}
if (value !== this.searchValue) {
// if we always update the search value, this would also happen
// immidiately in the moment where we click on the listitem.
// Then, the list get's updated before the click is registered.
// So, we make sure this state change is due to value change and
// not due to focus change.
this.searchValue = value;
this.debouncedSearch(value);
}
},
}
)
),
]
),
(this.showList || listAppliedAttrs.permanent) && !this.selected
? m(List, {
...listAppliedAttrs,
style: {
...listAppliedAttrs.style,
height: listAppliedAttrs.height,
background: listAppliedAttrs.background,
},
tiles: this.filteredOptions.map(item => this._renderItem(item)),
})
: null,
]);
}
_renderItem(item) {
return m(ListTile, {
title: item,
compactFront: true,
hoverable: true,
events: {
onclick: () => {
if (this.onSelect) {
this.onSelect(item);
}
this.selected = item;
this.showList = false;
},
},
});
}
}
......@@ -188,6 +188,20 @@ export default {
// Studydocuments Page
studydocs: {
quickfilter: {
title: 'Schnellfilter',
selection: 'Auswahl',
selectSemester: 'Departement und Semester auswählen',
selectDepartment: 'Departement auswählen',
selectLecture: 'Vorlesung auswählen',
lecturePlaceholder: 'Vorlesung hier eintippen',
loadingError: 'Ups, die Filteroptionen konnten nicht geladen werden.',
},
departments: {
all: 'Alle',
other: 'Andere',
otherLong: 'Andere Departemente',
},
types: {
cheatsheets: 'Zusammenfassungen',
exams: 'Alte Prüfungen',
......
......@@ -188,6 +188,20 @@ export default {
// Studydocuments Page
studydocs: {
quickfilter: {
title: 'Quick Filter',
selection: 'Selection',
selectSemester: 'Select department and semester',
selectDepartment: 'Select department',
selectLecture: 'Select lecture',
lecturePlaceholder: 'Type lecture name',
loadingError: 'Oops, we could not load the filter values.',
},
departments: {
all: 'All',
other: 'Other',
otherLong: 'Other Departments',
},
types: {
cheatsheets: 'Summaries',
exams: 'Old exams',
......
import { DatalistController } from 'amiv-web-ui-components';
/**
* Controller for a list of data provided by a function.
* This controller only supports a single page of data.
*/
export default class SimpleDatalistController extends DatalistController {
/**
* @param {function} get function(search),
* returns results for the given search (using Promise). Search is a
* simple string that has to be defined by the get-function to perform any kind of
* string-matching that makes sense for the represented data
*/
constructor(get) {
super(get, {});
this.setFilter = undefined;
this.setQuery = undefined;
}
/**
* Return the data of the only page.
*
* @param {int} pageNum - The page number
* @return {Promise} The page data as a list.
*/
getPageData(pageNum) {
if (pageNum !== 1) return [];
return this.get(this.search);
}
/**
* Get all available pages
*/
getFullList() {
return this.getPageData(1);
}
setSearch(search) {
this.search = search;
}
}
......@@ -32,6 +32,38 @@ export default class StudydocsController extends PaginationController {
return true;
}
/**
* Set a new query to load the configured resource
*
* @return {boolean} `true` - if query has changed; `false` - otherwise
* @public
*/
async setFilterValues(filterValues) {
const query = {};
Object.keys(filterValues).forEach(key => {
let value = filterValues[key];
if (Array.isArray(value) && value.indexOf('all') === -1) {
query[key] = { $in: value };
} else if (key === 'title' && value.length > 0) {
value = value.substring(0, value.length);
query.$or = [
{ title: { $regex: `^(?i).*${value}.*` } },
{ lecture: { $regex: `^(?i).*${value}.*` } },
{ author: { $regex: `^(?i).*${value}.*` } },
{ professor: { $regex: `^(?i).*${value}.*` } },
];
}
// Remove from query is all document types are selected.
if (query.type && query.type.$in.length === 4) {
delete query.type;
}
});
return this.setQuery({ where: query });
}
/** Check if the study document is already loaded */
isDocumentLoaded(documentId) {
const test = item => item._id === documentId;
......
......@@ -16,7 +16,7 @@
@import './mediaquery.less';
@import './profile.less';
@import './studydocList.less';
@import './studydocDetails.less';
@import './studydocQuickFilter.less';
@import './studydocNew.less';
@import './fileUpload.less';
@import './filterView.less';
......
.studydoc-details-table {
.flex-container {
display: flex;
flex-wrap: nowrap;
}
.flex-container > div {
padding: 0 5px 5px 0;
text-align: left;
}
b {
display: inline-block;
width: 8em;
padding: 0 5px 5px 0;
}
.button-details-style {
display: inline-block;
width: 8em;
margin-right: 1em;
padding: 0 5px 5px 0;
}
#title-wrap-style {
display: inline-block;
text-align: center;
white-space: nowrap;
max-width: 8em;
overflow: hidden;
text-overflow: ellipsis;
direction: ltr;
}
}
@import './colors.less';
.studydocs-quickfilter {
transition: background 300ms cubic-bezier(.4, 0, .2, 1) !important;
background-color: @color-grey;
&.expanded {
background-color: @color-white;
}
.pe-toolbar {
background-color: transparent;
}
}
.studydocs-quickfilter-container {
padding-bottom: 1em;
width: 100%;
height: 100%;
.spinner {
width: 100%;
height: 3em;
position: relative;
}
.selection {
width: 100%;
opacity: 1;
padding: 0 1em;
transition: opacity 400ms cubic-bezier(0, 0, .2, 1) 0ms, width 400ms cubic-bezier(0, 0, .2, 1) 0ms;
> * {
display: inline-block;
}
.button {
margin-right: 1em;
}
.unset {
font-style: italic;
}
.header > h3,span {
display: inline;
}
.header span {
color: @color-red;
cursor: pointer;
}
.header h3:after {
content: ' \2013 ';
font-weight: normal;
}
}
.filtercontent > * {
margin: 0 16px;
}
.semester {
text-align: left;
}
.department > div {
display: inline-block;
width: 5em;
padding-right: 1em;
font-weight: bold;
}
}
......@@ -305,6 +305,10 @@ export class FilteredListPage {
/* eslint-enable */
static get pinnedListIdentifier() {
return 'pinned';
}
reload() {
this.dataStore.listState = LIST_LOADING;
return this._reloadData()
......@@ -463,12 +467,25 @@ export class FilteredListPage {
if (this.dataStore.pinnedItem && !this.dataStore.pinnedItem.loading) {
pinnedList = this._renderList({
name: 'pinned',
name: this.constructor.pinnedListIdentifier,
items: [this.dataStore.pinnedItem.item],
});
}
return [pinnedList, ...this._lists.map(list => this._renderList(list))];
const lists = this._lists;
const containsPinnedList = lists.some(
list => list.name === this.constructor.pinnedListIdentifier
);
return [
!containsPinnedList ? pinnedList : null,
...lists.map(list => {
if (list.name === this.constructor.pinnedListIdentifier) {
return pinnedList;
}
return this._renderList(list);
}),
];
}
return this.constructor._renderFullPageMessage(i18n('emptyList'));
}
......
......@@ -10,6 +10,7 @@ import StudydocsController from '../../models/studydocs';
import { i18n, currentLanguage } from '../../models/language';
import { FilteredListDataStore, FilteredListPage } from '../filteredListPage';
import mimeTypeToIcon from '../../images/mimeTypeToIcon';
import StudydocQuickFilter from './studydocQuickFilter';
const controller = new StudydocsController();
const dataStore = new FilteredListDataStore();
......@@ -208,32 +209,12 @@ export default class StudydocList extends FilteredListPage {
},
],
onchange: values => {
const query = {};
this.dataStore.filterValues = values;
Object.keys(values).forEach(key => {
let value = values[key];
if (Array.isArray(value) && value.indexOf('all') === -1) {
query[key] = { $in: value };
} else if (key === 'title' && value.length > 0) {
value = value.substring(0, value.length);
query.$or = [
{ title: { $regex: `^(?i).*${value}.*` } },
{ lecture: { $regex: `^(?i).*${value}.*` } },
{ author: { $regex: `^(?i).*${value}.*` } },
{ professor: { $regex: `^(?i).*${value}.*` } },
];
}
if (query.department && query.department.$in.length === 2) {
delete query.department;
}
if (query.type && query.type.$in.length === 4) {
delete query.type;
if (controller.setFilterValues(values)) {
StudydocQuickFilter.clear();
return true;
}
});
return controller.setQuery({ where: query });
return false;
},
};
}
......@@ -241,6 +222,13 @@ export default class StudydocList extends FilteredListPage {
// eslint-disable-next-line class-methods-use-this
get _lists() {
return [
{
name: 'quickfilter',
items: [m(StudydocQuickFilter, { controller, dataStore })],
},
{
name: FilteredListPage.pinnedListIdentifier,
},
{
name: 'studydocs',
pages: controller,
......@@ -250,9 +238,13 @@ export default class StudydocList extends FilteredListPage {
}
// eslint-disable-next-line class-methods-use-this