Merge pull request #13357 from rak-phillip/task/12776-table-actions

Replace ActionMenu usage for Table actions and Global Settings
This commit is contained in:
Phillip Rak 2025-02-19 11:24:25 -07:00 committed by GitHub
commit 07289b9f81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 301 additions and 130 deletions

View File

@ -0,0 +1,15 @@
import ComponentPo from '@/cypress/e2e/po/components/component.po';
export default class ActionMenuPo extends ComponentPo {
constructor(arg:any) {
super(arg || cy.get('[dropdown-menu-collection]'));
}
clickMenuItem(index: number) {
return this.self().find('[dropdown-menu-item]').eq(index).click();
}
getMenuItem(name: string) {
return this.self().get('[dropdown-menu-item]').contains(name);
}
}

View File

@ -11,7 +11,7 @@ export default class ListRowPo extends ComponentPo {
* the action button could be in a different column
*/
actionBtn() {
return this.self().find('.btn.actions');
return this.self().find('[data-testid*="action-button"]');
}
get(selector: string, options?: any) {

View File

@ -7,9 +7,9 @@ export default class Shell extends ComponentPo {
openTerminal() {
// get and click on the first row's action menu button
cy.get(`button[data-testid="sortable-table-0-action-button"]`).first().click();
cy.get(`[data-testid="sortable-table-0-action-button"`).first().click();
// get and click on the action menu's first option (execute shell)
cy.get(`li[data-testid="action-menu-0-item"]`).click();
cy.get(`[dropdown-menu-item]`).contains('Execute Shell').click();
this.self().get('.window.show-grid .text-success').should('contain', 'Connected');
return this;

View File

@ -1,5 +1,5 @@
import ComponentPo, { GetOptions } from '@/cypress/e2e/po/components/component.po';
import ActionMenuPo from '@/cypress/e2e/po/components/action-menu.po';
import ActionMenuPo from '@/cypress/e2e/po/components/action-menu-shell.po';
import CheckboxInputPo from '@/cypress/e2e/po/components/checkbox-input.po';
import ListRowPo from '@/cypress/e2e/po/components/list-row.po';
import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po';

View File

@ -9,7 +9,7 @@ export default class ChartRepositoriesListPo extends BaseResourceList {
}
closeActionMenu() {
cy.get('body').click(0, 0); // Click outside of the action menu
cy.get('body').type('{esc}');
}
openBulkActionDropdown() {
@ -25,8 +25,11 @@ export default class ChartRepositoriesListPo extends BaseResourceList {
}
refreshRepo(repoName: string) {
return this.resourceTable().sortableTable().rowActionMenuOpen(repoName).getMenuItem('Refresh')
.click();
return this.resourceTable()
.sortableTable()
.rowActionMenuOpen(repoName)
.getMenuItem('Refresh')
.click({ force: true }); // We shouldn't require force, but other methods don't work
}
state(repoName: string) {

View File

@ -33,7 +33,7 @@ export class SettingsPagePo extends RootClusterPage {
* @returns
*/
actionButtonByLabel(label: string) {
return this.advancedSettingRow(label).find('.action > button');
return this.advancedSettingRow(label).find('[data-testid*="action-button"]');
}
/**

View File

@ -10,7 +10,7 @@ export default class PageActionsPo extends ComponentPo {
* @returns {Cypress.Chainable}
*/
static open(): Cypress.Chainable {
return cy.getId('page-actions-menu').should('be.visible').click();
return cy.getId('page-actions-menu-action-button').should('be.visible').click();
}
/**

View File

@ -88,7 +88,7 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [
ChartRepositoriesPagePo.navTo();
repositoriesPage.waitForPage();
cy.intercept('PUT', `/v1/catalog.cattle.io.clusterrepos/${ this.repoName }`).as('refreshRepo');
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').click();
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').click({ force: true });
cy.wait('@refreshRepo').its('response.statusCode').should('eq', 200);
// check list details
@ -278,7 +278,10 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').should('be.visible');
// close action menu
repositoriesPage.list().closeActionMenu();
// disable repo
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1500);
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Disable').click();
repositoriesPage.list().details(this.repoName, 1).contains('Disabled', { timeout: 10000 }).scrollIntoView()
.should('be.visible');
@ -287,7 +290,10 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').should('not.exist');
// close action menu
repositoriesPage.list().closeActionMenu();
// enable repo
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1500);
repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Enable').click();
repositoriesPage.list().details(this.repoName, 1).contains('Active', LONG_TIMEOUT_OPT).scrollIntoView()
.should('be.visible');

View File

@ -37,6 +37,7 @@ module.exports = {
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest', // process js with `babel-jest`
'.*\\.(vue)$': '<rootDir>/node_modules/@vue/vue3-jest', // process `*.vue` files with `vue-jest`
'^.+\\.vue$': './vue3JestRegisterTs.js', // point to a different transformer than vue-jest and call registerTs before exporting vue-jest
'^.+\\.tsx?$': 'ts-jest', // process `*.ts` files with `ts-jest`
'^.+\\.svg$': '<rootDir>/svgTransform.js' // to mock `*.svg` files
},

View File

@ -65,25 +65,30 @@ defineExpose({ focus });
</template>
<style lang="scss" scoped>
.role-link {
&:focus, &.focused {
outline: var(--outline-width) solid var(--border);
box-shadow: 0 0 0 var(--outline-width) var(--outline);
}
}
button {
&.role-link {
&:focus, &.focused {
@include focus-outline;
outline-offset: -2px;
}
&:hover {
background-color: var(--accent-btn);
box-shadow: none;
}
}
&.role-ghost {
padding: 0;
background-color: transparent;
&:focus, &.focused {
outline: 2px solid var(--primary-keyboard-focus);
@include focus-outline;
outline-offset: 0;
}
&:focus-visible {
outline: 2px solid var(--primary-keyboard-focus);
@include focus-outline;
outline-offset: 0;
}
}

View File

@ -28,6 +28,8 @@ defineProps<{
ariaLabel?: string
}>();
const emit = defineEmits(['update:open']);
const {
isMenuOpen,
showMenu,
@ -35,8 +37,7 @@ const {
setFocus,
provideDropdownContext,
registerDropdownCollection,
handleKeydown,
} = useDropdownContext();
} = useDropdownContext(emit);
provideDropdownContext();

View File

@ -53,12 +53,12 @@ const findNewIndex = (shouldAdvance: boolean, activeIndex: number, itemsArr: Ele
return newIndex;
};
const handleClick = () => {
const handleClick = (e: MouseEvent) => {
if (props.disabled) {
return;
}
emits('click');
emits('click', e);
close();
};
@ -85,6 +85,9 @@ const handleActivate = (e: KeyboardEvent) => {
@keydown.enter.space="handleActivate"
@keydown.up.down.stop="handleKeydown"
>
<slot name="before">
<!--Empty slot content-->
</slot>
<slot name="default">
<!--Empty slot content-->
</slot>
@ -93,6 +96,9 @@ const handleActivate = (e: KeyboardEvent) => {
<style lang="scss" scoped>
[dropdown-menu-item] {
display: flex;
gap: 8px;
align-items: center;
padding: 9px 8px;
margin: 0 9px;
border-radius: 4px;

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import {
RcDropdown,
RcDropdownItem,
RcDropdownSeparator,
RcDropdownTrigger
} from '@components/RcDropdown';
import { RcDropdownMenuComponentProps, DropdownOption } from './types';
import IconOrSvg from '@shell/components/IconOrSvg';
// eslint-disable-next-line vue/no-setup-props-destructure
const { buttonRole = 'primary', buttonSize = '' } = defineProps<RcDropdownMenuComponentProps>();
const emit = defineEmits(['update:open', 'select']);
const hasOptions = (options: DropdownOption[]) => {
return options.length !== undefined ? options.length : Object.keys(options).length > 0;
};
</script>
<template>
<rc-dropdown
:aria-label="dropdownAriaLabel"
@update:open="(e: boolean) => emit('update:open', e)"
>
<rc-dropdown-trigger
:[buttonRole]="true"
:[buttonSize]="true"
:data-testid="dataTestid"
:aria-label="buttonAriaLabel"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<template
v-for="(a) in options"
:key="a.label"
>
<rc-dropdown-item
v-if="!a.divider"
@click="(e: MouseEvent) => emit('select', e, a)"
>
<template #before>
<IconOrSvg
v-if="a.icon || a.svg"
:icon="a.icon"
:src="a.svg"
class="icon"
color="header"
/>
</template>
{{ a.label }}
</rc-dropdown-item>
<rc-dropdown-separator
v-else
/>
</template>
<rc-dropdown-item
v-if="!hasOptions(options)"
disabled
>
No actions available
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>

View File

@ -2,3 +2,4 @@ export { default as RcDropdown } from './RcDropdown.vue';
export { default as RcDropdownItem } from './RcDropdownItem.vue';
export { default as RcDropdownSeparator } from './RcDropdownSeparator.vue';
export { default as RcDropdownTrigger } from './RcDropdownTrigger.vue';
export { default as RcDropdownMenu } from './RcDropdownMenu.vue';

View File

@ -1,5 +1,6 @@
import { Ref, ref } from 'vue';
import type { RcButtonType } from '@components/RcButton';
import { ButtonRoleProps, ButtonSizeProps } from '@components/RcButton/types';
export type DropdownContext = {
handleKeydown: () => void;
@ -20,3 +21,29 @@ export const defaultContext: DropdownContext = {
isMenuOpen: ref(false),
close: () => null,
};
export type DropdownOption = {
action?: string;
divider?: boolean;
enabled: boolean;
icon?: string;
svg?: string;
label?: string;
total: number;
allEnabled: boolean;
anyEnabled: boolean;
available: number;
bulkable?: boolean;
bulkAction?: string;
altAction?: string;
weight?: number;
}
export type RcDropdownMenuComponentProps = {
options: DropdownOption[];
buttonRole?: keyof ButtonRoleProps;
buttonSize?: keyof ButtonSizeProps;
buttonAriaLabel?: string;
dropdownAriaLabel?: string;
dataTestid?: string;
}

View File

@ -1,7 +1,9 @@
import { ref, provide, nextTick } from 'vue';
import { ref, provide, nextTick, defineEmits } from 'vue';
import { useDropdownCollection } from './useDropdownCollection';
import { RcButtonType } from '@components/RcButton';
const rcDropdownEmits = defineEmits(['update:open']);
/**
* Composable that provides the context for a dropdown menu. Includes methods
* and state for managing the dropdown's visibility, focus, and keyboard
@ -11,7 +13,7 @@ import { RcButtonType } from '@components/RcButton';
* @returns Dropdown context methods and state. Used for programmatic
* interactions and setting focus.
*/
export const useDropdownContext = () => {
export const useDropdownContext = (emit: typeof rcDropdownEmits) => {
const {
dropdownItems,
firstDropdownItem,
@ -30,6 +32,7 @@ export const useDropdownContext = () => {
didKeydown.value = false;
}
isMenuOpen.value = show;
emit('update:open', show);
};
/**

View File

@ -7,6 +7,7 @@ export {
RcDropdown,
RcDropdownItem,
RcDropdownSeparator,
RcDropdownTrigger
RcDropdownTrigger,
RcDropdownMenu
} from './components/RcDropdown';
export * from './components/Form';

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useStore } from 'vuex';
import { isAlternate } from '@shell/utils/platform';
import { RcDropdownMenu } from '@components/RcDropdown';
import { ButtonRoleProps, ButtonSizeProps } from '@components/RcButton/types';
const store = useStore();
const options = computed(() => store.getters['action-menu/optionsArray']);
type RcDropdownMenuComponentProps = {
buttonRole?: keyof ButtonRoleProps;
buttonSize?: keyof ButtonSizeProps;
buttonAriaLabel?: string;
dropdownAriaLabel?: string;
dataTestid?: string;
resource: Object;
}
const props = defineProps <RcDropdownMenuComponentProps>();
const openChanged = (event: boolean) => {
if (event) {
store.dispatch('action-menu/setResource', props.resource);
}
};
const execute = (action: any, event: MouseEvent, args?: any) => {
if (action.disabled) {
return;
}
// this will come from extensions...
if (action.invoke) {
const fn = action.invoke;
if (fn && action.enabled) {
const resources = store.getters['action-menu/resources'];
const opts = {
event,
action,
isAlt: isAlternate(event)
};
if (resources.length === 1) {
fn.apply(this, [opts, resources]);
}
}
} else {
// If the state of this component is controlled
// by Vuex, mutate the store when an action is clicked.
const opts = { alt: isAlternate(event) };
store.dispatch('action-menu/execute', {
action, args, opts
});
}
};
</script>
<template>
<rc-dropdown-menu
:button-role="buttonRole || 'link'"
:button-size="buttonSize || 'small'"
:button-aria-label="buttonAriaLabel"
:dropdown-aria-label="dropdownAriaLabel"
:options="options"
:data-testid="dataTestid"
@update:open="openChanged"
@select="(e: MouseEvent, option: object) => execute(option, e)"
/>
</template>

View File

@ -23,6 +23,7 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
import { getParent } from '@shell/utils/dom';
import { FORMATTERS } from '@shell/components/SortableTable/sortable-config';
import ButtonMultiAction from '@shell/components/ButtonMultiAction.vue';
import ActionMenu from '@shell/components/ActionMenuShell.vue';
// Uncomment for table performance debugging
// import tableDebug from './debug';
@ -58,6 +59,7 @@ export default {
ActionDropdown,
LabeledSelect,
ButtonMultiAction,
ActionMenu,
},
mixins: [
filtering,
@ -1468,23 +1470,16 @@ export default {
</template>
<td
v-if="rowActions"
align="middle"
>
<slot
name="row-actions"
:row="row.row"
:index="i"
>
<ButtonMultiAction
:id="`actionButton+${i}+${(row.row && row.row.name) ? row.row.name : ''}`"
:ref="`actionButton${i}`"
aria-haspopup="true"
aria-expanded="false"
:aria-label="t('sortableTable.tableActionsLabel', { resource: row?.row?.id || '' })"
<ActionMenu
:resource="row.row"
:data-testid="componentTestid + '-' + i + '-action-button'"
:borderless="true"
@click="handleActionButtonClick(i, $event)"
@keyup.enter="handleActionButtonClick(i, $event)"
@keyup.space="handleActionButtonClick(i, $event)"
:button-aria-label="t('sortableTable.tableActionsLabel', { resource: row?.row?.id || '' })"
/>
</slot>
</td>
@ -1782,7 +1777,6 @@ export default {
min-width: 400px;
border-radius: 5px 5px 0 0;
outline: 1px solid var(--border);
overflow: hidden;
background: var(--sortable-table-bg);
border-radius: 4px;

View File

@ -1,51 +1,22 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useStore } from 'vuex';
import {
RcDropdown,
RcDropdownItem,
RcDropdownSeparator,
RcDropdownTrigger
} from '@components/RcDropdown';
const isPageActionMenuOpen = ref(false);
const showPageActionsMenu = (show: boolean) => {
isPageActionMenuOpen.value = show;
};
import { RcDropdownMenu } from '@components/RcDropdown';
const store = useStore();
const pageActions = computed(() => store.getters.pageActions);
const pageAction = (action: string) => {
const pageAction = (_event: Event, action: string) => {
store.dispatch('handlePageAction', action);
showPageActionsMenu(false);
};
</script>
<template>
<rc-dropdown :aria-label="t('nav.actionMenu.label')">
<rc-dropdown-trigger
tertiary
data-testid="page-actions-menu"
:aria-label="t('nav.actionMenu.button.label')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<template
v-for="(a) in pageActions"
:key="a.label"
>
<rc-dropdown-item
v-if="!a.separator"
@click="pageAction(a)"
>
{{ a.labelKey ? t(a.labelKey) : a.label }}
</rc-dropdown-item>
<rc-dropdown-separator
v-else
/>
</template>
</template>
</rc-dropdown>
<rc-dropdown-menu
:options="pageActions"
:button-aria-label="t('nav.actionMenu.label')"
:dropdown-aria-label="t('nav.actionMenu.button.label')"
data-testid="page-actions-menu-action-button"
button-role="tertiary"
@select="pageAction"
/>
</template>

View File

@ -85,8 +85,8 @@ export default {
if (canSetAsHome) {
pageActions.push({
labelKey: 'nav.header.setLoginPage',
action: SET_LOGIN_ACTION
label: this.t('nav.header.setLoginPage'),
action: SET_LOGIN_ACTION
});
}

View File

@ -5,9 +5,12 @@ import { ALLOWED_SETTINGS } from '@shell/config/settings';
import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading';
import { VIEW_IN_API } from '@shell/store/prefs';
import ActionMenu from '@shell/components/ActionMenuShell.vue';
export default {
components: { Banner, Loading },
components: {
Banner, Loading, ActionMenu
},
async fetch() {
const viewInApi = this.$store.getters['prefs/get'](VIEW_IN_API);
@ -64,31 +67,8 @@ export default {
computed: {
...mapGetters({ t: 'i18n/t' }),
...mapGetters({
// Use either these Vuex getters
// OR the props to set the action menu state,
// but don't use both.
targetElem: 'action-menu/elem',
shouldShow: 'action-menu/showing',
}),
...mapGetters({ options: 'action-menu/optionsArray' }),
},
methods: {
toggleActionMenu(e, setting) {
const actionElement = e.srcElement;
if (!this.targetElem && !this.shouldShow) {
this.$store.commit(`action-menu/show`, {
resources: setting.data,
elem: actionElement
});
} else if (this.targetElem === actionElement && this.shouldShow) {
// this condition is needed so that we can "toggle" the action menu with
// the keyboard for accessibility (row action menu)
this.$store.commit('action-menu/hide');
}
},
}
};
</script>
@ -104,8 +84,8 @@ export default {
</div>
</Banner>
<div
v-for="(setting, i) in settings"
:key="i"
v-for="(setting) in settings"
:key="setting.id"
class="advanced-setting mb-20"
:data-testid="`advanced-setting__option-${setting.id}`"
>
@ -128,20 +108,12 @@ export default {
v-if="setting.hasActions"
class="action"
>
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-sm role-multi-action actions"
role="button"
:aria-label="t('advancedSettings.edit.moreActions', { setting: setting.id })"
@click="toggleActionMenu($event, setting)"
>
<i
class="icon icon-actions"
:alt="t('advancedSettings.edit.moreActions', { setting: setting.id })"
/>
</button>
<action-menu
:resource="setting.data"
:button-aria-label="t('advancedSettings.edit.label')"
data-testid="action-button"
button-role="tertiary"
/>
</div>
</div>
<div value>

View File

@ -53,17 +53,17 @@ export default defineComponent({
// Page actions don't change on the Home Page
pageActions: [
{
labelKey: 'nav.header.setLoginPage',
action: SET_LOGIN_ACTION
label: this.t('nav.header.setLoginPage'),
action: SET_LOGIN_ACTION
},
{ separator: true },
{ divider: true },
{
labelKey: 'nav.header.showHideBanner',
action: SHOW_HIDE_BANNER_ACTION
label: this.t('nav.header.showHideBanner'),
action: SHOW_HIDE_BANNER_ACTION
},
{
labelKey: 'nav.header.restoreCards',
action: RESET_CARDS_ACTION
label: this.t('nav.header.restoreCards'),
action: RESET_CARDS_ACTION
},
],
vendor: getVendor(),

View File

@ -27,7 +27,7 @@ export const getters = {
event: (state) => state.event,
resources: (state) => state.resources,
options(state) {
optionsArray(state) {
let selected = state.resources;
if ( !selected ) {
@ -50,7 +50,10 @@ export const getters = {
const out = _filter(map);
return { ...out };
return [...out];
},
options(_state, getters) {
return { ...getters.optionsArray };
},
};
@ -144,12 +147,19 @@ export const mutations = {
state.modalData = data;
},
SET_RESOURCE(state, resources) {
state.resources = !isArray(resources) ? [resources] : resources;
}
};
export const actions = {
execute({ state }, { action, args, opts }) {
return _execute(state.resources, action, args, opts);
},
setResource({ commit }, resource) {
commit('SET_RESOURCE', resource);
}
};
// -----------------------------

15
vue3JestRegisterTs.js Normal file
View File

@ -0,0 +1,15 @@
/**
* Unit tests are failing with the following error:
*
* ```
* [@vue/compiler-sfc] No fs option provided to `compileScript` in non-Node environment. File system access is required for resolving imported types.
* ```
*
* It seems TypeScript does not populate ts.sys when loaded in Jest. In order to
* resolve this issue, we can use the hack below to point to a different
* transformer than vue-jest and call registerTs before exporting vue-jest.
*
* SEE: https://github.com/vuejs/core/issues/8301
*/
require('@vue/compiler-sfc').registerTS(() => require('typescript'));
module.exports = require('@vue/vue3-jest');