TimeTrex Community Edition v16.2.0

This commit is contained in:
2022-12-13 07:10:06 +01:00
commit 472f000c1b
6810 changed files with 2636142 additions and 0 deletions

View File

@ -0,0 +1,458 @@
import { Global } from '@/global/Global';
import { createApp } from 'vue';
import main_ui_router from '@/components/main_ui_router';
import TTContextMenu from '@/components/context_menu/TTContextMenu';
import PrimeVue from 'primevue/config';
import TTEventBus from '@/services/TTEventBus';
/*
* -- Structure of the ContextMenuManager --
* You can search the code for `#VueContextMenu#` which will help identify the key areas during development and refactor.
* Dynamic: For the Dynamic creation of the menu, you can search for #VueContextMenu#Dynamic-View and #VueContextMenu#Dynamic-EditView and look at mountContextMenu
*
* There are 3 elements to the ContextMenuManager
* 1) The TT applications view code, 2) the ContextMenuManager, and 3) the Vue context menu.
* The Vue component is controlled by events, the events work as follows:
* 1) The Vue component is setup to listen to events from the EventBus (mitt)
* 2) The control events are fired from the ContextMenuManager.
* 3) The view controller calls functions in the ContextMenuManager to trigger these events.
* - ContextMenuManagers are created in BaseViewController.buildContextMenu
* - ContextMenu Models are built in BaseViewController.buildContextMenuModels() and all other ViewControllers that override those functions.
* - The Vue component is created currently by the LegacyView component, and only when a viewId is passed to the LegacyView, currently done by the Vue Router.
* The Vue router in turn is triggered NOT by the hash changes, as this currently leads to some race conditions/out of sync with the TT app.
* Instead, the Vue router is running in createMemoryHistory mode, and routing manually triggered by VueRouter.push, which is currently done from BaseViewController.loadView
*
* -- Creating a new Context Menu --
* So step 1 in adding a new context menu, is make sure the Vue Router is triggered, to create a new menu.
* The new contextMenu responds to a viewId, so this needs to be unique. (Approach might have to be re-evaluated for places with subviews.
* Maybe in the router we pass a contextmenu id which can be the same as the viewId in most cases? Discuss with Mike.
* Either way, having the router control that Vue contextmenu generation will control duplicates/clashes.
* -- Points of note
* - Make sure any new listeners in the Vue component are also destroyed once no longer used (e.g. when component is unMounted).
*
* Initialized from BaseViewController rather than the Vue component, so that each manager created is controlled from this point.
* Every contextmenu Vue component will then listen to a given viewId which is triggered by BaseVC.loadview.
* If it was the other way around, there could be more chance for a viewId manager to be overriden in the array.
* Still, the organisation between when a Vue contextmenu is created, and a manager from the TT app side, could be improved.
* Also, worst case scenario, a manager is created in BaseView, and no vue component to listen to it.
* If the vue component was to control when a manager was created, then we would get problems when functions like this.context_menu.parseContextRibbonAction are called from setDefault menu functions within views. So BaseView MUST be in charge of creating the manager.
* - An area to improve would be to have our own EventBus, not re-use the one from PrimeVue.
*/
class ContextMenu {
constructor( options ) {
// Set validation to reject if minimum data not supplied.
this.id = options.id || null;
this.type = options.type || null;
this.vue_menu_instance = options.vue_menu_instance || null;
this.view_controller_instance = options.view_controller_instance || null;
this.menu_model = null;
}
}
class ContextMenuManager {
constructor() {
this._menus = {}; // Will contain ContextMenu's
this.event_bus = new TTEventBus({ view_id: 'context_menu' });
}
createAndMountMenu( menu_id, parent_mount_container, parent_context ) {
if( !menu_id || !parent_mount_container || !parent_context ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'createAndMountMenu', 1 );
return false;
}
if( this.getMenu( menu_id ) !== undefined ) {
Debug.Error( 'Error: Context Menu Manager ('+ menu_id +') already exists and mounted.', 'ContextMenuManager.js', 'ContextMenuManager', 'createAndMountMenu', 1 );
return false;
}
// TODO:
// - Tie in a onDestroy function where the vue context and reference in the array are deleted if the menu is removed from the dom.
// #VueContextMenu#Dynamic-View - Create dynamic container for the vue context menu
parent_mount_container.prepend('<div id="'+ menu_id +'" class="context-menu-mount-container"></div>');
// Create and mount unique context menu for this view.
let vue_context = this.mountContextMenu( menu_id );
let menu = new ContextMenu({
id: menu_id,
vue_menu_instance: vue_context,
view_controller_instance: parent_context ,
});
// Add context menu to the array of active context menu's so we can track them.
this._menus[ menu_id ] = menu;
return menu.id;
}
generateMenuId( parent_type, parent_id ) {
if( !parent_type || !parent_id ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'generateMenuId', 1 );
return false;
}
/* -- Examples --
* View: BranchView
* EditView: BranchView -> Edit
* EditView with tabs: Employee -> Edit
* SubView: Example?
* SubViewLists: Employee -> Edit -> Qualifications.
* */
return `CM-${parent_id}-${parent_type}`; // Maybe need a unique menu on the end? or not?
}
getMenu( id ) {
return this._menus[ id ];
}
mountContextMenu( menu_id ) {
if( menu_id === undefined ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'mountContextMenu', 1 );
return false;
}
if( this.getMenu( menu_id ) !== undefined ) {
Debug.Error( 'Error: Context Menu Manager ('+ menu_id +') already exists and mounted.', 'ContextMenuManager.js', 'ContextMenuManager', 'mountContextMenu', 1 );
return false;
}
// Used by #VueContextMenu#Dynamic-View and #VueContextMenu#Dynamic-EditView
let mount_component = TTContextMenu;
let mount_reference = '#' + menu_id;
// TODO: Check if component has not already been mounted or existing in the menu list.
let vue_menu_instance = createApp( mount_component, { menu_id: menu_id } ); // Can pass an object in here too for proper JS only code, and allow data in without eventBus.
vue_menu_instance.use( PrimeVue, { ripple: true, inputStyle: 'filled' }); // From: AppConfig.vue this.$primevue.config.inputStyle value is filled/outlined as we dont use AppConfig in TT.
vue_menu_instance.use( main_ui_router ); // #VueContextMenu# FIXES: Failed to resolve component: router-link when TTOverlayMenuButton is opened. Because each context menu is a separate Vue instance, and they did not globally 'use' the Router, only in main ui.
vue_menu_instance.mount( mount_reference ); // e.g. '#tt-edit-view-test'
return vue_menu_instance;
}
unmountContextMenu ( id ) {
if( this._menus[ id ] ) {
this._menus[ id ].vue_menu_instance.unmount();
delete this._menus[ id ];
Debug.Text( 'Context menu successfully unmounted ('+ id +').', 'ContextMenuManager.js', 'ContextMenuManager', 'unmountContextMenu', 11 );
return true;
} else {
Debug.Warn( 'Unable to unmount context menu. Menu not found ('+ id +'). Maybe already removed?', 'ContextMenuManager.js', 'ContextMenuManager', 'unmountContextMenu', 11 );
return false;
}
}
buildContextMenuModelFromBackbone( menu_id, final_context_menu_model, view_controller_context ) {
if( menu_id === null ) return false;
var parsed_bb_menu = this.convertBackBoneMenuModelToPrimeVue( final_context_menu_model, view_controller_context );
this.updateMenuModel( menu_id, parsed_bb_menu );
if ( this.getMenu( menu_id ) ) {
//Fixes context menu flashing. For example when a user has lower permission levels context icons will appear and then dissapear half a second later.
//NOTE: These branching paths may be removed in the future when setDefaultMenu() and setEditMenu() are consolidated and some changes may be needed to reduce duplicate calls to the set*Menu() functions.
if ( !this.getMenu( menu_id ).view_controller_instance.is_edit && !this.getMenu( menu_id ).view_controller_instance.is_add && !this.getMenu( menu_id ).view_controller_instance.is_viewing ) {
this.getMenu( menu_id ).view_controller_instance.setDefaultMenu();
} else {
//Fixes issue where refreshing an edit view would flash context menus.
this.getMenu( menu_id ).view_controller_instance.setEditMenu();
}
} else {
Debug.Error( 'Error: Cannot find Vue context menu.', 'ContextMenuManager.js', 'ContextMenuManager', 'getMenuModelByMenuId', 1 );
}
}
convertBackBoneMenuModelToPrimeVue( backbone_menu_format, view_controller_context ) {
/*
* This would have arrays for groups & icons
* icons is very similar to the PrimeVue MenuModel.
*
* Note: This function only builds the menu when its asked to do so, like on view controller init.
* However, if the menu icons are manipulated via permission controls or user selecting items in a data table, this function does not run.
* Thats mostly fine, as the menu structure itself does not really change based on user interaction,
* But some things might. E.g. (Paystubs ->Edit Employee) when the Save menu initially has all the options during this build run, but later ends up with only one Save option. That needs to be handled dynamically in Vue.
* */
var icons = backbone_menu_format.icons;
var action_groups = {};
var parsed_icons = [];
// TODO: Is the below still needed? And can we do this in a neater/clearer way.
if ( icons && Object.keys( icons ).length && Object.keys( icons ).length < 1) {
// Invalid data.
console.error('ContextMenuManager: Invalid data format. No icons or not an array.');
return false;
}
// Sort icons by their sort_order attribute.
icons = Object.values(backbone_menu_format.icons).sort(this.sortCompareIcons);
// parse the icons item.icons further
for ( var key in icons ) {
// Dev warning: iteration order is not guaranteed during for...in loops. And only modify the currently iterated key. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#deleted_added_or_modified_properties
if(icons.hasOwnProperty( key )) {
var item = icons[key];
// Validation checks and default values.
// When modifying these, make sure to update sub_menu_item checks later on too.
if( !item.id ) {
Debug.Error( 'ERROR: Invalid data. Missing ID.', 'ContextMenuManager.js', 'ContextMenuManager', 'convertBackBoneMenuModelToPrimeVue', 1 );
}
if( item.icon ) delete item.icon; // Remove the .icon attribute, because it interferes with PrimeVue <button> element's icon attribute. Currently still needed for legacy context menu icon display.
if( item.vue_icon ) item.icon = item.vue_icon;
if( item.items ) delete item.items; // Remove the .items array, as this is from the old menu, and PrimeVue will incorrectly interpret it as a submenu.
if( !item.action_group ) item.action_group = item.id; // If no action_group, then just use the id, as we will use action_group later on as a unique id.
if( !item.menu_align ) item.menu_align = 'left'; // Set default alignment to left
if( !item.command ) item.command = default_command; // Modify the command related to the nav item
function default_command( item ) {
// Perhaps put this into a default parser just like MenuManager.
// console.log('Default command triggered for ', icon_id);
if ( item.id ) {
view_controller_context.onContextMenuClick( item, item.id );
} else {
view_controller_context.onContextMenuClick( null, item );
}
}
// Convert TT legacy selected state to Vue active state. (Used in places like MessageControlVC).
if( item.selected == true ) { // == rather than === as this is based on legacy code which sometimes comes as strings from API.
delete item.selected;
item.active = true;
item.icon = 'tticon tticon-show-in-dropdown tticon-radio_button_checked_black_24dp';
} else if ( item.selected == false ) { // == rather than === as this is based on legacy code which sometimes comes as strings from API.
delete item.selected;
item.active = false;
item.icon = 'tticon tticon-show-in-dropdown tticon-radio_button_unchecked_black_24dp';
}
// Modify the label for a nav item, parse the <br> tags - might be possible to remove this in a couple of months, once confirmed the old menu/data is not needed.
if ( item.label ) {
item.label = item.label.replace( '<br>', ' ' ); // Removing the <br> here rather than in the source data, just incase we want to keep the old menu/data working for now.
}
// Parse old style contextmenu dropdowns to new primevue menu
if( item.original_items && item.original_items.length > 0 ) {
// This results in making a special version of the split-button menu, using the overlay menu, which has a header instead of the currently 'active' action button. See TTOverlayMenuButton.vue for more detailed explanation.
// Create on-the-fly action_group entry for the legacy drop down menus like Print in TimeSheetViewController
if( !item.action_group ) {
item.action_group = item.id;
}
item.items = []; // RibbonSubMenuNavItem adds the backbone menu models to the parent .items array, so we need to clear it before it confuses the PrimeVue menu which uses .items too.
item.action_group_header = true;
item.permission_result = false; // to hide it in legacy context menu and avoid errors in legacy parsers.
this.parseIconActionGroup( item, action_groups, parsed_icons ); // Parse the header to generate the group array.
for ( var sub_key in item.original_items ) {
if( item.original_items.hasOwnProperty( sub_key )) {
var sub_menu_item = item.original_items[sub_key];
// Modify the command related to the sub menu/nav item.
if( !sub_menu_item.command ) sub_menu_item.command = default_submenu_command;
// Set the alignment to match the parent item.
sub_menu_item.menu_align = item.menu_align;
// Set the action_group to match the parent item.
sub_menu_item.action_group = item.action_group;
this.parseIconActionGroup( sub_menu_item, action_groups, parsed_icons ); // Parse the sub items to add them to the group.
}
}
function default_submenu_command( icon_id ) {
// Perhaps put this into a default parser just like MenuManager.
view_controller_context.onReportMenuClick( icon_id);
}
} else {
this.parseIconActionGroup( item, action_groups, parsed_icons );
}
}
}
return parsed_icons;
}
// Parse action_group's
// If an action group exists, add icons into groups, even if there is just one, as TTContextButton will handle single item arrays as a single object anyway.
parseIconActionGroup( parse_item, action_groups, parsed_icons ) {
var action_group_id = parse_item.menu_align + '-' + parse_item.action_group;
if( action_groups[ action_group_id ] === undefined ) {
// First item encountered for this action_group. Create new and add current item.
action_groups[ action_group_id ] = {
action_group_id: action_group_id,
menu_align: parse_item.menu_align, // set the align based on first item, as in theory they would all have the same value.
items: [ parse_item ]
};
parsed_icons.push( action_groups[ action_group_id ] ); // Add action group reference to the main icon array.
} else {
// This action group already exists, add the item to the existing action group.
if( parse_item.action_group_header ) {
action_groups[ action_group_id ].items.unshift( parse_item ); // move to the front, to treat as group label
} else {
action_groups[ action_group_id ].items.push( parse_item );
}
}
}
// Sort the icons based on their sort_order attribute
// Based on Global.compareMenuItems from the original ribbon menu.
sortCompareIcons( a, b ) {
// If no sort_order, or sort_order is equal, then base on add_order
if ( a.sort_order === undefined || b.sort_order === undefined || a.sort_order == b.sort_order ) {
// Debug.Text( 'sort_order equal or undefined. Check sort_order/add_order for '+ a.id + ' ('+ a.sort_order +') '+' & '+ b.id +' ('+ b.sort_order +').', 'ContextMenuManager.js', 'ContextMenuManager', 'sortCompareIcons', 10 );
if ( a.add_order < b.add_order ) {
return -1; // Leave a and b order unchanged.
}
if ( a.add_order > b.add_order ) {
return 1; // Sort b before a.
}
}
// Base sorting on sort_order, regardless of add_order
if ( a.sort_order < b.sort_order ) {
return -1; // Leave a and b order unchanged.
}
if ( a.sort_order > b.sort_order ) {
return 1; // Sort b before a.
}
// No criteria matched at all - skip basically. Could also apply to action_group_header's (they will be pushed to the front of their group later in action group parsing)
Debug.Text( 'sort_order returned 0. Check sort_order/add_order for '+ a.id + ' ('+ a.sort_order +') '+' & '+ b.id +' ('+ b.sort_order +').', 'ContextMenuManager.js', 'ContextMenuManager', 'sortCompareIcons', 10 );
return 0; // leave a and b unchanged with respect to each other, but sorted with respect to all different elements.
}
filterVisibleItems( items ) {
let check_visible = ( element ) => element.visible !== false;
let filtered_items = items.filter( check_visible );
return {
items: filtered_items,
count: filtered_items.length
};
}
getMenuModelByMenuId( menu_id ) {
var menu_model = [];
if ( this.getMenu( menu_id ) !== undefined && this.getMenu( menu_id ).menu_model ) {
for ( var menu_item of this.getMenu( menu_id ).menu_model ) {
menu_model.push( ...menu_item.items );
}
} else {
//This should never happen, the one case it did happen (now fixed) was when right click menu was being created before edit view when opening a record.
Debug.Error( 'Fatal Error: Vue context menu has not been build yet and is undefined.', 'ContextMenuManager.js', 'ContextMenuManager', 'getMenuModelByMenuId', 1 );
// debugger; // Useful to debug here if this error happens.
}
return menu_model;
}
// Context Menu Event Emitters
updateMenuModel( menu_id, menu_model ) {
if( !menu_id || !menu_model ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'updateMenuModel', 1 );
return false;
}
//This can be done differently, just creating an easy to access reference to the menu model.
if ( this.getMenu( menu_id ) ) {
this.getMenu( menu_id ).menu_model = menu_model;
} else {
Debug.Error( 'Error: Cannot find Vue context menu..', 'ContextMenuManager.js', 'ContextMenuManager', 'getMenuModelByMenuId', 1 );
}
// EventBus.emit( view_id + '.updateContextMenu', {
this.event_bus.emit( 'context_menu', 'update_context_menu', {
menu_id: menu_id,
menu_model: menu_model
});
Debug.Text( 'Contextmenu\n MENU update SENT for: '+ menu_id, 'ContextMenuManager.js', 'ContextMenuManager', 'updateMenuModel', 11 );
}
updateItem( menu_id, item_id, item_attributes ) {
if( !menu_id || !item_id || !item_attributes ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'updateItem', 1 );
return false;
}
// EventBus.emit( this.view_controller_instance.viewId + '.updateItem', {
this.event_bus.emit( 'context_menu', 'update_item', {
menu_id: menu_id,
item_id: item_id,
item_attributes: item_attributes
});
Debug.Text( 'Contextmenu\n ITEM update SENT for: '+ menu_id + ':' + item_id, 'ContextMenuManager.js', 'ContextMenuManager', 'updateItem', 11 );
}
// Context Menu actions
multiSelectActivateItem( item_id ) {
if( !item_id ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'multiSelectActivateItem', 1 );
return false;
}
this.event_bus.emit( 'context_menu', 'activate_multi_select_item', {
item_id: item_id
});
Debug.Text( 'Contextmenu\n MULTI_ITEM update SENT for: '+ menu_id + ':' + item_id, 'ContextMenuManager.js', 'ContextMenuManager', 'multiSelectActivateItem', 11 );
}
activateSplitButtonItem( menu_id, item_id ) {
if ( !menu_id || !item_id ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'activateSplitButtonItem', 1 );
return false;
}
this.event_bus.emit( 'context_menu', 'activate_split_button_item', {
menu_id: menu_id,
item_id: item_id
} );
Debug.Text( 'Contextmenu\n SPLIT BUTTON update SENT for: ' + menu_id + ':' + item_id, 'ContextMenuManager.js', 'ContextMenuManager', 'activateSplitButtonItem', 11 );
}
freezeSplitButtonActiveItem( menu_id, item_id ) {
if ( !menu_id || !item_id ) {
Debug.Error( 'Error: Invalid parameters passed to function.', 'ContextMenuManager.js', 'ContextMenuManager', 'setSplitButtonTempIgnoreDisabled', 1 );
return false;
}
this.event_bus.emit( 'context_menu', 'freeze_split_button_active_item', {
menu_id: menu_id,
item_id: item_id
} );
Debug.Text( 'Contextmenu\n SPLIT BUTTON update SENT for: ' + menu_id + ':' + item_id, 'ContextMenuManager.js', 'ContextMenuManager', 'setSplitButtonTempIgnoreDisabled', 11 );
}
disableMenuItem( menu_id, item_id, reverse_action ) {
if( reverse_action ) {
this.updateItem( menu_id, item_id, { disabled: false });
} else {
this.updateItem( menu_id, item_id, { disabled: true });
}
}
hideMenuItem( menu_id, item_id, reverse_action ) {
if( reverse_action ) {
this.updateItem( menu_id, item_id, { visible: true });
} else {
this.updateItem( menu_id, item_id, { visible: false });
}
}
activateMenuItem( menu_id, item_id, reverse_action ) {
// Note: This is also done during menu build for selected items, in convertBackBoneMenuModelToPrimeVue
if( reverse_action ) {
this.updateItem( menu_id, item_id, {
active: false,
icon: 'tticon tticon-show-in-dropdown tticon-radio_button_unchecked_black_24dp',
});
} else {
this.updateItem( menu_id, item_id, {
active: true,
icon: 'tticon tticon-show-in-dropdown tticon-radio_button_checked_black_24dp',
});
}
}
}
// Export as below to share one instance of the manager to manage all Context Menu's.
export default new ContextMenuManager()

View File

@ -0,0 +1,102 @@
<template>
<div :class="containerClass" :style="style" :id="$attrs.id">
<PVSButton type="button" class="p-splitbutton-defaultbutton" v-bind="$attrs" :id="defaultButtonId" :icon="icon" :label="label" :disabled="$attrs.disable_item" @click="onDefaultButtonClick" />
<PVSButton type="button" class="p-splitbutton-menubutton" :id="$attrs.id+'-split-menu'" icon="pi pi-chevron-down" @click="onDropdownButtonClick" :disabled="$attrs.disable_menu"
aria-haspopup="true" :aria-controls="ariaId + '_overlay'"/>
<PVSMenu :id="ariaId + '_overlay'" ref="menu" :model="model" :popup="true" :autoZIndex="autoZIndex"
:baseZIndex="baseZIndex" :appendTo="appendTo" />
</div>
</template>
<script>
/* Copied from primevue version 3.1.1 */
/* This has been modified from the original PrimeVue SplitButton in order to add customizations to the disable state logic and allow the active button disabling to be independant from the dropdown arrow. */
import Button from 'primevue/button';
import Menu from 'primevue/menu';
import {UniqueComponentId} from 'primevue/utils';
export default {
inheritAttrs: false,
props: {
label: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
model: {
type: Array,
default: null
},
autoZIndex: {
type: Boolean,
default: true
},
baseZIndex: {
type: Number,
default: 0
},
appendTo: {
type: String,
default: 'body'
},
class: null,
style: null
},
methods: {
onDropdownButtonClick() {
this.$refs.menu.toggle({currentTarget: this.$el});
},
onDefaultButtonClick() {
this.$refs.menu.hide();
}
},
computed: {
ariaId() {
return UniqueComponentId();
},
containerClass() {
return ['p-splitbutton p-component', this.class];
},
defaultButtonId() {
return this.$attrs.id.replace('context-group', 'context-button');
},
},
components: {
'PVSButton': Button,
'PVSMenu': Menu
}
}
</script>
<style scoped>
.p-splitbutton {
display: inline-flex;
position: relative;
}
.p-splitbutton .p-splitbutton-defaultbutton {
flex: 1 1 auto;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0 none;
}
.p-splitbutton-menubutton {
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.p-splitbutton .p-menu {
min-width: 100%;
}
.p-fluid .p-splitbutton {
display: flex;
}
</style>

View File

@ -0,0 +1,179 @@
<template>
<component
v-if="valid_types_for_dynamic_component.includes( parseItem.type ) && filterVisibleItems.count !== 0"
:is="parseItem.type"
:id="'context-button-'+parseItem.id"
class="menu-item"
:items="parseItem.items">
</component>
<li
v-else-if="parseItem.separator === true"
class="menu-separator">
</li>
<div
v-else-if="parseItem.type === 'PrimeVueButton'"
class="menu-item">
<PrimeVueButton
:label="parseItem.label"
@click="parseItem.click_handler"
:style="parseItem.style"
:icon="parseItem.icon"
:id="'context-button-'+parseItem.id"
:disabled="parseItem.disabled">
</PrimeVueButton>
</div>
</template>
<script>
import PrimeVueButton from 'primevue/button';
import TTSplitButton from '@/components/context_menu/TTSplitButton';
import TTOverlayMenuButton from '@/components/context_menu/TTOverlayMenuButton';
import TTOverlayMultiSelectButton from '@/components/context_menu/TTOverlayMultiSelectButton';
export default {
name: "TTContextButton",
props: {
items: Array,
},
data() {
return {
valid_types_for_dynamic_component: ['TTOverlayMenuButton', 'TTSplitButton', 'TTOverlayMultiSelectButton'],
}
},
components: {
TTOverlayMenuButton,
PrimeVueButton,
TTSplitButton,
TTOverlayMultiSelectButton
},
computed: {
parseItem() {
// Make sure the returned menu type is listed in the 'vue components' object above.
let ret_val;
if( !this.items.length ) {
Debug.Error( 'Invalid icon format. No length value.', 'TTContextButton.vue', 'TTContextButton', 'parseItem', 1 );
return {
type: 'invalid'
};
}
// Multi-select Overlay button
if ( this.items.length > 1 && this.items[0].multi_select_group ) {
ret_val = {
type: 'TTOverlayMultiSelectButton',
items: this.items,
id: this.items[0].id,
};
}
// Overlay button
else if ( this.items.length > 1 && this.items[0].action_group_header ) {
ret_val = {
type: 'TTOverlayMenuButton',
items: this.items,
id: this.items[0].id,
};
}
// button - split button single visible. Could also be a single item too.
else if ( this.filterVisibleItems.count === 1 ) {
ret_val = {
type: 'PrimeVueButton',
label: this.filterVisibleItems.items[0].label,
click_handler: Global.debounce( () => {
this.filterVisibleItems.items[0].command( this.filterVisibleItems.items[0] );
}, Global.calcDebounceWaitTimeBasedOnNetwork(), true ),
style: { 'min-width': this.filterVisibleItems.items[0].min_width + 'px' },
icon: this.filterVisibleItems.items[0].icon,
disabled: ( this.filterVisibleItems.items[0].disabled === true ),
id: this.filterVisibleItems.items[0].id,
};
}
// split button for action groups - both useable and all items disabled.
// Show grouped items always as SplitButton, even if only one item is enabled. This is to avoid icon sizes jumping around when the dropdown is added/removed. We cant simply add/remove padding to account for lost space, as they are different items and text would also not be in the same place/centered.
else if ( this.filterVisibleItems.count > 1 ) {
ret_val = {
type: 'TTSplitButton',
items: this.items,
id: this.items[0].id,
};
}
// Deprecated. Remove after testing (29/04/2021) This should now no longer be needed, button handler above should work for all required cases.
// actual single button - likely disabled
// else if ( this.items.length === 1 && this.items[0].visible !== false ) {
// ret_val = {
// type: 'PrimeVueButton',
// label: this.items[0].label,
// click_handler: () => this.items[0].command( this.items[0].id ),
// style: { 'min-width': this.items[0].min_width + 'px' },
// disabled: (this.items[0].disabled === true ),
// };
// }
// fallback - no matches - could just not be visible.
else {
Debug.Text( 'No menu type criteria matched. Could be invisible menu item.', 'TTContextButton.vue', 'TTContextButton', 'parseItem', 11 );
// console.log( '', this.items );
ret_val = {
type: 'no-match'
};
}
return ret_val;
},
filterVisibleItems() {
var items = this.items;
var visible = ( element ) => element.visible !== false;
var not_group_header = ( element ) => element.action_group_header !== true;
var not_single_separator = ( element ) => element.separator !== true;
var filtered_items = items.filter( visible ).filter( not_group_header ).filter( not_single_separator );
return {
items: filtered_items,
count: filtered_items.length
};
}
},
methods: {
}
};
</script>
<!-- To fix the Attendance->TimeSheet wrapping on New Punch, you can apply white-space: nowrap; to the p-button-label -->
<style scoped>
.menu-item {
/*flex: 1 1 0; !* Uncomment to spread out the buttons at equal sized. This together with the styles in TTContextMenu.vue will make all the buttons the same size, regardless of internal padding *!*/
/*margin-top: 5px;*/ /* Removed the top padding to make it more balanced until we decide how these buttons will look, as bottom padding cannot be applied simply, due to it looking odd when an edit view is open. */
margin-left: 5px;
}
.menu-item:first-child {
margin-left: 0; /* outer padding controlled by overall .context-menu-bar */
}
::v-deep(.p-button) {
height: 100%; /* To ensure the max available height is used, and then all menu buttons will be same height (without this, splitbutton is taller than single button) */
background: #fff; /* Previously #f8f9fa but the contrast against the background was not great. */
color: #32689b; /* This will also set the icon colour to this. */
border-color: #e1e1e1; /* add outline to the vue context menu buttons. improves splitbutton visual. */
padding: 0.4rem 0.6rem; /* To match figma design */
}
::v-deep(.p-button .p-button-label) {
color: #3b3b3b;
text-align: left; /* Edit button label looks odd if centered with the extra width for mass edit. left align looks better. */
}
::v-deep(.p-button:enabled:hover .p-button-label) {
color: #fff;
}
::v-deep(.p-button):disabled {
opacity: .4; /* Previously 0.6 same as .p-component:disabled, but the contrast against enabled and contextmenu background was not good enough. */
color: #cacaca;
border-color: #cacaca;
}
/* Works together with the dynamic class for item.action_group_id set in TTContextMenu.vue Perhaps refactor to be JS based with a v-if */
/*.sub-view .menu-item.left-cancelIcon,*/
/*.sub-view .menu-item.center-cancelIcon,*/
/*.sub-view .menu-item.right-cancelIcon {*/
/* display: none; !* Cancel button is not applicable on subview menus *!*/
/*}*/
</style>

View File

@ -0,0 +1,281 @@
<template>
<div class="context-menu-bar">
<div class="left-container">
<TTContextButton :id="'context-group-'+item.items[0].id" :class="[dynamicClasses.nowrap, item.action_group_id]" v-for="item in alignItems.left" :items="item.items" />
</div>
<div class="center-container">
<TTContextButton :id="'context-group-'+item.items[0].id" :class="[dynamicClasses.nowrap, item.action_group_id]" v-for="item in alignItems.center" :items="item.items" />
</div>
<div class="right-container">
<TTContextButton :id="'context-group-'+item.items[0].id" :class="[dynamicClasses.nowrap, item.action_group_id]" v-for="item in alignItems.right" :items="item.items" />
</div>
</div>
</template>
<script>
import TTContextButton from '@/components/context_menu/TTContextButton';
// Note: No longer need to import the context menu manager, as we only respond to events generated from it.
export default {
name: "TTContextMenu",
created() {
this.event_bus = new TTEventBus( {
component_id: this.component_id + this.menu_id, //Context menu is unique to each view and is removed when the view is closed.
} );
// Debug.Text( 'Context menu created.', 'TTContextMenu.vue', 'TTContextMenu', 'created', 10 );
/*
* Init event listeners with EventBus to handle data coming from outside the component/Vue.
*/
/* -------- Event Listener for MENU updates -------- */
let onUpdateMenu = function ( event_data ) {
// If the view id changes when the menu is re-built, update current view id. Only menu update should change active view id.
// if( this.active_view_id !== event_data.view_id ) {
// // Debug.Highlight('Active view for context menu updated to: '+event_data.view_id+', previous was: '+ this.active_view_id);
// this.active_view_id = event_data.view_id;
// }
if( this.menu_id === event_data.menu_id ) {
// Debug.Highlight('Context menu update received for: '+ event_data.menu_id, event_data );
Debug.Text( 'Contextmenu\n MENU update RECEIVED for: '+ event_data.menu_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:update_context_menu', 11 );
this.rebuildMenu( event_data.menu_model );
} else {
Debug.Warn( 'Error: Context Menu update does not match menu id.\nThis menu: '+ this.menu_id +'\nEvent menu: '+event_data.menu_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:update_context_menu', 11 );
}
}.bind(this); // Must bind to this at variable definition, not in the EventBus on/off as each .bind creates a new function reference, so it wont be able to match up on delete.
// EventBus.on( this.viewId + '.updateContextMenu', ( event_data ) => {
this.event_bus.on( this.component_id, 'update_context_menu', onUpdateMenu, TTEventBusStatics.AUTO_CLEAR_ON_EXIT );
/* -------- Event Listener for ITEM updates -------- */
let onUpdateItem = function( event_data ) {
if( this.menu_id === event_data.menu_id ) {
Debug.Text( 'Contextmenu\n ITEM update RECEIVED for: '+ event_data.menu_id + ':' + event_data.item_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:update_item', 11 );
this.updateMenuItem( event_data.item_id, event_data.item_attributes );
} else {
// If the view id does not match active view, then ignore the update, as its probably the previous view.
// Must ignore update if no match, otherwise item updates from old and new view will conflict and context menu items may then not display as expected. (BugFix: TimeSheet->JumpTo->AddRequest->Cancel. Visible icons not as expected. Cancel icon not disabled).
Debug.Warn( 'Error: Context Menu Item update does not match menu id.\nThis menu: '+ this.menu_id +'\nEvent menu: '+event_data.menu_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:updateItem', 11 );
}
}.bind(this); // Must bind to this at variable definition, not in the EventBus on/off as each .bind creates a new function reference, so it wont be able to match up on delete.
// EventBus.on( this.viewId + '.updateItem', ( event_data ) => {
this.event_bus.on( this.component_id, 'update_item', onUpdateItem, TTEventBusStatics.AUTO_CLEAR_ON_EXIT );
/* -------- Event Listener for MULTISELECTITEM updates -------- */
let onActivateMultiSelectItem = function( event_data ) {
if( this.menu_id === event_data.menu_id ) {
Debug.Text( 'Contextmenu\n MULTI_ITEM update RECEIVED for: '+ event_data.menu_id + ':' + event_data.item_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_multi_select_item', 11 );
this.activateMultiSelectItem( event_data.item_id );
} else {
// If the view id does not match active view, then ignore the update, as its probably the previous view.
// Must ignore update if no match, otherwise item updates from old and new view will conflict and context menu items may then not display as expected. (BugFix: TimeSheet->JumpTo->AddRequest->Cancel. Visible icons not as expected. Cancel icon not disabled).
Debug.Warn( 'Error: Context Menu Multi-select Item activation does not match menu id.\nThis menu: '+ this.menu_id +'\n Event menu: '+event_data.menu_id+' )', 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_multi_select_item', 11 );
}
}.bind(this); // Must bind to this at variable definition, not in the EventBus on/off as each .bind creates a new function reference, so it wont be able to match up on delete.
this.event_bus.on( this.component_id, 'activate_multi_select_item', onActivateMultiSelectItem, TTEventBusStatics.AUTO_CLEAR_ON_EXIT );
/* -------- Event Listener for SPLITBUTTON updates -------- */
let onActivateSplitButtonItem = function( event_data ) {
if ( this.menu_id === event_data.menu_id ) {
Debug.Text( 'Contextmenu\n SPLIT BUTTON update RECEIVED for: ' + event_data.menu_id + ':' + event_data.item_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_split_button_item', 11 );
this.activateSplitButtonItem( event_data.item_id );
} else {
// If the view id does not match active view, then ignore the update, as its probably the previous view.
Debug.Warn( 'Error: Context Menu Split Button Item activation does not match menu id.\nThis menu: ' + this.menu_id + '\n Event menu: ' + event_data.menu_id + ' )', 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_split_button_item', 11 );
}
}.bind( this ); // Must bind to this at variable definition, not in the EventBus on/off as each .bind creates a new function reference, so it wont be able to match up on delete.
this.event_bus.on( this.component_id, 'activate_split_button_item', onActivateSplitButtonItem,TTEventBusStatics.AUTO_CLEAR_ON_EXIT );
let onFreezeSplitButtonActiveItem = function( event_data ) {
if ( this.menu_id === event_data.menu_id ) {
Debug.Text( 'Contextmenu\n SPLIT BUTTON update RECEIVED for: ' + event_data.menu_id + ':' + event_data.item_id, 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_split_button_item', 11 );
this.freezeSplitButtonActiveItem( event_data.item_id );
} else {
// If the view id does not match active view, then ignore the update, as its probably the previous view.
Debug.Warn( 'Error: Context Menu Split Button Item activation does not match menu id.\nThis menu: ' + this.menu_id + '\n Event menu: ' + event_data.menu_id + ' )', 'TTContextMenu.vue', 'TTContextMenu', 'created:EventBus:activate_split_button_item', 11 );
}
}.bind( this ); // Must bind to this at variable definition, not in the EventBus on/off as each .bind creates a new function reference, so it wont be able to match up on delete.
this.event_bus.on( this.component_id, 'freeze_split_button_active_item', onFreezeSplitButtonActiveItem, TTEventBusStatics.AUTO_CLEAR_ON_EXIT );
},
unmounted() {
Debug.Text( 'Vue context menu component unmounted ('+ this.menu_id +').', 'TTContextMenu.vue', 'TTContextMenu', 'unmounted', 10 );
this.event_bus.autoClear();
},
props: {
menu_id: String
},
data() {
return {
component_id: 'context_menu',
built_menu: [],
removeEventsOnUnmount: [],
nowrap_views: [ 'TimeSheet' ], // TimeSheet is to handle the New Punch label wrapping. This is here to test if this will work globally, nowrap might cause issues.
}
},
computed: {
alignItems() {
let filter_left = ( element ) => element.menu_align === 'left';
let filter_center = ( element ) => element.menu_align === 'center';
let filter_right = ( element ) => element.menu_align === 'right';
return {
left: this.built_menu.filter( filter_left ),
center: this.built_menu.filter( filter_center ),
right: this.built_menu.filter( filter_right )
};
},
dynamicClasses() {
return {
// 'nowrap': this.nowrap_views.includes( this.viewId ) ? 'no-wrap' : '', // If this returns true, this prevents the text label of a nav button wrapping to a new-line, affecting the button heights for the whole menu. Care needs to be taken if not all buttons will fit with no-wrap.
nowrap: 'no-wrap', // Simplifing this while we no longer pass in viewId. If this feature is needed, then we need to get viewId another way.
}
}
},
methods: {
rebuildMenu( menu_model ) {
this.built_menu.length = 0; // TODO: Should not need to do this once TTContextMenu has an independant instance for each view. At the moment, all views are one Vue View (LegacyView).
this.built_menu.push(...menu_model ); // TODO: Need to handle the eventuality where icons array needs to be cleared first without losing the JS reference to the array.
},
/**
* Update several item params in one go.
* This will also be useful for event driven updates, reduces the need to have an event function for each action.
* @param {string} item_id - ID of the item to update
* @param {Object} new_item_attributes - The new data as attributes in an object.
* @returns {Object}
*/
updateMenuItem( item_id, new_item_attributes ) {
var item = this.getMenuItemById( item_id );
if( item === undefined ) {
// If this happens, it might be that the Vue menu has cleared already, and the menu is trying to set states on old icons no longer present. Might be a legacy->Vue issue on a view change. But the vue menu items should always match the legacy menu on the view controller. Trace the icons and try and fix the disconnect.
Debug.Warn( 'Menu item not found ('+ item_id +') unable to update with: ' + JSON.stringify( new_item_attributes ), 'TTContextMenu.vue', 'TTContextMenu', 'updateMenuItem', 1 );
return false;
}
return Object.assign( item, new_item_attributes);
},
activateMultiSelectItem( item_id ) {
var item = this.getMenuItemById( item_id );
if( item === undefined ) {
// If this happens, it might be that the Vue menu has cleared already, and the menu is trying to set states on old icons no longer present. Might be a legacy->Vue issue on a view change. But the vue menu items should always match the legacy menu on the view controller. Trace the icons and try and fix the disconnect.
Debug.Warn( 'Menu item not found ('+ item_id +') unable to update with: ' + JSON.stringify( new_item_attributes ), 'TTContextMenu.vue', 'TTContextMenu', 'updateMenuItem', 1 );
return false;
}
if( item.setOnlySelfActive === undefined ) {
item.default_active_item = true;
return 1;
} else {
item.setOnlySelfActive();
return 2;
}
},
activateSplitButtonItem( item_id ) {
for ( let i = 0; i < this.built_menu.length; i++ ) {
//Only modify the action group that contains the item_id we want. This is to avoid affecting any other action groups active item.
if ( this.built_menu[i].items.some(item => item.id === item_id ) ) {
for ( let j = 0; j < this.built_menu[i].items.length; j++ ) {
if ( this.built_menu[i].items[j].id === item_id ) {
this.built_menu[i].items[j].menu_force_active = true;
} else {
this.built_menu[i].items[j].menu_force_active = false;
}
}
}
}
},
freezeSplitButtonActiveItem( item_id ) {
var item = this.getMenuItemById( item_id );
if ( item === undefined ) {
// If this happens, it might be that the Vue menu has cleared already, and the menu is trying to set states on old icons no longer present. Might be a legacy->Vue issue on a view change. But the vue menu items should always match the legacy menu on the view controller. Trace the icons and try and fix the disconnect.
Debug.Warn( 'Menu item not found (' + item_id + ') unable to update', 'TTContextMenu.vue', 'TTContextMenu', 'setSplitButtonTemporarilyIgnoreDisabled', 1 );
return false;
}
item.freeze_active_item = true;
},
enableMenuItem( item_id ) {
var item = this.getMenuItemById( item_id );
item.disabled = false;
},
disableMenuItem( item_id ) {
var item = this.getMenuItemById( item_id );
item.disabled = true;
},
hideMenuItem( item_id ) {
var item = this.getMenuItemById( item_id );
item.visible = false;
},
showMenuItem( item_id ) {
var item = this.getMenuItemById( item_id );
item.visible = true;
},
getMenuItemById( item_id ) {
// TODO: Have I already done a similar function elsewhere? It seems familiar, but with ES6 array functions instead of for loops?
// Check the context menu class as well as the left menu class. Put in Global if found.
var result;
function recursiveFind( haystack_array, needle_id ) {
return haystack_array.find( element => {
if ( Array.isArray( element ) ) {
return recursiveFind( element, needle_id );
} else {
if( element.id === undefined && Array.isArray( element.items ) ) {
return recursiveFind( element.items, needle_id );
} else if( element.id === needle_id ) {
result = element;
return true;
}
}
} );
}
recursiveFind( this.built_menu, item_id );
return result;
}
},
components: { TTContextButton }
};
</script>
<style>
/* Hide icons from dropdown menus of context buttons and not the buttons themselves. */
.p-menuitem-link .p-menuitem-icon {
display: none;
}
/* Icons can be displayed in dropdowns by using the class "tticon-show-in-dropdown"
for example: Multi-select (radio and check boxes) still need to be displayed.
*/
.p-menuitem-link .tticon-show-in-dropdown {
display: inline;
}
</style>
<style scoped>
.context-menu-bar {
display: flex;
justify-content: space-between;
/*background: #e9ecef; !* Chose #e9ecef, previously #ced4da but looked too dark once the rest of the page background is white/off-white. *!*/
margin-bottom: 10px;
padding-bottom: 5px; /* To balance out the top padding applied from .context-border padding. Trying 5px instead of 10px to balance out closeness of the context-border heading to the menu, but now top and bottom not equal but might be ok. */
border-bottom: 1px solid #dbdee1;
}
.left-container,
.center-container,
.right-container {
display: flex;
justify-content: flex-start;
padding: 4px; /* To match figma design */
}
.no-wrap {
white-space: nowrap;
}
.p-menuitem-icon {
display: none;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="tt-overlaymenu" v-if="items[0].visible !== false && items.length > 1">
<Menu ref="menu" :model="wrapped_items_without_header" :popup="true" appendTo="body"/>
<Button class="menu-button" type="button" :label="items[0].label" :icon="!items[0].label ? items[0].icon : 'pi pi-chevron-down'" iconPos="right" @click="toggleMenu"/>
</div>
</template>
<script>
/**
* This is a special version of the split-button menu, using the overlay menu, which has a header instead of the currently 'active' action button.
* So where the click of a 'Save & Next' button in a menu will swap the active button to Save & Next, clicking a menu item in a Navigation menu, will leave the label as Navigation.
* As there is no 'active action' the label and menu open button are one button, instead of split.
* There are two ways of making an action group menu with a header.
* 1) Define a header in the getCustomContextMenuModel function or similar, setting action_group and action_group_header. Like TimeSheet Navigation menu.
* 2) Define a single object in getCustomContextMenuModel, setting the action_group and action_group_header, and then including an 'items' array containing your sub menu items. Like TimeSheet Print menu.
* Having 2 options is more complicated, but its there to stay compatible with the legacy context menu. Option 1 is needed to stay compatible with the Save, Delete, and Navigation groups. And Option 2 is needed for the way the old menu defined items like the Print menu.
* Once the old menu and format is gone, we can refactor to simplify this behaviour.
* Note: permission_result: false is needed on the header items to make sure the old menu ignores this item.
* Note: the "v-if && items.length > 1" portion above is to ensure that the menu button is hidden if the items list contains only the header and nothing else. Hence the more than 1 criteria.
*/
import Menu from 'primevue/menu';
import Button from 'primevue/button';
export default {
name: "TTOverlayMenuButton",
props: {
items: [Array, Object],
},
computed: {
wrapped_items_without_header() {
return this.wrapItemCommands(this.items).slice(1); // We slice 1st item away so that the header item is not included. TODO: This means visible: false is no longer needed on the header, remove this from all code.
}
},
methods: {
toggleMenu(event) {
this.$refs.menu.toggle(event);
},
wrapItemCommands( items_array ) {
// TODO: Consolidate this with the function in TTSplitButton (maybe in TTContextButton), and the command function in ContextMenuManager.convertBackBoneMenuModelToPrimeVue so that we are not parsing the command functions in more than one place.
// Have to do this, currently see no quick way to detect when a button menu item has been clicked.
var new_array = items_array.map((item, item_index) => {
// The below is technically a Vue anti-pattern, as we are in effect also modifying the parent data.
// Normally data goes parent => child components, and events go child => parent. And then its up to the parent to handle that event to update data where needed.
// But in this case, we just want to manipulate data before it goes further down the line, but due to JS pass by reference, the parent gets updated here too.
// Perhaps fix this later on, after a Proof of concept for the SplitButtons has been achieved.
var original_command = item.command;
item.command = Global.debounce( () => {
// Intercept the item click before running the original command as normal.
original_command( item.id ); // trigger the original command, and pass in the item.id of the button icon. TODO: item.id is non-generic logic. Not ideal for a 'dumb' component.
}, Global.calcDebounceWaitTimeBasedOnNetwork(), true );
return item;
} );
return new_array;
},
},
components: {
Menu,
Button
}
};
</script>
<style scoped>
.tt-overlaymenu { /* Copy the p-splitbutton CSS to allow in-line display */
display: inline-flex;
}
.menu-button {
width: 100%;
}
</style>

View File

@ -0,0 +1,159 @@
<template>
<div class="tt-overlay-multi-select-menu" v-if="items.some( item => item.visible === true || item.visible === undefined )" :disabled="items.every( item => item.disabled === true )">
<Menu ref="menu" :model="wrapped_items" :popup="true" appendTo="body"/>
<Button class="menu-button" type="button" :label="get_menu_label" :icon="!showGroupLabel() ? items[0].vue_icon : 'pi pi-chevron-down'" iconPos="right" @click="toggleMenu"/>
</div>
</template>
<script>
import Menu from 'primevue/menu';
import Button from 'primevue/button';
export default {
name: "TTOverlayMultiSelectButton",
props: {
items: Array,
},
computed: {
wrapped_items() {
return this.wrapItemCommands(this.items);
},
get_menu_label() {
if ( !this.showGroupLabel() ) {
return '';
}
// Filter out all active items, and if none, then set the default as shown in the last line (action_group id).
return this.items
.filter(( element ) => element.active )
.map(( element ) => element.label)
.join(', ')
|| this.items[0].action_group; // Sets default label as action_group for fallback. Can't simply be first item in array, as the view controller data wont match this.
}
},
methods: {
toggleMenu(event) {
this.$refs.menu.toggle(event);
},
wrapItemCommands( items_array ) {
// TODO: Consolidate this with the function in TTSplitButton (maybe in TTContextButton), and the command function in ContextMenuManager.convertBackBoneMenuModelToPrimeVue so that we are not parsing the command functions in more than one place.
// Have to do this, currently see no quick way to detect when a button menu item has been clicked.
var new_array = items_array.map((item, item_index) => {
if( item.active === true ) {
this.setItemActive( item );
} else {
this.setItemInactive( item ); // Set all default item states to inactive, so that they get the correct active false flag, but also the correct inactive icon class to display in the menu.
}
// Copied from TTOverlayButton.vue
// The below is technically a Vue anti-pattern, as we are in effect also modifying the parent data.
// Normally data goes parent => child components, and events go child => parent. And then its up to the parent to handle that event to update data where needed.
// But in this case, we just want to manipulate data before it goes further down the line, but due to JS pass by reference, the parent gets updated here too.
// Perhaps fix this later on, after a Proof of concept for the SplitButtons has been achieved.
//this.onSelectionClick was being set and triggered multiple times.
//This caused some buttons such as Schedule -> Drag & Drop: Overwrite to not trigger properly until user clicked multiple times.
//For now just making sure not to set the same command multiple times.
if ( !item.command_set ) {
var original_command = item.command;
item.command = Global.debounce( () => {
this.onSelectionClick( item );
original_command( item.id ); // trigger the original command, and pass in the item.id of the button icon. TODO: item.id is non-generic logic. Not ideal for a 'dumb' component.
}, Global.calcDebounceWaitTimeBasedOnNetwork(), true );
item.command_set = true;
}
// add a setActive and setDeactivated command which can be called from parent components, that sets itself active, and other options inactive. Alternatively could also be done as a child to parent emit, but then harder to tie into EventBus events.
item.setOnlySelfActive = () => {
this.setOnlyOneActive( item );
}
item.setOnlySelfDeactivated = () => {
this.setOnlyOneDeactivated( item );
}
// check if there is an existing flag for default active item.
if( item.default_active_item ) {
item.setOnlySelfActive();
}
return item;
});
return new_array;
},
onSelectionClick( item ) {
// Check if the number of items in the group equals one. If so, then just toggle states.
if ( this.isSingleGroupItem( item ) ) {
this.toggleActive( item );
} else {
// We want to ensure only one active item at a time.
this.setOnlyOneActive( item );
}
},
toggleActive( item ) {
if ( item.active ) {
this.setItemInactive( item )
} else {
this.setItemActive( item );
}
},
setOnlyOneActive( item ) {
this.items.map(( element ) => {
// Ensure we only clear the ones from the same group.
if( element.multi_select_group === item.multi_select_group ) {
this.setItemInactive( element );
}
});
this.setItemActive( item );
},
setOnlyOneDeactivated( item ) {
this.setItemInactive( item );
},
setItemActive( item ) {
if ( this.isSingleGroupItem( item ) ) {
item.icon = 'tticon tticon-show-in-dropdown tticon-check_box_black_24dp ';
} else {
item.icon = 'tticon tticon-show-in-dropdown tticon-radio_button_checked_black_24dp';
}
item.active = true;
},
setItemInactive( item ) {
if ( this.isSingleGroupItem( item ) ) {
item.icon = 'tticon tticon-show-in-dropdown tticon-check_box_outline_blank_black_24dp ';
} else {
item.icon = 'tticon tticon-show-in-dropdown tticon-radio_button_unchecked_black_24dp';
}
item.active = false;
},
getItemById( find_item_id ) {
return this.items.find( element => element.id === find_item_id );
},
isSingleGroupItem( item ) {
//Check if select belongs to a group or is a Single-selection. Single-selection is displayed as a checkbox.
var group_count = this.items.filter( ( element ) => element.multi_select_group === item.multi_select_group ).length;
return group_count === 1;
},
showGroupLabel() {
if ( this.items.some( item => item.no_group_label ) ) {
return false;
}
return true;
}
},
components: {
Menu,
Button
}
};
</script>
<style scoped>
.tt-overlaymenu { /* Copy the p-splitbutton CSS to allow in-line display */
display: inline-flex;
}
.menu-button {
width: 100%;
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<SplitButton :label="active_item.label" :icon="active_item.icon" :model="parsed_items" @click="active_item.command" :disable_item="active_item.disabled" :disable_menu="disable_menu"></SplitButton>
</template>
<script>
import SplitButton from './PrimeVueSplitButton'; // Moved SplitButton into TT codebase as we needed to adjust the disabled button states.
export default {
name: "TTSplitButton",
props: {
items: Array
},
data() {
return {
disable_active_toggle: false, // If true, then active_item_index will not change upon menu selection. Likely used in conjunction with force_active_index.
active_item_index: 0,
}
},
computed: {
disable_menu() {
// FIX: Menu must be disabled independantly via computed disable_menu, as previously it was done in firstEnabledItemIndex function which was not always called on data updates if first item was enabled.
if( this.firstEnabledItemIndex === -1 ) {
// -1 means no enabled items found, so lets disable the whole menu.
return true;
} else {
// At least one enabled item found, enable the menu dropdown arrow.
return false;
}
},
parsed_items() {
// Converted this to a computed function from data, to ensure its recalculated. If this does not solve context icons updating, then look into Vue.set
// This should re-run each time the data changes
return this.parseItemArrayCommands(this.items);
},
active_item() {
// Active item logic has been modified to be overridden in certain situations like Export/Import.
// You can choose to force the active item default by setting menu_force_active to true on the item definition.
// You can also choose to disable the active item toggling when an option is selected from the menu. By default, if the above flag is used, toggling will also be disabled. Adjust this below.
var force_active_item_index = this.findForceActiveItemIndex();
if( force_active_item_index !== -1 ) {
// !== -1 means it found a menu_force_active flag
this.disable_active_toggle = true; // For now we will also disable toggle behaviour as this is the only need for this combo. Remove this line if you only want to set default but allow toggle changes.
this.active_item_index = force_active_item_index;
} else {
// -1 means no menu_force_active flag was found on any of the items, so lets find the first enabled item to make active.
if ( this.items[this.active_item_index].disabled === true && !this.items[this.active_item_index].freeze_active_item ) {
if ( this.firstEnabledItemIndex === -1 ) {
// No enabled items found, return 0 to label the menu by the first item in the array. Menu will be disabled via computed disable_menu function.
this.active_item_index = 0;
} else {
// Successfully found the first enabled item.
this.active_item_index = this.firstEnabledItemIndex;
}
}
}
//Button can be temporarily set to freeze the active item. This stops the active split button item from resetting.
//Example if doing "Save & Continue" we do not want the button to switch back to "Save" just because validation failed.
//Being reset here as we only want the freeze temporarily. If in the future this causes issues, the setting of false could be moved
//elsewhere to prevent any race conditions with order of events - although there are none I am aware of currently.
this.items[this.active_item_index].freeze_active_item = false;
return {
label: this.getParsedLabel( this.active_item_index ),
icon: this.getParsedIcon( this.active_item_index ),
command: this.getParsedCommand( this.active_item_index ),
disabled: this.getParsedDisabledState( this.active_item_index ),
};
},
firstEnabledItemIndex() {
// Find first enabled item in array. In most cases, should be 0, but not if item zero happens to be disabled, as it would end up being shown as enabled.
// If index=null, then this is on first load, so calculate which item should be the active one.
var enabled = ( element ) => element.disabled !== true;
var find_result = this.items.findIndex( enabled );
return find_result;
},
},
methods: {
parseItemArrayCommands( items_array ) {
// Have to do this, currently see no neater way to detect when a button menu item has been clicked.
let new_array = items_array.map((item, item_index) => {
// The below is technically a Vue anti-pattern, as we are in effect also modifying the parent data.
// Normally data goes parent => child components, and events go child => parent. And then its up to the parent to handle that event to update data where needed.
// But in this case, we just want to manipulate data before it goes further down the line, but due to JS pass by reference, the parent gets updated here too.
// Perhaps fix this later on, after a Proof of concept for the SplitButtons has been achieved.
let original_command = item.command;
item.command = Global.debounce( () => {
// Intercept the item click before running the original command as normal.
this.updateActiveButton( item_index );
original_command( item ); // trigger the original command, and pass in the item.id of the button icon. TODO: item.id is non-generic logic. Not ideal for a 'dumb' component.
}, Global.calcDebounceWaitTimeBasedOnNetwork(), true );
return item;
});
return new_array;
},
getParsedCommand( index ) {
return this.parsed_items[ index ].command;
},
getParsedLabel( index ) {
return this.parsed_items[ index ].label;
},
getParsedIcon( index ) {
return this.parsed_items[ index ].icon;
},
getParsedDisabledState( index ) {
return this.parsed_items[ index ].disabled;
},
updateActiveButton( new_active_item_index ) {
if ( this.disable_active_toggle === false ) {
// Only update the active button to last action if active toggle is not disabled.
this.active_item_index = new_active_item_index;
for ( let i = 0; i < this.items.length; i++ ) {
//Setting split_button_active_item so that from other areas of code we can know if the user selected an item.
//This is currently used doing error validation so we can check if a user selected an item and freeze it.
//For example if doing "Save & Continue" and validation error happens, we do not want to switch
//the button back to to "Save". But that would happen as the item is disabled while validation fails.
if ( i === this.active_item_index ) {
this.items[i].split_button_active_item = true;
} else {
this.items[i].split_button_active_item = false;
}
}
}
},
findForceActiveItemIndex() {
var menu_force_active = ( element ) => element.menu_force_active === true;
var filtered_items = this.items.filter( menu_force_active );
var first_index = this.items.findIndex( menu_force_active );
if( filtered_items.length === 1 ) {
return first_index;
} else if( filtered_items.length > 1 ) {
Debug.Error( 'Error: More than one Force Active flag found, defaulting to normal splitbutton behaviour.', 'TTSplitButton.vue', 'TTSplitButton', 'findForceActiveItem', 1 );
return -1;
} else {
// No force active flags found, treat as normal item.
return -1;
}
}
},
components: {
SplitButton
}
};
</script>
<style scoped>
</style>