mirror of https://github.com/rancher/dashboard.git
665 lines
18 KiB
Vue
665 lines
18 KiB
Vue
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import { get } from '@shell/utils/object';
|
|
import { mapPref, GROUP_RESOURCES } from '@shell/store/prefs';
|
|
import ButtonGroup from '@shell/components/ButtonGroup';
|
|
import SortableTable from '@shell/components/SortableTable';
|
|
import { NAMESPACE, AGE } from '@shell/config/table-headers';
|
|
import { findBy } from '@shell/utils/array';
|
|
import { ExtensionPoint, TableColumnLocation } from '@shell/core/types';
|
|
import { getApplicableExtensionEnhancements } from '@shell/core/plugin-helpers';
|
|
|
|
// Default group-by in the case the group stored in the preference does not apply
|
|
const DEFAULT_GROUP = 'namespace';
|
|
|
|
export const defaultTableSortGenerationFn = (schema, $store) => {
|
|
if ( !schema ) {
|
|
return null;
|
|
}
|
|
|
|
const resource = schema.id;
|
|
let sortKey = resource;
|
|
|
|
const inStore = $store.getters['currentStore'](resource);
|
|
const generation = $store.getters[`${ inStore }/currentGeneration`]?.(resource);
|
|
|
|
if ( generation ) {
|
|
sortKey += `/${ generation }`;
|
|
}
|
|
|
|
const nsFilterKey = $store.getters['activeNamespaceCacheKey'];
|
|
|
|
if ( nsFilterKey ) {
|
|
return `${ sortKey }/${ nsFilterKey }`;
|
|
}
|
|
|
|
// covers case where we have no current cluster's ns cache
|
|
return sortKey;
|
|
};
|
|
|
|
export default {
|
|
|
|
name: 'ResourceTable',
|
|
|
|
emits: ['clickedActionButton'],
|
|
|
|
components: { ButtonGroup, SortableTable },
|
|
|
|
props: {
|
|
schema: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
|
|
rows: {
|
|
type: Array,
|
|
required: true
|
|
},
|
|
|
|
loading: {
|
|
type: Boolean,
|
|
required: false
|
|
},
|
|
|
|
altLoading: {
|
|
type: Boolean,
|
|
required: false
|
|
},
|
|
|
|
keyField: {
|
|
// Field that is unique for each row.
|
|
type: String,
|
|
default: '_key',
|
|
},
|
|
|
|
headers: {
|
|
type: Array,
|
|
default: null,
|
|
},
|
|
|
|
groupBy: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
|
|
namespaced: {
|
|
type: Boolean,
|
|
default: null, // Automatic from schema
|
|
},
|
|
|
|
search: {
|
|
// Show search input to filter rows
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
|
|
tableActions: {
|
|
// Show bulk table actions
|
|
type: [Boolean, null],
|
|
default: null
|
|
},
|
|
|
|
pagingLabel: {
|
|
type: String,
|
|
default: 'sortableTable.paging.resource',
|
|
},
|
|
|
|
/**
|
|
* Additional params to pass to the pagingLabel translation
|
|
*/
|
|
pagingParams: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
|
|
rowActions: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
|
|
groupable: {
|
|
type: Boolean,
|
|
default: null, // Null: auto based on namespaced and type custom groupings
|
|
},
|
|
|
|
groupTooltip: {
|
|
type: String,
|
|
default: 'resourceTable.groupBy.namespace',
|
|
},
|
|
|
|
overflowX: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
overflowY: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
sortGenerationFn: {
|
|
type: Function,
|
|
default: null,
|
|
},
|
|
getCustomDetailLink: {
|
|
type: Function,
|
|
default: null
|
|
},
|
|
ignoreFilter: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
hasAdvancedFiltering: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
advFilterHideLabelsAsCols: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
advFilterPreventFilteringLabels: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/**
|
|
* Allows for the usage of a query param to work for simple filtering (q)
|
|
*/
|
|
useQueryParamsForSimpleFiltering: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/**
|
|
* Manaul force the update of live and delayed cells. Change this number to kick off the update
|
|
*/
|
|
forceUpdateLiveAndDelayed: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
|
|
externalPaginationEnabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
|
|
externalPaginationResult: {
|
|
type: Object,
|
|
default: null
|
|
},
|
|
|
|
rowsPerPage: {
|
|
type: Number,
|
|
default: null, // Default comes from the user preference
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
/**
|
|
* v-shortkey prevents the event's propagation:
|
|
* https://github.com/fgr-araujo/vue-shortkey/blob/55d802ea305cadcc2ea970b55a3b8b86c7b44c05/src/index.js#L156-L157
|
|
*
|
|
* 'Enter' key press is handled via event listener in order to allow the event propagation
|
|
*/
|
|
window.addEventListener('keyup', this.handleEnterKeyPress);
|
|
},
|
|
|
|
beforeUnmount() {
|
|
window.removeEventListener('keyup', this.handleEnterKeyPress);
|
|
},
|
|
|
|
data() {
|
|
// Confirm which store we're in, if schema isn't available we're probably showing a list with different types
|
|
const inStore = this.schema?.id ? this.$store.getters['currentStore'](this.schema.id) : undefined;
|
|
|
|
return {
|
|
inStore,
|
|
/**
|
|
* Override the sortGenerationFn given changes in the rows we pass through to sortable table
|
|
*
|
|
* Primary purpose is to directly connect an iteration of `rows` with a sortGeneration string. This avoids
|
|
* reactivity issues where `rows` hasn't yet changed but something like workspaces has (stale values stored against fresh key)
|
|
*/
|
|
sortGeneration: undefined
|
|
};
|
|
},
|
|
|
|
watch: {
|
|
filteredRows: {
|
|
handler() {
|
|
// This is only prevalent in fleet world and the workspace switcher
|
|
// - it's singular (a --> b --> c) instead of namespace switchers additive (a --> a+b --> a)
|
|
// - this means it's much more likely to switch between resource sets containing the same mount of rows
|
|
//
|
|
if (this.currentProduct.showWorkspaceSwitcher) {
|
|
this.sortGeneration = this.safeSortGenerationFn(this.schema, this.$store);
|
|
}
|
|
},
|
|
immediate: true
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
options() {
|
|
return this.$store.getters[`type-map/optionsFor`](this.schema, this.externalPaginationEnabled);
|
|
},
|
|
|
|
_listGroupMapped() {
|
|
return this.options?.listGroups?.reduce((acc, grp) => {
|
|
acc[grp.value] = grp;
|
|
|
|
return acc;
|
|
}, {});
|
|
},
|
|
|
|
_mandatorySort() {
|
|
return this.options?.listMandatorySort;
|
|
},
|
|
|
|
...mapGetters(['currentProduct']),
|
|
|
|
isNamespaced() {
|
|
if ( this.namespaced !== null ) {
|
|
return this.namespaced;
|
|
}
|
|
|
|
return !!get( this.schema, 'attributes.namespaced');
|
|
},
|
|
|
|
showNamespaceColumn() {
|
|
const groupNamespaces = this.group === 'namespace';
|
|
const out = !this.showGrouping || !groupNamespaces;
|
|
|
|
return out;
|
|
},
|
|
|
|
_showBulkActions() {
|
|
if (this.tableActions !== null) {
|
|
return this.tableActions;
|
|
} else if (this.schema) {
|
|
const hideTableActions = this.$store.getters['type-map/hideBulkActionsFor'](this.schema);
|
|
|
|
return !hideTableActions;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_headers() {
|
|
let headers;
|
|
const showNamespace = this.showNamespaceColumn;
|
|
|
|
if ( this.headers ) {
|
|
headers = this.headers.slice();
|
|
} else {
|
|
headers = this.$store.getters['type-map/headersFor'](this.schema, this.externalPaginationEnabled);
|
|
}
|
|
|
|
// add custom table columns provided by the extensions ExtensionPoint.TABLE_COL hook
|
|
// gate it so that we prevent errors on older versions of dashboard
|
|
if (this.$store.$plugin?.getUIConfig) {
|
|
const extensionCols = getApplicableExtensionEnhancements(this, ExtensionPoint.TABLE_COL, TableColumnLocation.RESOURCE, this.$route);
|
|
|
|
// Try and insert the columns before the Age column
|
|
let insertPosition = headers.length;
|
|
|
|
if (headers.length > 0) {
|
|
const ageColIndex = headers.findIndex((h) => h.name === AGE.name);
|
|
|
|
if (ageColIndex >= 0) {
|
|
insertPosition = ageColIndex;
|
|
} else {
|
|
// we've found some labels with ' ', which isn't necessarily empty (explore action/button)
|
|
// if we are to add cols, let's push them before these so that the UI doesn't look weird
|
|
const lastViableColIndex = headers.findIndex((h) => (!h.label || !h.label?.trim()) && (!h.labelKey || !h.labelKey?.trim()));
|
|
|
|
if (lastViableColIndex >= 0) {
|
|
insertPosition = lastViableColIndex;
|
|
}
|
|
}
|
|
}
|
|
|
|
// adding extension defined cols to the correct header config
|
|
extensionCols.forEach((col) => {
|
|
// we need the 'value' prop to be populated in order for the rows to show the values
|
|
if (!col.value && col.getValue) {
|
|
col.value = col.getValue;
|
|
}
|
|
headers.splice(insertPosition, 0, col);
|
|
});
|
|
}
|
|
|
|
// If only one namespace is selected, hide the namespace column
|
|
if ( !showNamespace ) {
|
|
const idx = headers.findIndex((header) => header.name === NAMESPACE.name);
|
|
|
|
if ( idx >= 0 ) {
|
|
headers.splice(idx, 1);
|
|
}
|
|
}
|
|
|
|
// If we are grouping by a custom group, it may specify that we hide a specific column
|
|
const custom = this._listGroupMapped?.[this.group];
|
|
|
|
if (custom?.hideColumn) {
|
|
const idx = headers.findIndex((header) => header.name === custom.hideColumn);
|
|
|
|
if ( idx >= 0 ) {
|
|
headers.splice(idx, 1);
|
|
}
|
|
}
|
|
|
|
return headers;
|
|
},
|
|
|
|
/**
|
|
* Take rows and filter out entries given the namespace filter
|
|
*/
|
|
filteredRows() {
|
|
const isAll = this.$store.getters['isAllNamespaces'];
|
|
|
|
// Do we need to filter by namespace like things?
|
|
if (
|
|
!this.isNamespaced || // Resource type isn't namespaced
|
|
this.ignoreFilter || // Component owner strictly states no filtering
|
|
this.externalPaginationEnabled ||
|
|
(isAll && !this.currentProduct?.hideSystemResources) || // Need all
|
|
(this.inStore ? this.$store.getters[`${ this.inStore }/haveNamespace`](this.schema.id)?.length : false)// Store reports type has namespace filter, so rows already contain the correctly filtered resources
|
|
) {
|
|
return this.rows || [];
|
|
}
|
|
|
|
const includedNamespaces = this.$store.getters['namespaces']();
|
|
|
|
// Shouldn't happen, but does for resources like management.cattle.io.preference
|
|
if (!this.rows) {
|
|
return [];
|
|
}
|
|
|
|
const haveAllNamespace = this.$store.getters['haveAllNamespace'];
|
|
|
|
return this.rows.filter((row) => {
|
|
if (this.currentProduct?.hideSystemResources && this.isNamespaced) {
|
|
return !!includedNamespaces[row.metadata.namespace] && !row.isSystemResource;
|
|
} else if (!this.isNamespaced) {
|
|
return true;
|
|
} else if (haveAllNamespace) {
|
|
// `rows` only contains resource from a single namespace
|
|
return true;
|
|
} else {
|
|
return !!includedNamespaces[row.metadata.namespace];
|
|
}
|
|
});
|
|
},
|
|
|
|
_group: mapPref(GROUP_RESOURCES),
|
|
|
|
// The group stored in the preference (above) might not be valid for this resource table - so ensure we
|
|
// choose a group that is applicable (the default)
|
|
// This saves us from having to store a group preference per resource type - given that custom groupings aer not used much
|
|
// and it feels like a good UX to be able to keep the namespace/flat grouping across tables
|
|
group: {
|
|
get() {
|
|
// Check group is valid
|
|
const exists = this.groupOptions.find((g) => g.value === this._group);
|
|
|
|
if (!exists) {
|
|
// Attempt to find the default option in available options...
|
|
// if not use the first value in the options collection...
|
|
// and if not that just fall back to the default
|
|
if (this.groupOptions.find((g) => g.value === DEFAULT_GROUP)) {
|
|
return DEFAULT_GROUP;
|
|
}
|
|
|
|
return this.groupOptions[0]?.value || DEFAULT_GROUP;
|
|
}
|
|
|
|
return this._group;
|
|
},
|
|
set(value) {
|
|
this._group = value;
|
|
}
|
|
},
|
|
|
|
showGrouping() {
|
|
if ( this.groupable === null ) {
|
|
const namespaceGroupable = this.$store.getters['isMultipleNamespaces'] && this.isNamespaced;
|
|
const customGroupable = !!this.options?.listGroups?.length;
|
|
|
|
return namespaceGroupable || customGroupable;
|
|
}
|
|
|
|
return this.groupable || false;
|
|
},
|
|
|
|
computedGroupBy() {
|
|
if ( this.groupBy ) {
|
|
// This probably comes from the type-map config for the resource (see ResourceList)
|
|
return this.groupBy;
|
|
}
|
|
|
|
if ( this.group === 'namespace' && this.showGrouping ) {
|
|
// This switches to group rows by a key which is the label for the group (??)
|
|
return 'groupByLabel';
|
|
}
|
|
|
|
const custom = this._listGroupMapped?.[this.group];
|
|
|
|
if (custom?.field) {
|
|
// Override the normal filtering
|
|
return custom.field;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
groupOptions() {
|
|
// Ignore the defaults below, we have an override set of groups
|
|
// REPLACE (instead of SUPPLEMENT) defaults with listGroups (given listGroupsWillOverride is true)
|
|
if (this.options?.listGroupsWillOverride && !!this.options?.listGroups?.length) {
|
|
return this.options?.listGroups;
|
|
}
|
|
|
|
const standard = [
|
|
{
|
|
tooltipKey: 'resourceTable.groupBy.none',
|
|
icon: 'icon-list-flat',
|
|
value: 'none',
|
|
}
|
|
];
|
|
|
|
if (!this.options?.hiddenNamespaceGroupButton) {
|
|
standard.push( {
|
|
tooltipKey: this.groupTooltip,
|
|
icon: 'icon-folder',
|
|
value: 'namespace',
|
|
});
|
|
}
|
|
|
|
// SUPPLEMENT (instead of REPLACE) defaults with listGroups (given listGroupsWillOverride is false)
|
|
if (!!this.options?.listGroups?.length) {
|
|
return standard.concat(this.options.listGroups);
|
|
}
|
|
|
|
return standard;
|
|
},
|
|
|
|
parsedPagingParams() {
|
|
if (this.pagingParams) {
|
|
return this.pagingParams;
|
|
}
|
|
|
|
if ( !this.schema ) {
|
|
return {
|
|
singularLabel: '',
|
|
pluralLabel: ''
|
|
};
|
|
}
|
|
|
|
return {
|
|
singularLabel: this.$store.getters['type-map/labelFor'](this.schema),
|
|
pluralLabel: this.$store.getters['type-map/labelFor'](this.schema, 99),
|
|
};
|
|
},
|
|
|
|
},
|
|
|
|
methods: {
|
|
keyAction(action) {
|
|
const table = this.$refs.table;
|
|
|
|
if ( !table ) {
|
|
return;
|
|
}
|
|
|
|
const selection = table.selectedRows;
|
|
|
|
if ( action === 'remove' ) {
|
|
const act = findBy(table.availableActions, 'action', 'promptRemove');
|
|
|
|
if ( act ) {
|
|
table.setBulkActionOfInterest(act);
|
|
table.applyTableAction(act);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ( selection.length !== 1 ) {
|
|
return;
|
|
}
|
|
|
|
switch ( action ) {
|
|
case 'detail':
|
|
selection[0].goToDetail();
|
|
break;
|
|
case 'edit':
|
|
selection[0].goToEdit();
|
|
break;
|
|
case 'yaml':
|
|
selection[0].goToViewYaml();
|
|
break;
|
|
}
|
|
},
|
|
|
|
clearSelection() {
|
|
this.$refs.table.clearSelection();
|
|
},
|
|
|
|
safeSortGenerationFn() {
|
|
if (this.sortGenerationFn) {
|
|
return this.sortGenerationFn(this.schema, this.$store);
|
|
}
|
|
|
|
return defaultTableSortGenerationFn(this.schema, this.$store);
|
|
},
|
|
|
|
handleActionButtonClick(event) {
|
|
this.$emit('clickedActionButton', event);
|
|
},
|
|
|
|
handleEnterKeyPress(event) {
|
|
if (event.key === 'Enter') {
|
|
this.keyAction('detail');
|
|
}
|
|
}
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<SortableTable
|
|
ref="table"
|
|
v-bind="$attrs"
|
|
:headers="_headers"
|
|
:rows="filteredRows"
|
|
:loading="loading"
|
|
:alt-loading="altLoading"
|
|
:group-by="computedGroupBy"
|
|
:group="group"
|
|
:group-options="groupOptions"
|
|
:search="search"
|
|
:paging="true"
|
|
:paging-params="parsedPagingParams"
|
|
:paging-label="pagingLabel"
|
|
:rows-per-page="rowsPerPage"
|
|
:row-actions="rowActions"
|
|
:table-actions="_showBulkActions"
|
|
:overflow-x="overflowX"
|
|
:overflow-y="overflowY"
|
|
:get-custom-detail-link="getCustomDetailLink"
|
|
:has-advanced-filtering="hasAdvancedFiltering"
|
|
:adv-filter-hide-labels-as-cols="advFilterHideLabelsAsCols"
|
|
:adv-filter-prevent-filtering-labels="advFilterPreventFilteringLabels"
|
|
:key-field="keyField"
|
|
:sortGeneration="sortGeneration"
|
|
:sort-generation-fn="safeSortGenerationFn"
|
|
:use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
|
|
:force-update-live-and-delayed="forceUpdateLiveAndDelayed"
|
|
:external-pagination-enabled="externalPaginationEnabled"
|
|
:external-pagination-result="externalPaginationResult"
|
|
:mandatory-sort="_mandatorySort"
|
|
@clickedActionButton="handleActionButtonClick"
|
|
@group-value-change="group = $event"
|
|
>
|
|
<template
|
|
v-if="showGrouping"
|
|
#header-middle
|
|
>
|
|
<slot name="more-header-middle" />
|
|
|
|
<ButtonGroup
|
|
v-model:value="group"
|
|
:options="groupOptions"
|
|
/>
|
|
</template>
|
|
|
|
<template
|
|
v-if="showGrouping"
|
|
#header-right
|
|
>
|
|
<slot name="header-right" />
|
|
</template>
|
|
|
|
<template #group-by="{group: thisGroup}">
|
|
<div
|
|
v-clean-html="thisGroup.ref"
|
|
class="group-tab"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Pass down templates provided by the caller -->
|
|
<template
|
|
v-for="(_, slot) of $slots"
|
|
:key="slot"
|
|
v-slot:[slot]="scope"
|
|
>
|
|
<slot
|
|
:name="slot"
|
|
v-bind="scope"
|
|
/>
|
|
</template>
|
|
|
|
<template #shortkeys>
|
|
<button
|
|
v-shortkey.once="['e']"
|
|
class="hide"
|
|
@shortkey="keyAction('edit')"
|
|
/>
|
|
<button
|
|
v-shortkey.once="['y']"
|
|
class="hide"
|
|
@shortkey="keyAction('yaml')"
|
|
/>
|
|
<button
|
|
v-if="_showBulkActions"
|
|
v-shortkey.once="['del']"
|
|
class="hide"
|
|
@shortkey="keyAction('remove')"
|
|
/>
|
|
<button
|
|
v-if="_showBulkActions"
|
|
v-shortkey.once="['backspace']"
|
|
class="hide"
|
|
@shortkey="keyAction('remove')"
|
|
/>
|
|
</template>
|
|
</SortableTable>
|
|
</template>
|