mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
673 lines
16 KiB
Vue
673 lines
16 KiB
Vue
<script lang="ts">
|
|
export type TableHeader<T> = {
|
|
title?: string;
|
|
key?: DeepKeys<T> | string;
|
|
value?: DeepKeys<T> | AccessorFn<T>;
|
|
disableSort?: boolean;
|
|
minWidth?: number;
|
|
width?: number;
|
|
align?: 'end' | 'start' | 'center';
|
|
} & (
|
|
| { title: string; key?: never; value?: never } // Ensures an object with only `title` is valid
|
|
| { key: DeepKeys<T> }
|
|
| { value: DeepKeys<T>; key?: string }
|
|
| { key: string; value: AccessorFn<T> }
|
|
);
|
|
export type TableSortBy = SortingState;
|
|
</script>
|
|
|
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
|
import type {
|
|
AccessorFn,
|
|
Cell,
|
|
CellContext,
|
|
ColumnDef,
|
|
CoreColumn,
|
|
CoreOptions,
|
|
DeepKeys,
|
|
PaginationState,
|
|
Row,
|
|
RowSelectionState,
|
|
SortingState,
|
|
Updater,
|
|
} from '@tanstack/vue-table';
|
|
import { createColumnHelper, FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
|
|
import { ElCheckbox } from 'element-plus';
|
|
import { get } from 'lodash-es';
|
|
import { computed, h, ref, shallowRef, useSlots, watch } from 'vue';
|
|
|
|
import N8nPagination from '../N8nPagination';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
items: T[];
|
|
headers: Array<TableHeader<T>>;
|
|
itemsLength: number;
|
|
loading?: boolean;
|
|
multiSort?: boolean;
|
|
|
|
showSelect?: boolean;
|
|
/**
|
|
* For the selection feature to work, the data table must be able to differentiate each row in the data set. This is done using the item-value prop. It designates a property on the item that should contain a unique value. By default the property it looks for is id.
|
|
* You can also supply a function, if for example the unique value needs to be a composite of several properties. The function receives each item as its first argument
|
|
*/
|
|
itemValue?: CoreOptions<T>['getRowId'] | string;
|
|
returnObject?: boolean;
|
|
|
|
itemSelectable?: boolean | DeepKeys<T> | ((row: T) => boolean);
|
|
pageSizes?: number[];
|
|
}>(),
|
|
{
|
|
itemSelectable: undefined,
|
|
itemValue: 'id',
|
|
pageSizes: () => [10, 25, 50, 100],
|
|
},
|
|
);
|
|
|
|
const slots = useSlots();
|
|
defineSlots<{
|
|
[key: `item.${string}`]: (props: { value: unknown; item: T }) => void;
|
|
item: (props: { item: T; cells: Array<Cell<T, unknown>> }) => void;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
'update:options': [
|
|
payload: {
|
|
page: number;
|
|
itemsPerPage: number;
|
|
sortBy: Array<{ id: string; desc: boolean }>;
|
|
},
|
|
];
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
'click:row': [event: MouseEvent, payload: { item: T }];
|
|
}>();
|
|
|
|
const data = shallowRef<T[]>(props.items.concat());
|
|
watch(
|
|
() => props.items,
|
|
() => {
|
|
data.value = props.items.concat();
|
|
},
|
|
// the sync will NOT work without the deep watcher
|
|
{ deep: true },
|
|
);
|
|
|
|
function itemKeySlot(info: CellContext<T, unknown>) {
|
|
const slotName = `item.${info.column.id}` as const;
|
|
return slots[slotName]
|
|
? slots[slotName]({ item: info.row.original, value: info.getValue() })
|
|
: info.getValue();
|
|
}
|
|
|
|
function isValueAccessor(column: TableHeader<T>): column is Required<TableHeader<T>> {
|
|
return !!column.value;
|
|
}
|
|
|
|
function getHeaderTitle(column: TableHeader<T>) {
|
|
const value = typeof column.value === 'function' ? '' : column.value;
|
|
|
|
return column.title ?? column.key ?? value;
|
|
}
|
|
|
|
function isAccessorColumn(
|
|
column: TableHeader<T>,
|
|
): column is Omit<TableHeader<T>, 'key' | 'value'> & { key: string; value: AccessorFn<T> } {
|
|
return typeof column.value === 'function';
|
|
}
|
|
|
|
type ColumnMeta = {
|
|
cellProps: {
|
|
align?: 'end' | 'start' | 'center';
|
|
};
|
|
};
|
|
|
|
const getColumnMeta = (column: CoreColumn<T, unknown>) => {
|
|
return (column.columnDef.meta ?? {}) as ColumnMeta;
|
|
};
|
|
|
|
const MIN_COLUMN_WIDTH = 75;
|
|
|
|
function getValueAccessor(column: Required<TableHeader<T>>) {
|
|
if (isAccessorColumn(column)) {
|
|
return columnHelper.accessor(column.value, {
|
|
id: column.key,
|
|
cell: itemKeySlot,
|
|
header: () => getHeaderTitle(column),
|
|
enableSorting: !column.disableSort,
|
|
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
|
size: column.width,
|
|
meta: {
|
|
cellProps: {
|
|
align: column.align,
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
return columnHelper.accessor(column.value, {
|
|
id: column.key ?? column.value,
|
|
cell: itemKeySlot,
|
|
header: () => getHeaderTitle(column),
|
|
enableSorting: !column.disableSort,
|
|
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
|
size: column.width,
|
|
meta: {
|
|
cellProps: {
|
|
align: column.align,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function mapHeaders(columns: Array<TableHeader<T>>) {
|
|
return columns.map((column, index) => {
|
|
if (isValueAccessor(column)) {
|
|
return getValueAccessor(column);
|
|
}
|
|
|
|
if (column.key) {
|
|
const accessor = column.key;
|
|
//@ts-expect-error key is marked as a string
|
|
return columnHelper.accessor(column.key, {
|
|
id: accessor,
|
|
cell: itemKeySlot,
|
|
header: () => getHeaderTitle(column),
|
|
enableSorting: !column.disableSort,
|
|
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
|
size: column.width,
|
|
meta: {
|
|
cellProps: {
|
|
align: column.align,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
return columnHelper.display({
|
|
id: `display_column_${index}`,
|
|
header: () => getHeaderTitle(column),
|
|
size: column.width,
|
|
meta: {
|
|
cellProps: {
|
|
align: column.align,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
const columnsDefinition = computed(() => {
|
|
return [...(props.showSelect ? [selectColumn] : []), ...mapHeaders(props.headers)];
|
|
});
|
|
|
|
const page = defineModel<number>('page', { default: 0 });
|
|
watch(page, () => table.setPageIndex(page.value));
|
|
|
|
const itemsPerPage = defineModel<number>('items-per-page', { default: 10 });
|
|
watch(itemsPerPage, () => (page.value = 0));
|
|
|
|
const pagination = computed<PaginationState>({
|
|
get() {
|
|
return {
|
|
pageIndex: page.value,
|
|
pageSize: itemsPerPage.value,
|
|
};
|
|
},
|
|
set(newValue) {
|
|
page.value = newValue.pageIndex;
|
|
itemsPerPage.value = newValue.pageSize;
|
|
},
|
|
});
|
|
|
|
const showPagination = computed(() => props.itemsLength > Math.min(...props.pageSizes));
|
|
|
|
const sortBy = defineModel<SortingState>('sort-by', { default: [], required: false });
|
|
|
|
function handleSortingChange(updaterOrValue: Updater<SortingState>) {
|
|
sortBy.value =
|
|
typeof updaterOrValue === 'function' ? updaterOrValue(sortBy.value) : updaterOrValue;
|
|
|
|
emit('update:options', {
|
|
page: page.value,
|
|
itemsPerPage: itemsPerPage.value,
|
|
sortBy: sortBy.value,
|
|
});
|
|
}
|
|
|
|
const SELECT_COLUMN_ID = 'data-table-select';
|
|
const selectColumn: ColumnDef<T> = {
|
|
id: SELECT_COLUMN_ID,
|
|
enableResizing: false,
|
|
size: 38,
|
|
enablePinning: true,
|
|
header: ({ table }) => {
|
|
const checkboxRef = ref<typeof ElCheckbox>();
|
|
return h(ElCheckbox, {
|
|
ref: checkboxRef,
|
|
modelValue: table.getIsAllRowsSelected(),
|
|
indeterminate: table.getIsSomeRowsSelected(),
|
|
onChange: () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
const input = checkboxRef.value?.$el.getElementsByTagName('input')[0];
|
|
if (!input) return;
|
|
table.getToggleAllRowsSelectedHandler()?.({ target: input });
|
|
},
|
|
});
|
|
},
|
|
cell: ({ row }) => {
|
|
const checkboxRef = ref<typeof ElCheckbox>();
|
|
return h(ElCheckbox, {
|
|
ref: checkboxRef,
|
|
modelValue: row.getIsSelected(),
|
|
disabled: !row.getCanSelect(),
|
|
onChange: () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
const input = checkboxRef.value?.$el.getElementsByTagName('input')[0];
|
|
if (!input) return;
|
|
row.getToggleSelectedHandler()?.({ target: input });
|
|
},
|
|
});
|
|
},
|
|
meta: {
|
|
cellProps: {
|
|
align: undefined,
|
|
},
|
|
},
|
|
};
|
|
|
|
function getRowId(originalRow: T, index: number, parent?: Row<T>): string {
|
|
if (typeof props.itemValue === 'function') {
|
|
return props.itemValue(originalRow, index, parent);
|
|
}
|
|
|
|
return String(get(originalRow, props.itemValue));
|
|
}
|
|
|
|
function handleRowSelectionChange(updaterOrValue: Updater<RowSelectionState>) {
|
|
if (typeof updaterOrValue === 'function') {
|
|
rowSelection.value = updaterOrValue(rowSelection.value);
|
|
} else {
|
|
rowSelection.value = updaterOrValue;
|
|
}
|
|
|
|
if (props.returnObject) {
|
|
selection.value = Object.keys(rowSelection.value).map((id) => table.getRow(id).original);
|
|
} else {
|
|
selection.value = Object.keys(rowSelection.value);
|
|
}
|
|
}
|
|
|
|
const selection = defineModel<string[] | T[]>('selection');
|
|
const rowSelection = ref(
|
|
(selection.value ?? []).reduce<RowSelectionState>((acc, item, index) => {
|
|
const key = typeof item === 'string' ? item : getRowId(item, index);
|
|
acc[key] = true;
|
|
|
|
return acc;
|
|
}, {}),
|
|
);
|
|
|
|
const columnHelper = createColumnHelper<T>();
|
|
const table = useVueTable({
|
|
data,
|
|
columns: columnsDefinition.value,
|
|
get rowCount() {
|
|
return props.itemsLength;
|
|
},
|
|
state: {
|
|
get sorting() {
|
|
return sortBy.value;
|
|
},
|
|
get pagination() {
|
|
return pagination.value;
|
|
},
|
|
get rowSelection() {
|
|
return rowSelection.value;
|
|
},
|
|
},
|
|
getCoreRowModel: getCoreRowModel(),
|
|
onSortingChange: handleSortingChange,
|
|
onPaginationChange(updaterOrValue) {
|
|
pagination.value =
|
|
typeof updaterOrValue === 'function' ? updaterOrValue(pagination.value) : updaterOrValue;
|
|
|
|
emit('update:options', {
|
|
page: page.value,
|
|
itemsPerPage: itemsPerPage.value,
|
|
sortBy: sortBy.value,
|
|
});
|
|
},
|
|
manualSorting: true,
|
|
enableMultiSort: props.multiSort,
|
|
manualPagination: true,
|
|
columnResizeMode: 'onChange',
|
|
columnResizeDirection: 'ltr',
|
|
getRowId,
|
|
enableRowSelection: (row) => {
|
|
if (typeof props.itemSelectable === 'undefined') {
|
|
return true;
|
|
}
|
|
if (typeof props.itemSelectable === 'boolean') {
|
|
return props.itemSelectable;
|
|
}
|
|
|
|
if (typeof props.itemSelectable === 'function') {
|
|
return props.itemSelectable(row.original);
|
|
}
|
|
|
|
return Boolean(get(row.original, props.itemSelectable));
|
|
},
|
|
onRowSelectionChange: handleRowSelectionChange,
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="n8n-data-table-server-wrapper">
|
|
<div class="table-scroll">
|
|
<table
|
|
class="n8n-data-table-server"
|
|
:class="{ 'table--loading': loading }"
|
|
:style="{
|
|
width: `${table.getCenterTotalSize()}px`,
|
|
borderSpacing: 0,
|
|
minWidth: '100%',
|
|
tableLayout: 'fixed',
|
|
}"
|
|
>
|
|
<thead :style="{ position: 'sticky', top: 0, zIndex: 2 }" :class="{ loading }">
|
|
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
|
<template v-for="header in headerGroup.headers" :key="header.id">
|
|
<th
|
|
:style="{
|
|
cursor: header.column.getCanSort() ? 'pointer' : undefined,
|
|
width: `${header.getSize()}px`,
|
|
}"
|
|
@mousedown="header.column.getToggleSortingHandler()?.($event)"
|
|
>
|
|
<FlexRender
|
|
v-if="!header.isPlaceholder"
|
|
:render="header.column.columnDef.header"
|
|
:props="header.getContext()"
|
|
/>
|
|
|
|
<template v-if="header.column.getCanSort()">
|
|
{{ { asc: '↑', desc: '↓' }[header.column.getIsSorted() as string] }}
|
|
</template>
|
|
|
|
<div
|
|
v-if="header.column.getCanResize()"
|
|
:class="{ resizer: true, ['is-resizing']: header.column.getIsResizing() }"
|
|
@mousedown.stop="header.getResizeHandler()?.($event)"
|
|
@touchstart="header.getResizeHandler()?.($event)"
|
|
@dblclick="header.column.resetSize()"
|
|
></div>
|
|
</th>
|
|
</template>
|
|
</tr>
|
|
<tr v-if="loading">
|
|
<th :colspan="table.getVisibleFlatColumns().length" class="loading-row">
|
|
<div class="progress-bar">
|
|
<div class="progress-bar-value"></div>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-if="loading && !table.getRowModel().rows.length">
|
|
<tr v-for="item in itemsPerPage" :key="item">
|
|
<td
|
|
v-for="coll in table.getVisibleFlatColumns()"
|
|
:key="coll.id"
|
|
class="el-skeleton is-animated"
|
|
>
|
|
<el-skeleton-item />
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template v-else-if="table.getRowModel().rows.length">
|
|
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
|
<slot name="item" v-bind="{ item: row.original, cells: row.getVisibleCells() }">
|
|
<tr @click="emit('click:row', $event, { item: row.original })">
|
|
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
|
|
<td
|
|
:class="{
|
|
[`cell-align--${getColumnMeta(cell.column).cellProps.align}`]: Boolean(
|
|
getColumnMeta(cell.column).cellProps.align,
|
|
),
|
|
}"
|
|
>
|
|
<FlexRender
|
|
:render="cell.column.columnDef.cell"
|
|
:props="cell.getContext()"
|
|
/>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
</slot>
|
|
</template>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div v-if="showPagination" class="table-pagination" data-test-id="pagination">
|
|
<N8nPagination
|
|
:current-page="page + 1"
|
|
:page-size="itemsPerPage"
|
|
:page-sizes="pageSizes"
|
|
layout="prev, pager, next"
|
|
:total="itemsLength"
|
|
@update:current-page="page = $event - 1"
|
|
>
|
|
</N8nPagination>
|
|
<div class="table-pagination__sizes">
|
|
<div class="table-pagination__sizes__label">Page size</div>
|
|
<el-select
|
|
v-model.number="itemsPerPage"
|
|
class="table-pagination__sizes__select"
|
|
size="small"
|
|
:teleported="false"
|
|
>
|
|
<el-option v-for="item in pageSizes" :key="item" :label="item" :value="item" />
|
|
</el-select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.n8n-data-table-server {
|
|
height: 100%;
|
|
font-size: var(--font-size-s);
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
white-space: nowrap;
|
|
|
|
> thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
}
|
|
}
|
|
|
|
th {
|
|
position: relative;
|
|
text-align: left;
|
|
}
|
|
|
|
thead {
|
|
background-color: var(--color-background-light-base);
|
|
border-bottom: 1px solid var(--color-foreground-base);
|
|
}
|
|
|
|
th {
|
|
color: var(--color-text-base);
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
padding: 0 8px;
|
|
text-transform: capitalize;
|
|
height: 36px;
|
|
white-space: nowrap;
|
|
|
|
&:first-child {
|
|
padding-left: 16px;
|
|
}
|
|
&:last-child {
|
|
padding-right: 16px;
|
|
}
|
|
}
|
|
|
|
tbody > tr {
|
|
&:hover {
|
|
background-color: var(--color-background-light);
|
|
}
|
|
|
|
&:last-child > td {
|
|
border-bottom: 0;
|
|
}
|
|
}
|
|
|
|
tbody tr {
|
|
background-color: var(--color-background-xlight);
|
|
border-bottom: 1px solid var(--color-foreground-base);
|
|
}
|
|
|
|
td {
|
|
color: var(--color-text-dark);
|
|
padding: 0 8px;
|
|
height: 48px;
|
|
|
|
&:first-child {
|
|
padding-left: 16px;
|
|
}
|
|
&:last-child {
|
|
padding-right: 16px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.n8n-data-table-server-wrapper {
|
|
border-radius: 8px;
|
|
border: 1px solid var(--color-foreground-base);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-scroll {
|
|
max-height: 100%;
|
|
overflow: auto;
|
|
position: relative;
|
|
}
|
|
|
|
th.loading-row {
|
|
background-color: transparent;
|
|
padding: 0 !important;
|
|
border: 0 !important;
|
|
height: 0px;
|
|
position: relative;
|
|
}
|
|
|
|
.progress-bar {
|
|
position: absolute;
|
|
height: 2px;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar-value {
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: var(--color-primary);
|
|
animation: indeterminateAnimation 1s infinite linear;
|
|
transform-origin: 0% 50%;
|
|
position: absolute;
|
|
}
|
|
|
|
@keyframes indeterminateAnimation {
|
|
0% {
|
|
transform: translateX(0) scaleX(0);
|
|
}
|
|
40% {
|
|
transform: translateX(0) scaleX(0.4);
|
|
}
|
|
100% {
|
|
transform: translateX(100%) scaleX(0.5);
|
|
}
|
|
}
|
|
|
|
.table--loading {
|
|
td {
|
|
opacity: 0.38;
|
|
}
|
|
}
|
|
|
|
.table-pagination {
|
|
margin-top: 10px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
|
|
&__sizes {
|
|
display: flex;
|
|
|
|
&__label {
|
|
color: var(--color-text-base);
|
|
background-color: var(--color-background-light);
|
|
border: 1px solid var(--color-foreground-base);
|
|
border-right: 0;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 8px;
|
|
border-top-left-radius: var(--border-radius-base);
|
|
border-bottom-left-radius: var(--border-radius-base);
|
|
}
|
|
|
|
&__select {
|
|
--input-border-top-left-radius: 0;
|
|
--input-border-bottom-left-radius: 0;
|
|
width: 70px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.resizer {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
width: 3px;
|
|
background: var(--color-primary);
|
|
cursor: col-resize;
|
|
user-select: none;
|
|
touch-action: none;
|
|
right: -2px;
|
|
display: none;
|
|
z-index: 1;
|
|
&:hover {
|
|
display: block;
|
|
}
|
|
}
|
|
|
|
.resizer.is-resizing {
|
|
display: block;
|
|
}
|
|
|
|
th:hover:not(:last-child) > .resizer {
|
|
display: block;
|
|
}
|
|
|
|
.cell-align {
|
|
&--end {
|
|
text-align: end;
|
|
}
|
|
|
|
&--center {
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|