mirror of https://github.com/rancher/dashboard.git
357 lines
9.2 KiB
Vue
357 lines
9.2 KiB
Vue
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import $ from 'jquery';
|
|
import { AUTO, CENTER, fitOnScreen } from '@shell/utils/position';
|
|
import { isAlternate } from '@shell/utils/platform';
|
|
import IconOrSvg from '@shell/components/IconOrSvg';
|
|
|
|
const HIDDEN = 'hide';
|
|
const CALC = 'calculate';
|
|
const SHOW = 'show';
|
|
|
|
export default {
|
|
name: 'ActionMenu',
|
|
components: { IconOrSvg },
|
|
props: {
|
|
customActions: {
|
|
// Custom actions can be used if you need the action
|
|
// menu to work for something that is not a Kubernetes
|
|
// resource, for example, a receiver within an
|
|
// AlertmanagerConfig.
|
|
|
|
// This prop can also be used to avoid
|
|
// a dependency on Vuex. For now, this component can have
|
|
// its state controlled by either props OR by Vuex, but if it
|
|
// gets unwieldy, it could later be split into two components,
|
|
// one with the dependency on Vuex and one without.
|
|
type: Array,
|
|
default: () => {
|
|
return [];
|
|
}
|
|
},
|
|
open: {
|
|
// Use this prop to show and hide the action menu if
|
|
// you want to avoid an unnecessary dependency on Vuex.
|
|
|
|
// Note: There are known issues with performance if this component
|
|
// is included with every row of a table, so don't do that.
|
|
// Instead the ActionMenu component can be included once on a page,
|
|
// and then if you click on a list item, that can change
|
|
// the menu's target so that it can open in different locations.
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
useCustomTargetElement: {
|
|
// The custom target element can be a
|
|
// variable in the component state of a list or detail page
|
|
// if you don't want a dependency on Vuex.
|
|
// Then when an action menu button is clicked, it can emit an event
|
|
// that triggers the target to be set to the clicked element,
|
|
// so that the dropdown menu can open where the context menu
|
|
// was clicked.
|
|
// This flag tells the component to look for and use the
|
|
// custom target element.
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
customTargetElement: {
|
|
type: HTMLElement,
|
|
default: null
|
|
},
|
|
customTargetEvent: {
|
|
// The event details from the user's click can be used
|
|
// for positioning the menu on the page.
|
|
type: PointerEvent,
|
|
default: null
|
|
},
|
|
|
|
/**
|
|
* Inherited global identifier prefix for tests
|
|
* Define a term based on the parent component to avoid conflicts on multiple components
|
|
*/
|
|
componentTestid: {
|
|
type: String,
|
|
default: 'action-menu'
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return { phase: HIDDEN, style: {} };
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters({
|
|
// Use either these Vuex getters
|
|
// OR the props to set the action menu state,
|
|
// but don't use both.
|
|
targetElem: 'action-menu/elem',
|
|
targetEvent: 'action-menu/event',
|
|
shouldShow: 'action-menu/showing',
|
|
options: 'action-menu/options'
|
|
}),
|
|
|
|
showing() {
|
|
return this.phase !== HIDDEN;
|
|
},
|
|
menuOptions() {
|
|
if (this.customActions.length > 0) {
|
|
return this.customActions;
|
|
}
|
|
|
|
return this.options;
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
shouldShow: {
|
|
handler(show) {
|
|
if ( show ) {
|
|
this.phase = CALC;
|
|
this.updateStyle();
|
|
this.$nextTick(() => {
|
|
if ( this.phase === CALC ) {
|
|
this.phase = SHOW;
|
|
this.updateStyle();
|
|
}
|
|
});
|
|
} else {
|
|
this.phase = HIDDEN;
|
|
}
|
|
},
|
|
},
|
|
|
|
open() {
|
|
// This component has a timing issue where the
|
|
// mounted size of the expanded menu is used to
|
|
// calculate where its position should be. That means
|
|
// it won't work if the style is a computed property,
|
|
// so we put a watcher here to update the style instead.
|
|
this.updateStyle();
|
|
},
|
|
|
|
'$route.path'(val, old) {
|
|
this.hide();
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
hide() {
|
|
if (this.useCustomTargetElement) {
|
|
// If the show/hide state is controlled
|
|
// by props, emit an event to close the menu.
|
|
this.$emit('close');
|
|
} else {
|
|
// If the show/hide state is controlled
|
|
// by Vuex, mutate the store to close the menu.
|
|
this.$store.commit('action-menu/hide');
|
|
}
|
|
},
|
|
|
|
updateStyle() {
|
|
if ( this.phase === SHOW && !this.useCustomTargetElement) {
|
|
const menu = $('.menu', this.$el)[0];
|
|
const event = this.targetEvent;
|
|
const elem = this.targetElem;
|
|
|
|
// If the action menu state is controlled with Vuex,
|
|
// use the target element and the target event
|
|
// to position the menu.
|
|
this.style = fitOnScreen(menu, elem || event, {
|
|
overlapX: true,
|
|
fudgeX: elem ? -2 : 0,
|
|
fudgeY: elem ? 20 : 0,
|
|
positionX: (elem ? AUTO : CENTER),
|
|
positionY: AUTO,
|
|
});
|
|
this.style.visibility = 'visible';
|
|
|
|
return;
|
|
}
|
|
|
|
if ( this.open && this.useCustomTargetElement) {
|
|
const menu = $('.menu', this.$el)[0];
|
|
const elem = this.customTargetElement;
|
|
|
|
// If the action menu state is controlled with
|
|
// props, use the target element to position the menu.
|
|
this.style = fitOnScreen(menu, elem, {
|
|
overlapX: true,
|
|
fudgeX: elem ? 4 : 0,
|
|
fudgeY: elem ? 4 : 0,
|
|
positionX: (elem ? AUTO : CENTER),
|
|
positionY: AUTO,
|
|
}, true );
|
|
|
|
this.style.visibility = 'visible';
|
|
|
|
return;
|
|
}
|
|
|
|
this.style = {};
|
|
},
|
|
|
|
execute(action, event, args) {
|
|
if (action.disabled) {
|
|
return;
|
|
}
|
|
|
|
// this will come from extensions...
|
|
if (action.invoke) {
|
|
const fn = action.invoke;
|
|
|
|
if (fn && action.enabled) {
|
|
const resources = this.$store.getters['action-menu/resources'];
|
|
const opts = {
|
|
event,
|
|
action,
|
|
isAlt: isAlternate(event)
|
|
};
|
|
|
|
if (resources.length === 1) {
|
|
fn.apply(this, [opts, resources]);
|
|
}
|
|
}
|
|
} else if (this.useCustomTargetElement) {
|
|
// If the state of this component is controlled
|
|
// by props instead of Vuex, we assume you wouldn't want
|
|
// the mutation to have a dependency on Vuex either.
|
|
// So in that case we use events to execute actions instead.
|
|
// If an action list item is clicked, this
|
|
// component emits that event, then we assume the parent
|
|
// component will execute the action.
|
|
this.$emit(action.action, {
|
|
action,
|
|
event,
|
|
...args,
|
|
route: this.$route
|
|
});
|
|
} else {
|
|
// If the state of this component is controlled
|
|
// by Vuex, mutate the store when an action is clicked.
|
|
const opts = { alt: isAlternate(event) };
|
|
|
|
this.$store.dispatch('action-menu/execute', {
|
|
action, args, opts
|
|
});
|
|
}
|
|
|
|
this.hide();
|
|
},
|
|
|
|
hasOptions(options) {
|
|
return options.length !== undefined ? options.length : Object.keys(options).length > 0;
|
|
}
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="showing || open">
|
|
<div
|
|
class="background"
|
|
@click="hide"
|
|
@contextmenu.prevent
|
|
/>
|
|
<ul
|
|
class="list-unstyled menu"
|
|
:style="style"
|
|
>
|
|
<li
|
|
v-for="(opt, i) in menuOptions"
|
|
:key="opt.action"
|
|
:disabled="opt.disabled"
|
|
:class="{divider: opt.divider}"
|
|
:data-testid="componentTestid + '-' + i + '-item'"
|
|
@click="execute(opt, $event)"
|
|
>
|
|
<IconOrSvg
|
|
v-if="opt.icon || opt.svg"
|
|
:icon="opt.icon"
|
|
:src="opt.svg"
|
|
class="icon"
|
|
color="header"
|
|
/>
|
|
<span v-html="opt.label" />
|
|
</li>
|
|
|
|
<li
|
|
v-if="!hasOptions(menuOptions)"
|
|
class="no-actions"
|
|
>
|
|
<span v-t="'sortableTable.noActions'" />
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.root {
|
|
position: absolute;
|
|
}
|
|
|
|
.menu {
|
|
position: absolute;
|
|
visibility: hidden;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: z-index('dropdownContent');
|
|
min-width: 145px;
|
|
|
|
color: var(--dropdown-text);
|
|
background-color: var(--dropdown-bg);
|
|
border: 1px solid var(--dropdown-border);
|
|
border-radius: 5px;
|
|
box-shadow: 0 5px 20px var(--shadow);
|
|
|
|
LI {
|
|
align-items: center;
|
|
display: flex;
|
|
padding: 8px 10px;
|
|
margin: 0;
|
|
|
|
&[disabled] {
|
|
cursor: not-allowed !important;
|
|
color: var(--disabled-text);
|
|
}
|
|
|
|
&.divider {
|
|
padding: 0;
|
|
border-bottom: 1px solid var(--dropdown-divider);
|
|
}
|
|
|
|
&:not(.divider):hover {
|
|
background-color: var(--dropdown-hover-bg);
|
|
color: var(--dropdown-hover-text);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.icon {
|
|
display: unset;
|
|
width: 14px;
|
|
text-align: center;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
&.no-actions {
|
|
color: var(--disabled-text);
|
|
}
|
|
|
|
&.no-actions:hover {
|
|
background-color: initial;
|
|
color: var(--disabled-text);
|
|
cursor: default;
|
|
}
|
|
}
|
|
}
|
|
|
|
.background {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
opacity: 0;
|
|
z-index: z-index('dropdownOverlay');
|
|
}
|
|
</style>
|