UNPKG

cipherguard-mad

Version:

Cipherguard javascript application library for the open source password manager for teams

811 lines (733 loc) 24.2 kB
/** * Cipherguard ~ Open source password manager for teams * Copyright (c) KhulnaSoft Ltd (https://www.khulnasoft.com) * * Licensed under GNU Affero General Public License version 3 of the or any later version. * For full copyright and license information, please see the LICENSE.txt * Redistributions of files must retain the above copyright notice. * * @copyright Copyright (c) KhulnaSoft Ltd (https://www.khulnasoft.com) * @license https://opensource.org/licenses/AGPL-3.0 AGPL License * @link https://www.khulnasoft.com KhulnaSoft(tm) */ import $ from 'jquery'; import Component from './component'; import DomData from 'can-dom-data'; import getObject from 'can-util/js/get/get'; import GridColumn from '../model/map/grid_column'; import GridState from '../model/state/gridState'; import GridView from '../view/component/grid'; import columnHeaderTemplate from '../view/template/component/grid/gridColumnHeader.stache!'; import template from '../view/template/component/grid/grid.stache!'; import itemTemplate from '../view/template/component/grid/gridItem.stache!'; import cellTemplate from '../view/template/component/grid/gridCell.stache!'; import '../view/helper/stache/grid/grid_cell.js'; /** * @parent Mad.components_api * @inherits mad.Component * @group mad.component.Grid.view_events 0 View Events * * The Grid Component as for aim to display a data grid. * @todo TBD */ const Grid = Component.extend('mad.component.Grid', { defaults: { // Override the label option. label: 'Grid Component', // Override the cssClasses option. cssClasses: ['tableview'], // Override the tag option. tag: 'div', // Override the template option. template: template, // The component header column template. columnHeaderTemplate: columnHeaderTemplate, // The component item template. itemTemplate: itemTemplate, // The component cell template. cellTemplate: cellTemplate, // Override the viewClass option. viewClass: GridView, stateClass: GridState, // Prefix the id of each row. prefixItemId: '', // The Model Class that defines the items displayed by the grud. itemClass: null, // The Map Class used to defined the column model. columnModelClass: GridColumn, // the grid column model columnModel: [], // The map used to transform the raw data into expected view format. map: null, // The callbacks the component offers to the dev to bind their code. callbacks: { // An item is left click selected. item_selected: null, // An item is hovered. item_hovered: null }, // The items the grid works with. items: null, // Is the grid sorted. isSorted: false, // Paginate the items paginate: false, itemsByPage: 50, displayedPages: { first: 0, last: 0 }, // Fade in animation when rendering the first set of items fadeInTimeout: 250 } }, /** @prototype */ { /** * @inheritdoc */ init: function(el, options) { options.items = new options.itemClass.List(); options.sourceItems = new options.itemClass.List(); this._super(el, options); this.mappedItems = {}; this.on(); }, /** * @inheritdoc */ _initState: function(options) { this._super(options); this.state.on('filtered', (ev, filtered) => this.onFilteredChange(filtered)); this.state.on('filtering', (ev, filtering) => this.onFilteringChange(filtering)); }, /** * Observe when the component is filtered / unfiltered * @param {boolean} started True if filtered, false otherwise */ onFilteredChange: function(filtered) { if (filtered) { $(this.element).addClass('filtered'); } else { $(this.element).removeClass('filtered'); } }, /** * Observe when the component is filtering / stop the filtering * @param {boolean} started True if filtering, false otherwise */ onFilteringChange: function(filtering) { if (filtering) { $(this.element).addClass('filtering'); } else { $(this.element).removeClass('filtering'); } }, /** * @inheritdoc */ start: function() { if (!this.options.map) { throw new Error('A map is required.'); } this._super(); }, /** * @inheritdoc */ afterStart: function() { this._afterColumnRender(); this._initEventListener(); this._super(); }, /** * Associate columnModel definitions to corresponding DOM elements th column header. * It will be used by the view to retrieve associated column model definition. * @private */ _afterColumnRender: function() { const columnModel = this.getColumnModel(); for (const i in columnModel) { const $el = $(`th.js_grid_column_${columnModel[i].name}`, this.element); DomData.set($el[0], this.getColumnModelClass().constructor.shortName, columnModel[i]); } }, /** * Init the component event listener * @private */ _initEventListener: function() { $('.tableview-content', this.element).scroll(ev => this._handleTableContentScroll(ev)); }, /** * Observe when the component is loaded * @param {boolean} loaded True if loaded, false otherwise */ onLoadedChange: function(loaded) { this._super(loaded); // Hide the table while loading. const table = $('.tableview-content table', this.element); if (!loaded) { table.hide(); } else { table.show(); } }, /** * @inheritdoc */ beforeRender: function() { this.setViewData('columnModel', this.options.columnModel); this.setViewData('columnHeaderTemplate', this.options.columnHeaderTemplate); this.setViewData('items', []); this._super(); }, /** * Get a target column model of the grid. * If no target * * @param {string} name (optional) The name of the column model to retrieve, if not provided return all. * @return {Column} */ getColumnModel: function(name) { let returnValue = null; if (name != undefined) { for (const i in this.options.columnModel) { if (this.options.columnModel[i].name == name) { return this.options.columnModel[i]; } } } else { returnValue = this.options.columnModel; } return returnValue; }, /** * Get the itemClass which represents the items managed by the component. * * @return {DefineMap.prototype} */ getItemClass: function() { return this.options.itemClass; }, /** * Get the column model class. * * @return {DefineMap.prototype} */ getColumnModelClass: function() { return this.options.columnModelClass; }, /** * Get the associated map, which will be used to map the model data to the * expected view format. * * @return {mad.object.Map} */ getMap: function() { return this.options.map; }, /** * Select an item. * * @param {DefineMap} */ selectItem: function(item) { this.view.selectItem(item); }, /** * Right select an item. * @param {DefineMap} */ rightSelectItem: function(item) { this.view.rightSelectItem(item); }, /** * Unselect an item. * @param {DefineMap} * @todo unselectItem calls the unselectAll view function, check where this function is used and correct this logic problem. */ // eslint-disable-next-line no-unused-vars unselectItem: function(item) { this.view.unselectAll(); }, /** * Hover an item. * @param {DefineMap} */ hoverItem: function(item) { this.view.hoverItem(item); }, /** * Unselect all the previously selected items. */ unselectAll: function() { this.view.unselectAll(); }, /** * Remove an item from the grid. * @param {DefineMap} item The item to remove */ removeItem: function(item) { const items = this.options.items; let index = -1; items.forEach((_item, i) => { if (_item.id == item.id) { index = i; } }); if (index != -1) { items.splice(index, 1); delete this.mappedItems[item.id]; if (this.isItemDisplayed(item)) { this.view.removeItem(item); } this.state.empty = items.length == 0; } }, /** * Assert an item is an instance of the ItemClass defined as option * @param {DefineMap} item * @private */ _assertItemClass: function(item) { const itemClass = this.getItemClass(); if (itemClass && !(item instanceof itemClass)) { throw new Error('Expect the item to be an instance of the ItemClass defined in optoin'); } }, /** * Insert an item into the grid. * * @param {DefineMap} item The item to insert * @param {DefineMap} refItem (optional) The reference item to use to position the new item. * By default the item will be inserted as last element of the grid. * @param {string} position (optional) If the reference item has been defined. The position of the item to insert, * regarding the reference item. Available values : before, after, first, last. By default last. * @param {boolean} render Render the row if true. By default true. * @param {object} options * - hidden: Render the item in the DOM, but hide it. */ insertItem: function(item, refItem, position, render, options) { render = render != undefined ? render : true; this._assertItemClass(item); this.state.loaded = false; this.options.items.push(item); this._mapItem(item); if (render) { this._renderItem(item, refItem, position, options); } this.state.loaded = true; this.state.empty = false; }, /** * Check if an item is displayed * @param {DefineMap} item * @return {boolean} */ isItemDisplayed: function(item) { return this.view.getItemElement(item).length; }, /** * Refresh an item. * @param {DefineMap} item The item to refresh */ refreshItem: function(item) { this._assertItemClass(item); const options = {}; const mappedItem = this.getMap().mapObject(item); this.mappedItems[item.id] = mappedItem; if (this.isItemDisplayed(item)) { const hidden = this.view.getItemElement(item).hasClass('hidden'); options.hidden = hidden; this.view.refreshItem(item, options); this._afterRenderItem(item, mappedItem); } }, /** * Load items in the grid. If the grid contain items, reset it. * @param {array<DefineMap>} data The list of items to insert in the grid * @param {object} options * - filter: Filter the items * filter.keywords {string}: The keywords to filter on * filter.fields {array}: The fields to filter on * @return {Promise} */ load: function(data, options) { options = options || {}; const sourceItems = this.options.sourceItems; const items = this.options.items; this.reset(); data.forEach(item => items.push(item)); data.forEach(item => sourceItems.push(item)); this._mapItems(items); this.state.empty = items.length == 0; if (options.filter) { return this.filterByKeywords(options.filter.keywords, options.filter.fields); } else { this.state.loaded = false; return this._renderItems(options).then(() => { this.state.loaded = true; }); } }, /** * Render items. * @param {object} options The options * @returns {Promise} * @private */ _renderItems: function(options) { options = options || {}; const items = this.options.items; const paginate = this.options.paginate; const itemsByPage = this.options.itemsByPage; const table = $('.tableview-content table', this.element); let itemsToRender, page; table.hide(); if (!paginate) { itemsToRender = items; } else { page = 1; const visibleItemId = options.visibleItemId; if (visibleItemId) { const visibleItemIndex = items.indexOf({id: visibleItemId}); if (visibleItemIndex > 0) { page = Math.ceil((visibleItemIndex + 1) / itemsByPage); } } this.options.displayedPages.first = page; this.options.displayedPages.last = page; itemsToRender = items.slice((page - 1) * itemsByPage, (page) * itemsByPage); } return new Promise(resolve => { // The timeout makes the UX smoother. setTimeout(() => { itemsToRender.forEach(item => { this._renderItem(item); }); table.fadeIn(this.options.fadeInTimeout, () => { // Scroll to 1 px, it will allow the infinite top pagination if any. this.bufferPreviousPage(); this.bufferNextPage(); // Allow the user to scroll to top by initializing the component with a scroll of 1px. const pageCount = Math.ceil(items.length / itemsByPage); // If last page is displayed, to prevent the case where there is not enough element in the page to scroll of 1px. if (page != 1 && pageCount == page) { this._handleTableContentScrollTop(); this.scrollToItem(itemsToRender[0]); } else { $('.tableview-content').scrollTop(1); } resolve(); }); }); }); }, /** * Scroll the grid to the given item * @param {DefineMap} item The item to scrollTo */ scrollToItem: function(item) { const itemElement = this.view.getItemElement(item); $('.tableview-content', this.element).scrollTop($(itemElement).position().top); }, /** * Buffer the previous page in the DOM. */ bufferPreviousPage: function() { const items = this.options.items; const paginate = this.options.paginate; const pageToBuffer = this.options.displayedPages.first - 1; const itemsByPage = this.options.itemsByPage; // If pagination is disabled or there is nothing to buffer if (!paginate || pageToBuffer < 0) { return; } const itemsToBuffer = items.slice((pageToBuffer - 1) * itemsByPage, pageToBuffer * itemsByPage); let previousItem = DomData.get($('.tableview-content tr:first', this.element)[0], this.options.itemClass.shortName); itemsToBuffer.reverse().forEach(item => { this._renderItem(item, previousItem, 'before', {hidden: true, css: ['buffer-before']}); previousItem = item; }); }, /** * Buffer the next page in the DOM. */ bufferNextPage: function() { const items = this.options.items; const paginate = this.options.paginate; const itemsByPage = this.options.itemsByPage; const pageToBuffer = this.options.displayedPages.last + 1; const totalPage = Math.ceil(items.length / itemsByPage); // If pagination is disabled or there is nothing to buffer. if (!paginate || pageToBuffer > totalPage) { return; } const itemsToBuffer = items.slice((pageToBuffer - 1) * itemsByPage, pageToBuffer * itemsByPage); itemsToBuffer.forEach(item => { this._renderItem(item, null, null, {hidden: true, css: ['buffer-after']}); }); }, /** * Render an item in the DOM * @param {DefineMap} item The item to insert * @param {DefineMap} refItem (optional) The reference item to use to position the new item. * By default the item will be inserted as last element of the grid. * @param {string} position (optional) If the reference item has been defined. The position of the item to insert, * regarding the reference item. Available values : before, after, first, last. By default last. * @param {object} options * - hidden: Render the item in the DOM, but hide it. * @private */ _renderItem: function(item, refItem, position, options) { options = options || {}; const mappedItem = this.mappedItems[item.id]; this.view.insertItem(item, refItem, position, options); this._afterRenderItem(item, mappedItem); }, /** * After an item has been rendered hook. * @param {DefineMap} item The item to insert * @param {object} mappedItem Mapped item values * @private */ _afterRenderItem: function(item, mappedItem) { const columnModels = this.getColumnModel(); for (const j in columnModels) { const columnModel = columnModels[j]; // Execute post cell rendered function if any. if (columnModel.afterRender) { const itemId = this.options.prefixItemId + mappedItem.id; const $cell = $(`#${itemId} .js_grid_column_${columnModel.name} div`); const cellValue = mappedItem[columnModel.name]; columnModel.afterRender($cell, cellValue, mappedItem, item, columnModel); } } }, /** * Handle table content scroll * @private */ _handleTableContentScroll: function() { if (!this.options.paginate) { return; } const tableElement = $('.tableview-content', this.element); const tableContentElement = $('.tableview-content table', this.element); const scrollPercent = (tableElement.scrollTop() / (tableContentElement.height() - tableElement.height())) * 100; if (scrollPercent == 0) { this._handleTableContentScrollTop(); } else if (scrollPercent >= 100) { this._handleTableContentScrollBottom(); } }, /** * Handle infinite scroll top. */ _handleTableContentScrollTop: function() { // If min page reach if (this.options.displayedPages.first == 1) { return; } const $topElement = $('.tableview-content tr:not(".hidden"):first', this.element); this._displayBufferedPage('before'); $('.tableview-content', this.element).scrollTop($topElement.position().top); this.options.displayedPages.first--; // To ensure a nice experience, insert buffer in the DOM after 200ms. setTimeout(() => { this.bufferPreviousPage(); }, 200); }, /** * Handle infinite scroll bottom. */ _handleTableContentScrollBottom: function() { const items = this.options.items; const itemsByPage = this.options.itemsByPage; const totalPage = Math.ceil(items.length / itemsByPage); const pageToDisplay = this.options.displayedPages.last + 1; // If max page reach. if (pageToDisplay > totalPage) { return; } this._displayBufferedPage('after'); this.options.displayedPages.last++; // To ensure a nice experience, insert buffer in the DOM after 200ms. setTimeout(() => { this.bufferNextPage(); }, 200); }, /** * Reset the grid. * Remove all the displayed (and hidden) items. */ reset: function() { this.options.items.splice(0, this.options.items.length); this.options.sourceItems.splice(0, this.options.sourceItems.length); this.mappedItems = {}; this.state.filtered = false; this.options.isSorted = false; this.state.empty = false; this.view.reset(); }, /** * Map a list of items. * @param {DefineList} items The list of items to map * @private */ _mapItems: function(items) { items.forEach(item => { this._mapItem(item); }); }, /** * Map an item * @param {DefineMap} item The item to map * @returns {*|Object} * @private */ _mapItem: function(item) { const mappedItem = this.getMap().mapObject(item); this.mappedItems[item.id] = mappedItem; return mappedItem; }, /** * Display the buffered page of items. * @param {string} position Which before should be displayed. Options: before, after. * @private */ _displayBufferedPage: function(position) { $(`.tableview-content table tbody tr.hidden.buffer-${position}`, this.element) .removeClass(`hidden buffer-${position}`); }, /** * Does the item exist * @param {DefineMap} item The item to check if it existing * @return {boolean} */ itemExists: function(item) { return this.view.getItemElement(item).length > 0 ? true : false; }, /** * Reset the filtering */ resetFilter: function() { this.state.loaded = false; this.options.items = this.options.sourceItems; this.state.empty = this.options.items == 0; this.state.filtered = false; this.view.reset(); return this._renderItems().then(() => { this.state.loaded = true; }); }, /** * Filter items in the grid by keywords * @param {string} needle The string to search in the grid * @param {array} fields The fields to search in * @return {Promise} */ filterByKeywords: function(needle, fields) { this.state.filtering = true; this.state.loaded = false; this.view.reset(); this.options.items = this.options.sourceItems.filterContain(needle, fields); this.state.empty = this.options.items.length == 0; return this._renderItems().then(() => { this.state.filtering = false; this.state.filtered = true; this.state.loaded = true; }); }, /** * Sort the grid functions of a given column. * @param {GridColumn} columnModel The column the grid should be sort in functions of. * @param {boolean} sortAsc Should the sort be ascending. True by default. * @return {Promise} */ sort: function(columnModel, sortAsc) { const columnId = columnModel.sortOn ? columnModel.sortOn : columnModel.name; const items = this.options.items; this.state.loaded = false; this.view.reset(); items.sort((itemA, itemB) => { itemA = getObject(itemA, columnId); itemB = getObject(itemB, columnId); const isBoolean = (typeof itemA === "boolean"); const valueA = isBoolean ? itemA : (itemA || '').toUpperCase(); const valueB = isBoolean ? itemB : (itemB || '').toUpperCase(); if (valueA < valueB) { return sortAsc ? -1 : 1; } else if (valueA > valueB) { return sortAsc ? 1 : -1; } return 0; }); return this._renderItems() .then(() => { this.options.isSorted = true; this.view.markColumnAsSorted(columnModel, sortAsc); this.state.loaded = true; }); }, /** * Move an item to another position in the grid. * @param item The item to move * @param position The position to move the item to */ moveItem: function(item, position) { this.view.moveItem(item, position); }, /* ************************************************************** */ /* LISTEN TO THE MODEL EVENTS */ /* ************************************************************** */ /** * Observe when items are removed from the list of observed items and * remove it from the grid * @param {DefinedMap.prototype} model The model reference * @param {HTMLEvent} ev The event which occurred * @param {CanList} items The removed items */ '{itemClass} destroyed': function(model, event, destroyedItem) { this.removeItem(destroyedItem); }, /* ************************************************************** */ /* LISTEN TO THE VIEW EVENTS */ /* ************************************************************** */ /** * Observe when a sort is requested on a column. * @param {HTMLElement} el The element the event occurred on * @param {HTMLEvent} ev The event that occurred */ '{element} column_sort': function(el, ev) { const columnModel = ev.data.columnModel; const sortAsc = ev.data.sortAsc; this.sort(columnModel, sortAsc); }, /** * Observe when an item is selected. * @param {HTMLElement} el The element the event occurred on * @param {HTMLEvent} ev The event that occurred */ '{element} item_selected': function(el, ev) { const item = ev.data.item; const srcEv = ev.data.srcEv; // override this function, call _super if you want the default behavior processed if (this.options.callbacks.itemSelected) { this.options.callbacks.itemSelected(el, ev, item, srcEv); } }, /** * Observe when an item has been hovered. * @param {HTMLElement} el The element the event occurred on * @param {HTMLEvent} ev The event that occurred */ '{element} item_hovered': function(el, ev) { const item = ev.data.item; const srcEv = ev.data.srcEv; // override this function, call _super if you want the default behavior processed if (this.options.callbacks.itemHovered) { this.options.callbacks.itemHovered(el, ev, item, srcEv); } } }); export default Grid;