Vue Attract: Sort/filterable table based on Vue

Initial commit implementing sortable and filterable tables for attract
using Vue.
This commit is contained in:
Tobias Johansson 2019-02-12 09:08:37 +01:00
parent a5bae513e1
commit 2f5f73843d
29 changed files with 776 additions and 30 deletions

View File

@ -1 +1,3 @@
export { thenMarkdownToHtml } from './markdown' export { thenMarkdownToHtml } from './markdown'
export { thenGetProject } from './projects'
export { thenGetNodes } from './nodes'

View File

@ -0,0 +1,8 @@
function thenGetNodes(where, embedded={}) {
let encodedWhere = encodeURIComponent(JSON.stringify(where));
let encodedEmbedded = encodeURIComponent(JSON.stringify(embedded));
return $.get(`/api/nodes?where=${encodedWhere}&embedded=${encodedEmbedded}`);
}
export { thenGetNodes }

View File

@ -0,0 +1,5 @@
function thenGetProject(projectId) {
return $.get(`/api/projects/${projectId}`);
}
export { thenGetProject }

View File

@ -0,0 +1,92 @@
class EventName {
static parentCreated(parentId, node_type) {
return `pillar:node:${parentId}:created-${node_type}`;
}
static globalCreated(node_type) {
return `pillar:node:created-${node_type}`;
}
static updated(nodeId) {
return `pillar:node:${nodeId}:updated`;
}
static deleted(nodeId) {
return `pillar:node:${nodeId}:deleted`;
}
}
class Nodes {
static triggerCreated(node) {
if (node.parent) {
$('body').trigger(
EventName.parentCreated(node.parent, node.node_type),
node);
}
$('body').trigger(
EventName.globalCreated(node.node_type),
node);
}
static onParentCreated(parentId, node_type, cb){
$('body').on(
EventName.parentCreated(parentId, node_type),
cb);
}
static offParentCreated(parentId, node_type, cb){
$('body').off(
EventName.parentCreated(parentId, node_type),
cb);
}
static onCreated(node_type, cb){
$('body').on(
EventName.globalCreated(node_type),
cb);
}
static offCreated(node_type, cb){
$('body').off(
EventName.globalCreated(node_type),
cb);
}
static triggerUpdated(node) {
$('body').trigger(
EventName.updated(node._id),
node);
}
static onUpdated(nodeId, cb) {
$('body').on(
EventName.updated(nodeId),
cb);
}
static offUpdated(nodeId, cb) {
$('body').off(
EventName.updated(nodeId),
cb);
}
static triggerDeleted(nodeId) {
$('body').trigger(
EventName.deleted(nodeId),
nodeId);
}
static onDeleted(nodeId, cb) {
$('body').on(
EventName.deleted(nodeId),
cb);
}
static offDeleted(nodeId, cb) {
$('body').off(
EventName.deleted(nodeId),
cb);
}
}
export { Nodes }

View File

@ -0,0 +1 @@
export {Nodes} from './Nodes'

View File

@ -1,5 +1,4 @@
import { prettyDate } from '../../utils/prettydate'; import { prettyDate } from '../../utils/prettydate';
import { thenLoadImage } from '../utils';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class NodesBase extends ComponentCreatorInterface { export class NodesBase extends ComponentCreatorInterface {
@ -20,7 +19,7 @@ export class NodesBase extends ComponentCreatorInterface {
} }
else { else {
$(window).trigger('pillar:workStart'); $(window).trigger('pillar:workStart');
thenLoadImage(node.picture) pillar.utils.thenLoadImage(node.picture)
.fail(warnNoPicture) .fail(warnNoPicture)
.then((imgVariation) => { .then((imgVariation) => {
let img = $('<img class="card-img-top">') let img = $('<img class="card-img-top">')

View File

@ -1,24 +1,5 @@
function thenLoadImage(imgId, size = 'm') {
return $.get('/api/files/' + imgId)
.then((resp)=> {
var show_variation = null;
if (typeof resp.variations != 'undefined') {
for (var variation of resp.variations) {
if (variation.size != size) continue;
show_variation = variation;
break;
}
}
if (show_variation == null) {
throw 'Image not found: ' + imgId + ' size: ' + size;
}
return show_variation;
})
}
function thenLoadVideoProgress(nodeId) { function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress') return $.get('/api/users/video/' + nodeId + '/progress')
} }
export { thenLoadImage, thenLoadVideoProgress }; export { thenLoadVideoProgress };

View File

@ -0,0 +1,20 @@
function thenLoadImage(imgId, size = 'm') {
return $.get('/api/files/' + imgId)
.then((resp)=> {
var show_variation = null;
if (typeof resp.variations != 'undefined') {
for (var variation of resp.variations) {
if (variation.size != size) continue;
show_variation = variation;
break;
}
}
if (show_variation == null) {
throw 'Image not found: ' + imgId + ' size: ' + size;
}
return show_variation;
})
}
export { thenLoadImage }

View File

@ -1,6 +1,7 @@
export { transformPlaceholder } from './placeholder' export { transformPlaceholder } from './placeholder'
export { prettyDate } from './prettydate' export { prettyDate } from './prettydate'
export { getCurrentUser, initCurrentUser } from './currentuser' export { getCurrentUser, initCurrentUser } from './currentuser'
export { thenLoadImage } from './files'
export function debounced(fn, delay=1000) { export function debounced(fn, delay=1000) {
@ -32,4 +33,4 @@ export function messageFromError(err){
// type xhr probably // type xhr probably
return xhrErrorResponseMessage(err); return xhrErrorResponseMessage(err);
} }
} }

View File

@ -13,7 +13,7 @@ export function prettyDate(time, detail=false) {
let second_diff = Math.round((now - theDate) / 1000); let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24) let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) { if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018" // "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'}); pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
@ -29,7 +29,7 @@ export function prettyDate(time, detail=false) {
else else
pretty = "in " + week_count +" weeks"; pretty = "in " + week_count +" weeks";
} }
else if (day_diff < -1) else if (day_diff < 0)
// "next Tuesday" // "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'}); pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) { else if (day_diff === 0) {
@ -94,4 +94,4 @@ export function prettyDate(time, detail=false) {
} }
return pretty; return pretty;
} }

View File

@ -0,0 +1,17 @@
// Code from https://stackoverflow.com/a/42389266
Vue.directive('click-outside', {
bind: function (el, binding, vnode) {
el.clickOutsideEvent = function (event) {
// here I check that click was outside the el and his childrens
if (!(el == event.target || el.contains(event.target))) {
// and if it did, call method provided in attribute value
vnode.context[binding.expression](event);
}
};
document.body.addEventListener('click', el.clickOutsideEvent)
},
unbind: function (el) {
document.body.removeEventListener('click', el.clickOutsideEvent)
},
});

View File

@ -1 +1,38 @@
import './comments/CommentTree' import './comments/CommentTree'
import './customdirectives/click-outside'
import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker'
import { PillarTable } from './table/Table'
import { CellPrettyDate } from './table/cells/renderer/CellPrettyDate'
import { CellDefault } from './table/cells/renderer/CellDefault'
import { ColumnBase } from './table/columns/ColumnBase'
import { ColumnFactoryBase } from './table/columns/ColumnFactoryBase'
import { RowObjectsSourceBase } from './table/rows/RowObjectsSourceBase'
import { RowBase } from './table/rows/RowObjectBase'
import { RowFilter } from './table/filter/RowFilter'
let mixins = {
UnitOfWorkTracker
}
let table = {
PillarTable,
columns: {
ColumnBase,
ColumnFactoryBase,
},
cells: {
renderer: {
CellDefault,
CellPrettyDate
}
},
rows: {
RowObjectsSourceBase,
RowBase
},
filter: {
RowFilter
}
}
export { mixins, table }

View File

@ -0,0 +1,42 @@
const TEMPLATE =`
<div class="pillar-dropdown">
<div class="pillar-dropdown-button"
:class="buttonClasses"
@click="toggleShowMenu"
>
<slot name="button"/>
</div>
<div class="pillar-dropdown-menu"
v-show="showMenu"
v-click-outside="closeMenu"
>
<slot name="menu"/>
</div>
</div>
`;
let DropDown = Vue.component('pillar-dropdown', {
template: TEMPLATE,
data() {
return {
showMenu: false
}
},
computed: {
buttonClasses() {
return {'is-open': this.showMenu};
}
},
methods: {
toggleShowMenu(event) {
event.preventDefault();
event.stopPropagation();
this.showMenu = !this.showMenu;
},
closeMenu(event) {
this.showMenu = false;
}
},
});
export { DropDown }

View File

@ -42,7 +42,15 @@ var UnitOfWorkTracker = {
methods: { methods: {
unitOfWork(promise) { unitOfWork(promise) {
this.unitOfWorkBegin(); this.unitOfWorkBegin();
return promise.always(this.unitOfWorkDone); if (promise.always) {
// jQuery Promise
return promise.always(this.unitOfWorkDone);
}
if (promise.finally) {
// Native js Promise
return promise.finally(this.unitOfWorkDone);
}
throw Error('Unsupported promise type');
}, },
unitOfWorkBegin() { unitOfWorkBegin() {
this.unitOfWorkCounter++; this.unitOfWorkCounter++;
@ -56,4 +64,4 @@ var UnitOfWorkTracker = {
} }
} }
export { UnitOfWorkTracker } export { UnitOfWorkTracker }

View File

@ -0,0 +1,89 @@
import './rows/renderer/Head'
import './rows/renderer/Row'
import './filter/ColumnFilter'
import './filter/RowFilter'
import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker'
const TEMPLATE =`
<div class="pillar-table-container"
:class="$options.name"
>
<div class="pillar-table-menu">
<pillar-table-row-filter
:rowObjects="rowObjects"
@visibleRowObjectsChanged="onVisibleRowObjectsChanged"
/>
<pillar-table-actions/>
<pillar-table-column-filter
:columns="columns"
@visibleColumnsChanged="onVisibleColumnsChanged"
/>
</div>
<div class="pillar-table">
<pillar-table-head
:columns="visibleColumns"
@sort="onSort"
/>
<transition-group name="pillar-table-row" tag="div" class="pillar-table-row-group">
<pillar-table-row
v-for="rowObject in visibleRowObjects"
:columns="visibleColumns"
:rowObject="rowObject"
:key="rowObject.getId()"
/>
</transition-group>
</div>
</div>
`;
let PillarTable = Vue.component('pillar-table-base', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
// columnFactory,
// rowsSource,
props: {
projectId: String
},
data: function() {
return {
columns: [],
visibleColumns: [],
visibleRowObjects: [],
rowsSource: {}
}
},
computed: {
rowObjects() {
return this.rowsSource.rowObjects || [];
}
},
created() {
let columnFactory = new this.$options.columnFactory(this.projectId);
this.rowsSource = new this.$options.rowsSource(this.projectId);
this.unitOfWork(
Promise.all([
columnFactory.thenGetColumns(),
this.rowsSource.thenInit()
])
.then((resp) => {
this.columns = resp[0];
})
);
},
methods: {
onVisibleColumnsChanged(visibleColumns) {
this.visibleColumns = visibleColumns;
},
onVisibleRowObjectsChanged(visibleRowObjects) {
this.visibleRowObjects = visibleRowObjects;
},
onSort(column, direction) {
function compareRows(r1, r2) {
return column.compareRows(r1, r2) * direction;
}
this.rowObjects.sort(compareRows);
},
}
});
export { PillarTable }

View File

@ -0,0 +1,21 @@
const TEMPLATE =`
<div>
{{ cellValue }}
</div>
`;
let CellDefault = Vue.component('pillar-cell-default', {
template: TEMPLATE,
props: {
column: Object,
rowObject: Object,
rawCellValue: Object
},
computed: {
cellValue() {
return this.rawCellValue;
}
},
});
export { CellDefault }

View File

@ -0,0 +1,12 @@
import { CellDefault } from './CellDefault'
let CellPrettyDate = Vue.component('pillar-cell-pretty-date', {
extends: CellDefault,
computed: {
cellValue() {
return pillar.utils.prettyDate(this.rawCellValue);
}
}
});
export { CellPrettyDate }

View File

@ -0,0 +1,34 @@
const TEMPLATE =`
<component class="pillar-cell"
:class="cellClasses"
:title="cellTitle"
:is="cellRenderer"
:rowObject="rowObject"
:column="column"
:rawCellValue="rawCellValue"
/>
`;
let CellProxy = Vue.component('pillar-cell-proxy', {
template: TEMPLATE,
props: {
column: Object,
rowObject: Object
},
computed: {
rawCellValue() {
return this.column.getRawCellValue(this.rowObject) || '';
},
cellRenderer() {
return this.column.getCellRenderer(this.rowObject);
},
cellClasses() {
return this.column.getCellClasses(this.rawCellValue, this.rowObject);
},
cellTitle() {
return this.column.getCellTitle(this.rawCellValue, this.rowObject);
}
},
});
export { CellProxy }

View File

@ -0,0 +1,43 @@
const TEMPLATE =`
<div class="pillar-cell header-cell"
:class="cellClasses"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div class="cell-content">
{{ column.displayName }}
<div class="column-sort"
v-if="column.isSortable"
>
<i class="sort-action pi-angle-up"
title="Sort Ascending"
@click="$emit('sort', column, 1)"
/>
<i class="sort-action pi-angle-down"
title="Sort Descending"
@click="$emit('sort', column, -1)"
/>
</div>
</div>
</div>
`;
Vue.component('pillar-head-cell', {
template: TEMPLATE,
props: {
column: Object
},
computed: {
cellClasses() {
return this.column.getHeaderCellClasses();
}
},
methods: {
onMouseEnter() {
this.column.highlightColumn(true);
},
onMouseLeave() {
this.column.highlightColumn(false);
},
},
});

View File

@ -0,0 +1,85 @@
import { CellDefault } from '../cells/renderer/CellDefault'
let nextColumnId = 0;
export class ColumnBase {
constructor(displayName, columnType) {
this._id = nextColumnId++;
this.displayName = displayName;
this.columnType = columnType;
this.isMandatory = false;
this.isSortable = true;
this.isHighLighted = 0;
}
/**
*
* @param {*} rowObject
* @returns {String} Name of the Cell renderer component
*/
getCellRenderer(rowObject) {
return CellDefault.options.name;
}
getRawCellValue(rowObject) {
// Should be overridden
throw Error('Not implemented');
}
/**
* Cell tooltip
* @param {Any} rawCellValue
* @param {RowObject} rowObject
* @returns {String}
*/
getCellTitle(rawCellValue, rowObject) {
// Should be overridden
return '';
}
/**
* Object with css classes to use on the header cell
* @returns {Any} Object with css classes
*/
getHeaderCellClasses() {
// Should be overridden
let classes = {}
classes[this.columnType] = true;
return classes;
}
/**
* Object with css classes to use on the cell
* @param {*} rawCellValue
* @param {*} rowObject
* @returns {Any} Object with css classes
*/
getCellClasses(rawCellValue, rowObject) {
// Should be overridden
let classes = {}
classes[this.columnType] = true;
classes['highlight'] = !!this.isHighLighted;
return classes;
}
/**
* Compare two rows to sort them. Can be overridden for more complex situations.
*
* @param {RowObject} rowObject1
* @param {RowObject} rowObject2
* @returns {Number} -1, 0, 1
*/
compareRows(rowObject1, rowObject2) {
let rawCellValue1 = this.getRawCellValue(rowObject1);
let rawCellValue2 = this.getRawCellValue(rowObject2);
if (rawCellValue1 === rawCellValue2) return 0;
return rawCellValue1 < rawCellValue2 ? -1 : 1;
}
/**
*
* @param {Boolean}
*/
highlightColumn(value) {
this.isHighLighted += !!value ? 1 : -1;
}
}

View File

@ -0,0 +1,21 @@
class ColumnFactoryBase{
constructor(projectId) {
this.projectId = projectId;
this.projectPromise;
}
// Override this
thenGetColumns() {
throw Error('Not implemented')
}
thenGetProject() {
if (this.projectPromise) {
return this.projectPromise;
}
this.projectPromise = pillar.api.thenGetProject(this.projectId);
return this.projectPromise;
}
}
export { ColumnFactoryBase }

View File

@ -0,0 +1,10 @@
const TEMPLATE =`
<div class="pillar-table-column"/>
`;
Vue.component('pillar-table-column', {
template: TEMPLATE,
props: {
column: Object
},
});

View File

@ -0,0 +1,80 @@
import '../../menu/DropDown'
const TEMPLATE =`
<div class="pillar-table-column-filter">
<pillar-dropdown>
<i class="pi-cog"
slot="button"
title="Table Settings"/>
<ul class="settings-menu"
slot="menu"
>
Columns:
<li class="attract-column-select"
v-for="c in columnStates"
:key="c._id"
>
<input type="checkbox"
v-model="c.isVisible"
/>
{{ c.displayName }}
</li>
</ul>
</pillar-dropdown>
</div>
`;
let Filter = Vue.component('pillar-table-column-filter', {
template: TEMPLATE,
props: {
columns: Array,
},
data() {
return {
columnStates: [],
}
},
computed: {
visibleColumns() {
return this.columns.filter((candidate) => {
return candidate.isMandatory || this.isColumnStateVisible(candidate);
});
}
},
watch: {
columns() {
this.columnStates = this.setColumnStates();
},
visibleColumns(visibleColumns) {
this.$emit('visibleColumnsChanged', visibleColumns);
}
},
created() {
this.$emit('visibleColumnsChanged', this.visibleColumns);
},
methods: {
setColumnStates() {
return this.columns.reduce((states, c) => {
if (!c.isMandatory) {
states.push({
_id: c._id,
displayName: c.displayName,
isVisible: true,
});
}
return states;
}, [])
},
isColumnStateVisible(column) {
for (let state of this.columnStates) {
if (state._id === column._id) {
return state.isVisible;
}
}
return false;
},
},
});
export { Filter }

View File

@ -0,0 +1,45 @@
const TEMPLATE =`
<div class="pillar-table-row-filter">
<input
placeholder="Filter by name"
v-model="nameQuery"
/>
</div>
`;
let RowFilter = Vue.component('pillar-table-row-filter', {
template: TEMPLATE,
props: {
rowObjects: Array
},
data() {
return {
nameQuery: '',
}
},
computed: {
nameQueryLoweCase() {
return this.nameQuery.toLowerCase();
},
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.filterByName(row);
});
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
filterByName(rowObject) {
return rowObject.getName().toLowerCase().indexOf(this.nameQueryLoweCase) !== -1;
},
},
});
export { RowFilter }

View File

@ -0,0 +1,31 @@
class RowBase {
constructor(underlyingObject) {
this.underlyingObject = underlyingObject;
this.isInitialized = false;
}
thenInit() {
this.isInitialized = true
return Promise.resolve();
}
getName() {
return this.underlyingObject.name;
}
getId() {
return this.underlyingObject._id;
}
getProperties() {
return this.underlyingObject.properties;
}
getRowClasses() {
return {
"is-busy": !this.isInitialized
}
}
}
export { RowBase }

View File

@ -0,0 +1,13 @@
class RowObjectsSourceBase {
constructor(projectId) {
this.projectId = projectId;
this.rowObjects = [];
}
// Override this
thenInit() {
throw Error('Not implemented');
}
}
export { RowObjectsSourceBase }

View File

@ -0,0 +1,18 @@
import '../../cells/renderer/HeadCell'
const TEMPLATE =`
<div class="pillar-table-head">
<pillar-head-cell
v-for="c in columns"
:column="c"
key="c._id"
@sort="(column, direction) => $emit('sort', column, direction)"
/>
</div>
`;
Vue.component('pillar-table-head', {
template: TEMPLATE,
props: {
columns: Array
}
});

View File

@ -0,0 +1,27 @@
import '../../cells/renderer/CellProxy'
const TEMPLATE =`
<div class="pillar-table-row"
:class="rowClasses"
>
<pillar-cell-proxy
v-for="c in columns"
:rowObject="rowObject"
:column="c"
:key="c._id"
/>
</div>
`;
Vue.component('pillar-table-row', {
template: TEMPLATE,
props: {
rowObject: Object,
columns: Array
},
computed: {
rowClasses() {
return this.rowObject.getRowClasses();
}
}
});

View File

@ -11,6 +11,9 @@ body
max-width: 100% max-width: 100%
min-width: auto min-width: auto
.page-body
height: 100%
body.has-overlay body.has-overlay
overflow: hidden overflow: hidden
padding-right: 5px padding-right: 5px
@ -24,6 +27,7 @@ body.has-overlay
.page-content .page-content
background-color: $white background-color: $white
height: 100%
.container-box .container-box
+container-box +container-box