Store filter/column settings in localStorage

The filter and column settings in tables are stored per project and
context in the browsers localStorage. This makes the table keep the
settings even if the browser is refreshed or restarted.

The table emits a "componentStateChanged" event containing the tables
current state (filter/column settings) which then is saved by the top
level component.
This commit is contained in:
Tobias Johansson 2019-03-28 10:29:13 +01:00
parent f6056f4f7e
commit 465f1eb87e
13 changed files with 562 additions and 152 deletions

View File

@ -9,7 +9,11 @@ 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'
import { RowFilter } from './table/rows/filter/RowFilter'
import { EnumFilter } from './table/rows/filter/EnumFilter'
import { StatusFilter } from './table/rows/filter/StatusFilter'
import { TextFilter } from './table/rows/filter/TextFilter'
import { NameFilter } from './table/rows/filter/NameFilter'
let mixins = {
UnitOfWorkTracker,
@ -31,12 +35,16 @@ let table = {
}
},
rows: {
filter: {
RowFilter,
EnumFilter,
StatusFilter,
TextFilter,
NameFilter
},
RowObjectsSourceBase,
RowBase,
},
filter: {
RowFilter
},
}
export { mixins, table }

View File

@ -1,6 +1,6 @@
const TEMPLATE =`
<div class="pillar-dropdown">
<div class="pillar-dropdown-button"
<div class="pillar-dropdown-button action"
:class="buttonClasses"
@click="toggleShowMenu"
>

View File

@ -1,8 +1,9 @@
import './rows/renderer/Head'
import './rows/renderer/Row'
import './filter/ColumnFilter'
import './filter/RowFilter'
import './columns/filter/ColumnFilter'
import './rows/filter/RowFilter'
import {UnitOfWorkTracker} from '../mixins/UnitOfWorkTracker'
import {RowFilter} from './rows/filter/RowFilter'
/**
* Table State
@ -23,6 +24,19 @@ class TableState {
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Object} rowFilter
* @param {Object} columnFilter
*/
constructor(rowFilter, columnFilter) {
this.rowFilter = rowFilter;
this.columnFilter = columnFilter
}
}
const TEMPLATE =`
<div class="pillar-table-container"
:class="$options.name"
@ -30,14 +44,19 @@ const TEMPLATE =`
<div class="pillar-table-menu">
<pillar-table-row-filter
:rowObjects="sortedRowObjects"
:config="rowFilterConfig"
:componentState="(componentState || {}).rowFilter"
@visibleRowObjectsChanged="onVisibleRowObjectsChanged"
@componentStateChanged="onRowFilterStateChanged"
/>
<pillar-table-actions
@item-clicked="onItemClicked"
/>
<pillar-table-column-filter
:columns="columns"
:componentState="(componentState || {}).columnFilter"
@visibleColumnsChanged="onVisibleColumnsChanged"
@componentStateChanged="onColumnFilterStateChanged"
/>
</div>
<div class="pillar-table">
@ -71,6 +90,7 @@ const TEMPLATE =`
*
* @emits isInitialized When all rows has been fetched, and are initialized.
* @emits selectItemsChanged(selectedItems) When selected rows has changed.
* @emits componentStateChanged(newState) When table state changed. Filtered rows, visible columns...
*/
let PillarTable = Vue.component('pillar-table-base', {
template: TEMPLATE,
@ -88,15 +108,23 @@ let PillarTable = Vue.component('pillar-table-base', {
type: Boolean,
default: true
},
componentState: {
// Instance of ComponentState
type: Object,
default: undefined
}
},
data: function() {
return {
columns: [],
visibleColumns: [],
visibleRowObjects: [],
rowsSource: undefined, // Override with your implementations of ColumnFactoryBase
columnFactory: undefined, // Override with your implementations of RowSource
rowsSource: undefined, // Override with your implementations of RowSource
columnFactory: undefined, // Override with your implementations of ColumnFactoryBase
rowFilterConfig: undefined,
isInitialized: false,
rowFilterState: (this.componentState || {}).rowFilter,
columnFilterState: (this.componentState || {}).columnFilter,
compareRowsCB: (row1, row2) => 0
}
},
@ -120,6 +148,15 @@ let PillarTable = Vue.component('pillar-table-base', {
selectedItems() {
return this.rowAndChildObjects.filter(it => it.isSelected)
.map(it => it.underlyingObject);
},
currentComponentState() {
if (this.isInitialized) {
return new ComponentState(
this.rowFilterState,
this.columnFilterState
);
}
return undefined;
}
},
watch: {
@ -130,8 +167,8 @@ let PillarTable = Vue.component('pillar-table-base', {
},
selectedItems(newValue, oldValue) {
// Deep compare to avoid spamming un needed events
let hasChanged = JSON.stringify(newValue ) === JSON.stringify(oldValue);
if (!hasChanged) {
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
if (hasChanged) {
this.$emit('selectItemsChanged', newValue);
}
},
@ -139,6 +176,15 @@ let PillarTable = Vue.component('pillar-table-base', {
if (newValue) {
this.$emit('isInitialized');
}
},
currentComponentState(newValue, oldValue) {
if (this.isInitialized) {
// Deep compare to avoid spamming un needed events
let hasChanged = JSON.stringify(newValue ) !== JSON.stringify(oldValue);
if (hasChanged) {
this.$emit('componentStateChanged', newValue);
}
}
}
},
created() {
@ -169,9 +215,15 @@ let PillarTable = Vue.component('pillar-table-base', {
onVisibleColumnsChanged(visibleColumns) {
this.visibleColumns = visibleColumns;
},
onColumnFilterStateChanged(newComponentState) {
this.columnFilterState = newComponentState;
},
onVisibleRowObjectsChanged(visibleRowObjects) {
this.visibleRowObjects = visibleRowObjects;
},
onRowFilterStateChanged(newComponentState) {
this.rowFilterState = newComponentState;
},
onSort(column, direction) {
function compareRows(r1, r2) {
return column.compareRows(r1, r2) * direction;
@ -228,6 +280,9 @@ let PillarTable = Vue.component('pillar-table-base', {
return true;
})
}
},
components: {
'pillar-table-row-filter': RowFilter
}
});

View File

@ -4,13 +4,12 @@ import { CellDefault } from '../cells/renderer/CellDefault'
* Column logic
*/
let nextColumnId = 0;
export class ColumnBase {
constructor(displayName, columnType) {
this._id = nextColumnId++;
this.displayName = displayName;
this.columnType = columnType;
this.isMandatory = false;
this.includedByDefault = true;
this.isSortable = true;
this.isHighLighted = 0;
}

View File

@ -0,0 +1,130 @@
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 action"
v-for="c in columnStates"
:key="c.displayName"
@click="toggleColumn(c)"
>
<input type="checkbox"
v-model="c.isVisible"
/>
{{ c.displayName }}
</li>
</ul>
</pillar-dropdown>
</div>
`;
class ColumnState{
constructor() {
this.displayName;
this.isVisible;
this.isMandatory;
}
static createDefault(column) {
let state = new ColumnState;
state.displayName = column.displayName;
state.isVisible = !!column.includedByDefault;
state.isMandatory = !!column.isMandatory;
return state;
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Array} selected The columns that should be visible
*/
constructor(selected) {
this.selected = selected;
}
}
/**
* Component to select what columns to render in the table.
*
* @emits visibleColumnsChanged(columns) When visible columns has changed
* @emits componentStateChanged(newState) When column filter state changed.
*/
let Filter = Vue.component('pillar-table-column-filter', {
template: TEMPLATE,
props: {
columns: Array, // Instances of ColumnBase
componentState: Object, // Instance of ComponentState
},
data() {
return {
columnStates: this.createInitialColumnStates(), // Instances of ColumnState
}
},
computed: {
visibleColumns() {
return this.columns.filter((candidate) => {
return candidate.isMandatory || this.isColumnStateVisible(candidate);
});
},
columnFilterState() {
return new ComponentState(this.visibleColumns.map(it => it.displayName));
}
},
watch: {
columns() {
this.columnStates = this.createInitialColumnStates();
},
visibleColumns(visibleColumns) {
this.$emit('visibleColumnsChanged', visibleColumns);
},
columnFilterState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleColumnsChanged', this.visibleColumns);
},
methods: {
createInitialColumnStates() {
let columnStateCB = ColumnState.createDefault;
if (this.componentState && this.componentState.selected) {
let selected = this.componentState.selected;
columnStateCB = (column) => {
let state = ColumnState.createDefault(column);
state.isVisible = selected.includes(column.displayName);
return state;
}
}
return this.columns.reduce((states, c) => {
if(!c.isMandatory) {
states.push(columnStateCB(c));
}
return states;
}, []);
},
isColumnStateVisible(column) {
for (let state of this.columnStates) {
if (state.displayName === column.displayName) {
return state.isVisible;
}
}
return false;
},
toggleColumn(column) {
column.isVisible = !column.isVisible;
}
},
});
export { Filter }

View File

@ -1,91 +0,0 @@
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>
`;
class ColumnState{
constructor(id, displayName, isVisible) {
this.id = id;
this.displayName = displayName;
this.isVisible = isVisible;
}
}
/**
* Component to select what columns to render in the table.
*
* @emits visibleColumnsChanged(columns) When visible columns has changed
*/
let Filter = Vue.component('pillar-table-column-filter', {
template: TEMPLATE,
props: {
columns: Array, // Instances of ColumnBase
},
data() {
return {
columnStates: [], // Instances of ColumnState
}
},
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(
new ColumnState(c._id, c.displayName, 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

@ -1,48 +0,0 @@
const TEMPLATE =`
<div class="pillar-table-row-filter">
<input
placeholder="Filter by name"
v-model="nameQuery"
/>
</div>
`;
/**
* @emits visibleRowObjectsChanged(rowObjects) When the what objects to be visible has changed.
*/
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,153 @@
const TEMPLATE =`
<pillar-dropdown>
<i class="pi-filter"
slot="button"
:class="enumButtonClasses"
title="Filter rows"
/>
<ul class="settings-menu"
slot="menu"
>
<li>
{{ label }}:
</li>
<li class="action"
@click="toggleAll"
>
<input type="checkbox"
:checked="includesRows"
/> Toggle All
</li>
<li class="input-group-separator"/>
<li v-for="val in enumVisibilities"
class="action"
:key="val.value"
@click="toggleEnum(val.value)"
>
<input type="checkbox"
v-model="enumVisibilities[val.value].isVisible"
/> {{ val.displayName }}
</li>
</ul>
</pillar-dropdown>
`;
class EnumState{
constructor(displayName, value, isVisible) {
this.displayName = displayName;
this.value = value;
this.isVisible = isVisible;
}
}
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Array} selected The enums that should be visible
*/
constructor(selected) {
this.selected = selected;
}
}
/**
* Filter row objects based on enumeratable values.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let EnumFilter = {
template: TEMPLATE,
props: {
label: String,
availableValues: Array, // Array with valid values [{value: abc, displayName: xyz},...]
componentState: Object, // Instance of ComponentState.
valueExtractorCB: {
// Callback to extract enumvalue from a rowObject
type: Function,
default: (rowObject) => {throw Error("Not Implemented")}
},
rowObjects: Array,
},
data() {
return {
enumVisibilities: this.initEnumVisibilities(),
}
},
computed: {
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.shouldBeVisible(row);
});
},
includesRows() {
for (const key in this.enumVisibilities) {
if(!this.enumVisibilities[key].isVisible) return false;
}
return true;
},
enumButtonClasses() {
return {
'filter-active': !this.includesRows
}
},
currentComponentState() {
let visibleEnums = [];
for (const key in this.enumVisibilities) {
const enumState = this.enumVisibilities[key];
if (enumState.isVisible) {
visibleEnums.push(enumState.value);
}
}
return new ComponentState(visibleEnums);
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
},
currentComponentState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
shouldBeVisible(rowObject) {
let value = this.valueExtractorCB(rowObject);
if (typeof this.enumVisibilities[value] === 'undefined') {
console.warn(`RowObject ${rowObject.getId()} has an invalid ${this.label} enum: ${value}`)
return true;
}
return this.enumVisibilities[value].isVisible;
},
initEnumVisibilities() {
let initialValueCB = () => true;
if (this.componentState && this.componentState.selected) {
initialValueCB = (val) => {
return this.componentState.selected.includes(val.value);
};
}
return this.availableValues.reduce((agg, val)=> {
agg[val.value] = new EnumState(val.displayName, val.value, initialValueCB(val));
return agg;
}, {});
},
toggleEnum(value) {
this.enumVisibilities[value].isVisible = !this.enumVisibilities[value].isVisible;
},
toggleAll() {
let newValue = !this.includesRows;
for (const key in this.enumVisibilities) {
this.enumVisibilities[key].isVisible = newValue;
}
}
},
};
export { EnumFilter }

View File

@ -0,0 +1,35 @@
import {TextFilter} from './TextFilter'
const TEMPLATE =`
<text-filter
label="Name"
:componentState="componentState"
:rowObjects="rowObjects"
:valueExtractorCB="extractName"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
>
`;
/**
* Filter row objects based on there name.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let NameFilter = {
template: TEMPLATE,
props: {
componentState: Object, // Instance of object that componentStateChanged emitted. To restore previous state.
rowObjects: Array,
},
methods: {
extractName(rowObject) {
return rowObject.getName();
},
},
components: {
'text-filter': TextFilter,
},
};
export { NameFilter }

View File

@ -0,0 +1,25 @@
import {NameFilter} from './NameFilter'
const TEMPLATE =`
<div class="pillar-table-row-filter">
<name-filter
:rowObjects="rowObjects"
:componentState="componentState"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
/>
</div>
`;
let RowFilter = {
template: TEMPLATE,
props: {
rowObjects: Array,
componentState: Object
},
components: {
'name-filter': NameFilter
}
};
export { RowFilter }

View File

@ -0,0 +1,48 @@
import {EnumFilter} from './EnumFilter'
const TEMPLATE =`
<enum-filter
label="Status"
:availableValues="availableEnumValues"
:componentState="componentState"
:rowObjects="rowObjects"
:valueExtractorCB="extractStatus"
@visibleRowObjectsChanged="$emit('visibleRowObjectsChanged', ...arguments)"
@componentStateChanged="$emit('componentStateChanged', ...arguments)"
>
`;
/**
* Filter row objects based on there status.
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed.
*/
let StatusFilter = {
template: TEMPLATE,
props: {
availableStatuses: Array, // Array with valid values ['abc', 'xyz']
componentState: Object, // Instance of object that componentStateChanged emitted. To restore previous state.
rowObjects: Array,
},
computed: {
availableEnumValues() {
let statusCopy = this.availableStatuses.concat().sort()
return statusCopy.map(status =>{
return {
value: status,
displayName: status.replace(/-|_/g, ' ') // Replace -(dash) and _(underscore) with space
}
});
}
},
methods: {
extractStatus(rowObject) {
return rowObject.getStatus();
},
},
components: {
'enum-filter': EnumFilter,
},
};
export { StatusFilter }

View File

@ -0,0 +1,86 @@
const TEMPLATE =`
<input
:class="textInputClasses"
:placeholder="placeholderText"
v-model="textQuery"
/>
`;
class ComponentState {
/**
* Serializable state of this component.
*
* @param {String} textQuery
*/
constructor(textQuery) {
this.textQuery = textQuery;
}
}
/**
* Component to filter rowobjects by a text value
*
* @emits visibleRowObjectsChanged(rowObjects) When the objects to be visible has changed.
* @emits componentStateChanged(newState) When row filter state changed. Filter query...
*/
let TextFilter = {
template: TEMPLATE,
props: {
label: String,
rowObjects: Array,
componentState: {
// Instance of ComponentState
type: Object,
default: undefined
},
valueExtractorCB: {
// Callback to extract text to filter from a rowObject
type: Function,
default: (rowObject) => {throw Error("Not Implemented")}
}
},
data() {
return {
textQuery: (this.componentState || {}).textQuery || '',
}
},
computed: {
textQueryLoweCase() {
return this.textQuery.toLowerCase();
},
visibleRowObjects() {
return this.rowObjects.filter((row) => {
return this.filterByText(row);
});
},
textInputClasses() {
return {
'filter-active': this.textQuery.length > 0
};
},
currentComponentState() {
return new ComponentState(this.textQuery);
},
placeholderText() {
return `Filter by ${this.label}`;
}
},
watch: {
visibleRowObjects(visibleRowObjects) {
this.$emit('visibleRowObjectsChanged', visibleRowObjects);
},
currentComponentState(newValue) {
this.$emit('componentStateChanged', newValue);
}
},
created() {
this.$emit('visibleRowObjectsChanged', this.visibleRowObjects);
},
methods: {
filterByText(rowObject) {
return (this.valueExtractorCB(rowObject) || '').toLowerCase().indexOf(this.textQueryLoweCase) !== -1;
},
},
};
export { TextFilter }

View File

@ -112,6 +112,9 @@ $thumbnail-max-height: calc(110px * (9/16))
display: flex
flex-direction: row
.action
cursor: pointer
.settings-menu
display: flex
flex-direction: column
@ -123,10 +126,17 @@ $thumbnail-max-height: calc(110px * (9/16))
text-transform: capitalize
z-index: $z-index-base + 1
box-shadow: 0 2px 5px rgba(black, .4)
user-select: none
.pillar-table-row-filter
display: flex
flex-direction: row
input.filter-active
background-color: rgba($color-info, .50)
.pi-filter.filter-active
color: $color-info
.pillar-table-actions
margin-left: auto