mirror of
https://github.com/crawlab-team/crawlab.git
synced 2026-01-30 18:00:56 +01:00
updated frontend
This commit is contained in:
262
frontend/src/components/table/Table.vue
Normal file
262
frontend/src/components/table/Table.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div ref="tableWrapperRef" class="table">
|
||||
<!-- Table Body -->
|
||||
<el-table
|
||||
v-if="selectedColumns.length > 0"
|
||||
ref="tableRef"
|
||||
:data="tableData"
|
||||
:fit="false"
|
||||
:row-key="rowKey"
|
||||
border
|
||||
size="small"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
v-if="selectable"
|
||||
align="center"
|
||||
reserve-selection
|
||||
type="selection"
|
||||
width="40"
|
||||
fixed="left"
|
||||
:selectable="selectableFunction"
|
||||
/>
|
||||
<el-table-column
|
||||
v-for="c in selectedColumns"
|
||||
:key="c.key"
|
||||
:column-key="c.key"
|
||||
:align="c.align"
|
||||
:fixed="c.fixed ? c.fixed : false"
|
||||
:label="c.label"
|
||||
:width="c.width"
|
||||
:min-width="c.minWidth || c.width"
|
||||
:sortable="c.sortable"
|
||||
:index="c.index"
|
||||
:resizable="c.resizable === undefined ? true : c.resizable"
|
||||
:class-name="c.className"
|
||||
>
|
||||
<template #header="scope">
|
||||
<TableHeader :column="c" :index="scope.$index" @change="onHeaderChange"/>
|
||||
</template>
|
||||
<template #default="scope">
|
||||
<TableCell :column="c" :row="scope.row" :row-index="scope.$index"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- ./Table Body-->
|
||||
|
||||
<!-- Table Footer-->
|
||||
<div v-if="!hideFooter" class="table-footer">
|
||||
<TableActions
|
||||
:selection="internalSelection"
|
||||
@delete="onDelete"
|
||||
@edit="onEdit"
|
||||
@export="onExport"
|
||||
@customize-columns="onShowColumnsTransfer"
|
||||
>
|
||||
<template #prefix>
|
||||
<slot name="actions-prefix"></slot>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="actions-suffix"></slot>
|
||||
</template>
|
||||
</TableActions>
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
class="pagination"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="onCurrentChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- ./Table Footer-->
|
||||
|
||||
<!-- Table Columns Transfer -->
|
||||
<TableColumnsTransfer
|
||||
:columns="columns"
|
||||
:selected-column-keys="internalSelectedColumnKeys"
|
||||
:visible="columnsTransferVisible"
|
||||
@apply="onColumnsChange"
|
||||
@close="onHideColumnsTransfer"
|
||||
/>
|
||||
<!-- ./Table Columns Transfer -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, inject, PropType, ref, SetupContext} from 'vue';
|
||||
import {Table} from 'element-plus/lib/el-table/src/table.type';
|
||||
import TableCell from '@/components/table/TableCell.vue';
|
||||
import TableHeader from '@/components/table/TableHeader.vue';
|
||||
import TableColumnsTransfer from '@/components/table/TableColumnsTransfer.vue';
|
||||
import useColumn from '@/components/table/column';
|
||||
import useHeader from '@/components/table/header';
|
||||
import useData from '@/components/table/data';
|
||||
import TableActions from '@/components/table/TableActions.vue';
|
||||
import useAction from '@/components/table/action';
|
||||
import usePagination from '@/components/table/pagination';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Table',
|
||||
components: {
|
||||
TableActions,
|
||||
TableColumnsTransfer,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: Array as PropType<TableData>,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
type: Array as PropType<TableColumn[]>,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
selectedColumnKeys: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: '_id',
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
visibleButtons: {
|
||||
type: Array as PropType<BuiltInTableActionButtonName[]>,
|
||||
required: false,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
hideFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectableFunction: {
|
||||
type: Function as PropType<TableSelectableFunction>,
|
||||
default: () => true,
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'edit',
|
||||
'delete',
|
||||
'export',
|
||||
'header-change',
|
||||
'pagination-change',
|
||||
'selection-change',
|
||||
],
|
||||
setup(props: TableProps, ctx: SetupContext) {
|
||||
const tableWrapperRef = ref();
|
||||
const tableRef = ref<Table>();
|
||||
|
||||
const {
|
||||
tableData,
|
||||
} = useData(props, ctx);
|
||||
|
||||
const {
|
||||
internalSelectedColumnKeys,
|
||||
columnsTransferVisible,
|
||||
selectedColumns,
|
||||
onShowColumnsTransfer,
|
||||
onHideColumnsTransfer,
|
||||
onColumnsChange,
|
||||
} = useColumn(props, ctx, tableRef, tableWrapperRef);
|
||||
|
||||
const {
|
||||
onHeaderChange,
|
||||
} = useHeader(props, ctx);
|
||||
|
||||
// inject action functions
|
||||
const actionFunctions = inject<ListLayoutActionFunctions>('action-functions');
|
||||
|
||||
const {
|
||||
selection: internalSelection,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onExport,
|
||||
} = useAction(props, ctx, tableRef, actionFunctions as ListLayoutActionFunctions);
|
||||
|
||||
const {
|
||||
onCurrentChange,
|
||||
onSizeChange,
|
||||
} = usePagination(props, ctx);
|
||||
|
||||
return {
|
||||
tableWrapperRef,
|
||||
tableRef,
|
||||
tableData,
|
||||
internalSelectedColumnKeys,
|
||||
columnsTransferVisible,
|
||||
selectedColumns,
|
||||
onHeaderChange,
|
||||
onShowColumnsTransfer,
|
||||
onHideColumnsTransfer,
|
||||
onColumnsChange,
|
||||
onExport,
|
||||
internalSelection,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCurrentChange,
|
||||
onSizeChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.table {
|
||||
background-color: $containerWhiteBg;
|
||||
|
||||
.el-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
|
||||
.pagination {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.el-table >>> th > .cell {
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
}
|
||||
</style>
|
||||
135
frontend/src/components/table/TableActions.vue
Normal file
135
frontend/src/components/table/TableActions.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="table-actions">
|
||||
<slot name="prefix"></slot>
|
||||
<!-- <FaIconButton-->
|
||||
<!-- v-if="showButton(ACTION_ADD)"-->
|
||||
<!-- :icon="['fa', 'plus']"-->
|
||||
<!-- class="action-btn"-->
|
||||
<!-- size="mini"-->
|
||||
<!-- tooltip="Add"-->
|
||||
<!-- type="success"-->
|
||||
<!-- :disabled="selection.length === 0"-->
|
||||
<!-- @click="onAdd"-->
|
||||
<!-- />-->
|
||||
<FaIconButton
|
||||
v-if="showButton(ACTION_EDIT)"
|
||||
:disabled="selection.length === 0"
|
||||
:icon="['fa', 'edit']"
|
||||
class="action-btn"
|
||||
size="mini"
|
||||
tooltip="Edit Selected"
|
||||
type="warning"
|
||||
@click="onEdit"
|
||||
/>
|
||||
<FaIconButton
|
||||
v-if="showButton(ACTION_DELETE)"
|
||||
:disabled="selection.length === 0"
|
||||
:icon="['fa', 'trash-alt']"
|
||||
class="action-btn"
|
||||
size="mini"
|
||||
tooltip="Delete Selected"
|
||||
type="danger"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<FaIconButton
|
||||
v-if="showButton(TABLE_ACTION_EXPORT)"
|
||||
:icon="['fa', 'file-export']"
|
||||
class="action-btn"
|
||||
size="mini"
|
||||
tooltip="Export"
|
||||
type="primary"
|
||||
@click="onExport"
|
||||
/>
|
||||
<FaIconButton
|
||||
v-if="showButton(TABLE_ACTION_CUSTOMIZE_COLUMNS)"
|
||||
:icon="['fa', 'arrows-alt']"
|
||||
class="action-btn"
|
||||
size="mini"
|
||||
tooltip="Customize Columns"
|
||||
type="primary"
|
||||
@click="onCustomizeColumns"
|
||||
/>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from 'vue';
|
||||
import FaIconButton from '@/components/button/FaIconButton.vue';
|
||||
import {ACTION_ADD, ACTION_DELETE, ACTION_EDIT,} from '@/constants/action';
|
||||
import {TABLE_ACTION_CUSTOMIZE_COLUMNS, TABLE_ACTION_EXPORT,} from '@/constants/table';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableActions',
|
||||
components: {
|
||||
FaIconButton,
|
||||
},
|
||||
emits: [
|
||||
'edit',
|
||||
'delete',
|
||||
'export',
|
||||
'customize-columns'
|
||||
],
|
||||
props: {
|
||||
selection: {
|
||||
type: Array as PropType<TableData>,
|
||||
required: false,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
visibleButtons: {
|
||||
type: Array as PropType<BuiltInTableActionButtonName[]>,
|
||||
required: false,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
setup(props: TableActionsProps, {emit}) {
|
||||
// const onAdd = () => {
|
||||
// emit('click-add');
|
||||
// };
|
||||
|
||||
const onEdit = () => {
|
||||
emit('edit');
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
emit('export');
|
||||
};
|
||||
|
||||
const onCustomizeColumns = () => {
|
||||
emit('customize-columns');
|
||||
};
|
||||
|
||||
const showButton = (name: string): boolean => {
|
||||
const {visibleButtons} = props;
|
||||
if (visibleButtons.length === 0) return true;
|
||||
return visibleButtons.includes(name);
|
||||
};
|
||||
|
||||
return {
|
||||
ACTION_ADD,
|
||||
ACTION_EDIT,
|
||||
ACTION_DELETE,
|
||||
TABLE_ACTION_EXPORT,
|
||||
TABLE_ACTION_CUSTOMIZE_COLUMNS,
|
||||
// onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onExport,
|
||||
onCustomizeColumns,
|
||||
showButton,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
76
frontend/src/components/table/TableCell.vue
Normal file
76
frontend/src/components/table/TableCell.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, h} from 'vue';
|
||||
import FaIconButton from '@/components/button/FaIconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableCell',
|
||||
props: {
|
||||
column: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
rowIndex: {
|
||||
type: Number,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup: function (props) {
|
||||
const getChildren = () => {
|
||||
const {row, column, rowIndex} = props as TableCellProps;
|
||||
const {value, buttons} = column;
|
||||
|
||||
// value
|
||||
if (value !== undefined) {
|
||||
if (typeof value === 'function') {
|
||||
return [value(row, rowIndex, column)];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// buttons
|
||||
if (buttons) {
|
||||
let _buttons: TableColumnButton[] = [];
|
||||
if (typeof buttons === 'function') {
|
||||
_buttons = buttons(row);
|
||||
} else if (Array.isArray(buttons) && buttons.length > 0) {
|
||||
_buttons = buttons;
|
||||
}
|
||||
|
||||
return _buttons.map(btn => {
|
||||
const {tooltip, type, size, icon, disabled, onClick} = btn;
|
||||
const props = {
|
||||
key: JSON.stringify({tooltip, type, size, icon}),
|
||||
tooltip: typeof tooltip === 'function' ? tooltip(row) : tooltip,
|
||||
type,
|
||||
size,
|
||||
icon,
|
||||
disabled: disabled?.(row),
|
||||
onClick: () => {
|
||||
onClick?.(row, rowIndex, column);
|
||||
},
|
||||
};
|
||||
// FIXME: use "as any" to fix type errors temporarily
|
||||
return h(FaIconButton, props as any);
|
||||
});
|
||||
}
|
||||
|
||||
// plain text
|
||||
return [row[column.key]];
|
||||
};
|
||||
|
||||
return () => h('div', getChildren());
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
103
frontend/src/components/table/TableColumnsTransfer.vue
Normal file
103
frontend/src/components/table/TableColumnsTransfer.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:before-close="onClose"
|
||||
:model-value="visible"
|
||||
title="Table Columns Customization">
|
||||
<div class="table-columns-transfer-content">
|
||||
<Transfer
|
||||
:data="computedData"
|
||||
:titles="['Available', 'Selected']"
|
||||
:value="internalSelectedColumnKeys"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button plain size="mini" type="info" @click="onClose">Cancel</Button>
|
||||
<Button size="mini" @click="onApply">Apply</Button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onBeforeMount, ref} from 'vue';
|
||||
import Transfer from '@/components/transfer/Transfer.vue';
|
||||
import Button from '@/components/button/Button.vue';
|
||||
import {DataItem} from 'element-plus/lib/el-transfer/src/transfer';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableColumnsTransfer',
|
||||
components: {Button, Transfer},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
selectedColumnKeys: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'close',
|
||||
'change',
|
||||
'sort',
|
||||
'apply',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const internalSelectedColumnKeys = ref<string[]>([]);
|
||||
|
||||
const computedData = computed<DataItem[]>(() => {
|
||||
const {columns} = props as TableColumnsTransferProps;
|
||||
return columns.map(d => {
|
||||
const {key, label, disableTransfer} = d;
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
disabled: disableTransfer || false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onChange = (value: string[]) => {
|
||||
internalSelectedColumnKeys.value = value;
|
||||
};
|
||||
|
||||
const onApply = () => {
|
||||
emit('apply', internalSelectedColumnKeys.value);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
const {selectedColumnKeys} = props as TableColumnsTransferProps;
|
||||
internalSelectedColumnKeys.value = selectedColumnKeys || [];
|
||||
});
|
||||
|
||||
return {
|
||||
internalSelectedColumnKeys,
|
||||
computedData,
|
||||
onClose,
|
||||
onChange,
|
||||
onApply,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table-columns-transfer-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
284
frontend/src/components/table/TableHeader.vue
Normal file
284
frontend/src/components/table/TableHeader.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="table-header">
|
||||
<span :class="[column.required ? 'required' : '']" class="label">
|
||||
<span v-if="column.icon" class="label-icon">
|
||||
<Icon :icon="column.icon"/>
|
||||
</span>
|
||||
{{ column.label }}
|
||||
</span>
|
||||
|
||||
<TableHeaderDialog
|
||||
v-if="hasDialog"
|
||||
:action-status-map="actionStatusMap"
|
||||
:column="column"
|
||||
:visible="dialogVisible"
|
||||
:filter="filterData"
|
||||
:sort="sortData"
|
||||
@apply="onDialogApply"
|
||||
@clear="onDialogClear"
|
||||
@cancel="onDialogCancel"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="actions">
|
||||
<TableHeaderAction
|
||||
v-for="{key, tooltip, isHtml, icon, onClick} in actions"
|
||||
:key="key + JSON.stringify(icon)"
|
||||
:icon="icon"
|
||||
:status="actionStatusMap[key]"
|
||||
:tooltip="tooltip"
|
||||
:is-html="isHtml"
|
||||
@click="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TableHeaderDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType, reactive, ref} from 'vue';
|
||||
import TableHeaderDialog from '@/components/table/TableHeaderDialog.vue';
|
||||
import TableHeaderAction from '@/components/table/TableHeaderAction.vue';
|
||||
import {conditionTypesMap} from '@/components/filter/FilterCondition.vue';
|
||||
import {ASCENDING, DESCENDING} from '@/constants/sort';
|
||||
import variables from '@/styles/variables.scss';
|
||||
import {FILTER_OP_NOT_SET} from '@/constants/filter';
|
||||
import Icon from '@/components/icon/Icon.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableHeader',
|
||||
components: {
|
||||
Icon,
|
||||
TableHeaderAction,
|
||||
TableHeaderDialog,
|
||||
},
|
||||
props: {
|
||||
column: {
|
||||
type: Object as PropType<TableColumn>,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'change',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const dialogVisible = ref<boolean>(false);
|
||||
|
||||
const actionStatusMap = reactive<TableHeaderActionStatusMap>({
|
||||
filter: {active: false, focused: false},
|
||||
sort: {active: false, focused: false},
|
||||
});
|
||||
|
||||
const sortData = ref<SortData>();
|
||||
const filterData = ref<TableHeaderDialogFilterData>();
|
||||
|
||||
const filterItemsMap = computed<Map<any, string | undefined>>(() => {
|
||||
const map = new Map<any, string | undefined>();
|
||||
const {column} = props;
|
||||
const {filterItems} = column;
|
||||
if (!filterItems) return map;
|
||||
filterItems.forEach(d => {
|
||||
const {label, value} = d;
|
||||
map.set(value, label);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const actions = computed<TableColumnButton[]>(() => {
|
||||
const {column} = props;
|
||||
|
||||
// sort icon and tooltip
|
||||
let sortIcon = ['fa', 'sort-amount-down-alt'];
|
||||
let sortTooltip = 'Sort';
|
||||
if (sortData.value?.d === ASCENDING) {
|
||||
sortIcon = ['fa', 'sort-amount-up'];
|
||||
sortTooltip = 'Sorted Ascending';
|
||||
} else if (sortData.value?.d === DESCENDING) {
|
||||
sortIcon = ['fa', 'sort-amount-down-alt'];
|
||||
sortTooltip = 'Sorted Descending';
|
||||
}
|
||||
|
||||
// filter tooltip
|
||||
let filterTooltip = 'Filter';
|
||||
let filterIsHtml = false;
|
||||
if (filterData.value) {
|
||||
const {searchString, conditions, items} = filterData.value;
|
||||
|
||||
// search string
|
||||
if (searchString) {
|
||||
filterTooltip += `<br><span style="color: ${variables.primaryColor}">Search:</span> <span style="color: ${variables.warningColor};">"${searchString}"</span>`;
|
||||
filterIsHtml = true;
|
||||
}
|
||||
|
||||
// filter conditions
|
||||
if (conditions && conditions.length > 0) {
|
||||
filterTooltip += '<br>' + conditions.filter(d => d.op !== FILTER_OP_NOT_SET)
|
||||
.map(d => `<span style="color: ${variables.primaryColor};margin-right: 5px">${conditionTypesMap[d.op || '']}:</span> <span style="color: ${variables.warningColor};">"${d.value}"</span>`)
|
||||
.join('<br>');
|
||||
filterIsHtml = true;
|
||||
}
|
||||
|
||||
// filter items
|
||||
if (items && items.length > 0) {
|
||||
const itemsStr = items.map(value => filterItemsMap.value.get(value)).join(', ');
|
||||
filterTooltip += `<br><span style="color: ${variables.primaryColor};margin-right: 5px">Include:</span><span style="color: ${variables.warningColor}">` + itemsStr + '</span>';
|
||||
filterIsHtml = true;
|
||||
}
|
||||
}
|
||||
|
||||
// tooltip items
|
||||
const items = [];
|
||||
if (column.hasSort) {
|
||||
items.push({
|
||||
key: 'sort',
|
||||
tooltip: sortTooltip,
|
||||
icon: sortIcon,
|
||||
onClick: () => {
|
||||
dialogVisible.value = true;
|
||||
actionStatusMap.sort.focused = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (column.hasFilter) {
|
||||
items.push({
|
||||
key: 'filter',
|
||||
tooltip: filterTooltip,
|
||||
isHtml: filterIsHtml,
|
||||
icon: ['fa', 'filter'],
|
||||
onClick: () => {
|
||||
dialogVisible.value = true;
|
||||
actionStatusMap.filter.focused = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const hideDialog = () => {
|
||||
dialogVisible.value = false;
|
||||
actionStatusMap.filter.focused = false;
|
||||
actionStatusMap.sort.focused = false;
|
||||
};
|
||||
|
||||
const clearDialog = () => {
|
||||
const {column} = props as TableHeaderProps;
|
||||
|
||||
// set status
|
||||
actionStatusMap.filter.active = false;
|
||||
actionStatusMap.sort.active = false;
|
||||
|
||||
// set data
|
||||
sortData.value = undefined;
|
||||
filterData.value = undefined;
|
||||
|
||||
// hide
|
||||
hideDialog();
|
||||
|
||||
// emit
|
||||
emit('change', column, undefined, undefined);
|
||||
};
|
||||
|
||||
const onDialogCancel = () => {
|
||||
hideDialog();
|
||||
};
|
||||
|
||||
const onDialogClear = () => {
|
||||
clearDialog();
|
||||
};
|
||||
|
||||
const onDialogApply = (value: TableHeaderDialogValue) => {
|
||||
const {column} = props as TableHeaderProps;
|
||||
const {sort, filter} = value;
|
||||
|
||||
// set status
|
||||
if (sort) actionStatusMap.sort.active = true;
|
||||
if (filter) actionStatusMap.filter.active = true;
|
||||
|
||||
// set data
|
||||
sortData.value = sort;
|
||||
filterData.value = filter;
|
||||
|
||||
// if no data set, clear
|
||||
if (!sortData.value && !filterData.value) {
|
||||
clearDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// hide
|
||||
hideDialog();
|
||||
|
||||
// emit
|
||||
emit('change', column, sortData.value, filterData.value);
|
||||
};
|
||||
|
||||
const hasDialog = computed<boolean>(() => {
|
||||
const {
|
||||
hasSort,
|
||||
hasFilter,
|
||||
} = props.column;
|
||||
|
||||
return !!hasSort || !!hasFilter;
|
||||
});
|
||||
|
||||
return {
|
||||
dialogVisible,
|
||||
actionStatusMap,
|
||||
actions,
|
||||
sortData,
|
||||
filterData,
|
||||
onDialogCancel,
|
||||
onDialogClear,
|
||||
onDialogApply,
|
||||
hasDialog,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.required:before {
|
||||
content: "*";
|
||||
color: $red;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
color: $infoMediumLightColor;
|
||||
font-size: 8px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.table-header:hover .actions >>> .action {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.table-header .actions >>> .action {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
86
frontend/src/components/table/TableHeaderAction.vue
Normal file
86
frontend/src/components/table/TableHeaderAction.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<span :class="classes" class="action" @click="onClick">
|
||||
<el-tooltip :content="tooltip">
|
||||
<template v-if="isHtml" #content>
|
||||
<div v-html="tooltip"/>
|
||||
</template>
|
||||
<font-awesome-icon :icon="icon"/>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableHeaderAction',
|
||||
props: {
|
||||
tooltip: {
|
||||
type: [String, Object],
|
||||
required: false,
|
||||
},
|
||||
isHtml: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: [Array, String],
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {
|
||||
return {active: false, focused: false};
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const classes = computed<string[]>(() => {
|
||||
const {status} = props as TableHeaderActionProps;
|
||||
if (!status) return [];
|
||||
const {active, focused} = status;
|
||||
const cls = [];
|
||||
if (active) cls.push('active');
|
||||
if (focused) cls.push('focused');
|
||||
return cls;
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
return {
|
||||
classes,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.action {
|
||||
margin-left: 3px;
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
color: $primaryColor;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
display: inline !important;
|
||||
color: $primaryColor;
|
||||
}
|
||||
|
||||
&.active {
|
||||
display: inline !important;
|
||||
color: $warningColor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
290
frontend/src/components/table/TableHeaderDialog.vue
Normal file
290
frontend/src/components/table/TableHeaderDialog.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<el-popover
|
||||
:show-arrow="false"
|
||||
:visible="visible"
|
||||
class="table-header-dialog"
|
||||
popper-class="table-header-popper"
|
||||
trigger="manual"
|
||||
>
|
||||
<template #reference>
|
||||
<div>
|
||||
<slot name="reference"/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-click-outside="onClickOutside" class="content">
|
||||
<div class="header">
|
||||
<div class="title">{{ column.label }}</div>
|
||||
</div>
|
||||
<span class="close" @click="onCancel">
|
||||
<el-icon name="close"></el-icon>
|
||||
</span>
|
||||
<div class="body">
|
||||
<div class="list">
|
||||
<div v-if="column.hasSort" class="item sort">
|
||||
<TableHeaderDialogSort :value="internalSort?.d" @change="onSortChange"/>
|
||||
</div>
|
||||
<div v-if="column.hasFilter" class="item filter">
|
||||
<TableHeaderDialogFilter
|
||||
:column="column"
|
||||
:conditions="conditions"
|
||||
:search-string="searchString"
|
||||
@change="onFilterChange"
|
||||
@enter="onFilterEnter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<Button plain size="mini" tooltip="Cancel" type="info" @click="onCancel">Cancel</Button>
|
||||
<Button plain size="mini" tooltip="Clear" type="warning" @click="onClear">Clear</Button>
|
||||
<Button
|
||||
:disabled="isApplyDisabled"
|
||||
size="mini"
|
||||
tooltip="Apply"
|
||||
type="primary"
|
||||
@click="onApply"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType, ref, watch} from 'vue';
|
||||
import Button from '@/components/button/Button.vue';
|
||||
import TableHeaderDialogFilter from '@/components/table/TableHeaderDialogFilter.vue';
|
||||
import TableHeaderDialogSort from '@/components/table/TableHeaderDialogSort.vue';
|
||||
import variables from '@/styles/variables.scss';
|
||||
import {plainClone} from '@/utils/object';
|
||||
import {FILTER_OP_NOT_SET} from '@/constants/filter';
|
||||
import {ClickOutside} from 'element-plus/lib/directives';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableHeaderFilter',
|
||||
components: {
|
||||
TableHeaderDialogSort,
|
||||
TableHeaderDialogFilter,
|
||||
Button,
|
||||
},
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
column: {
|
||||
type: Object as PropType<TableColumn>,
|
||||
required: true,
|
||||
},
|
||||
actionStatusMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sort: {
|
||||
type: Object as PropType<SortData>,
|
||||
required: false,
|
||||
},
|
||||
filter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
'cancel',
|
||||
'clear',
|
||||
'apply',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const defaultInternalSort = {key: props.column.key} as SortData;
|
||||
const internalSort = ref<SortData>();
|
||||
const internalFilter = ref<TableHeaderDialogFilterData>();
|
||||
|
||||
const searchString = computed<string | undefined>(() => internalFilter.value?.searchString);
|
||||
|
||||
const conditions = computed<FilterConditionData[]>(() => internalFilter.value?.conditions || []);
|
||||
|
||||
const items = computed<string[]>(() => internalFilter.value?.items || []);
|
||||
|
||||
const trueConditions = computed<FilterConditionData[]>(() => {
|
||||
return conditions.value?.filter(d => d.op !== FILTER_OP_NOT_SET);
|
||||
});
|
||||
|
||||
const isEmptyFilter = computed<boolean>(() => {
|
||||
return !searchString.value && trueConditions.value.length == 0 && items.value.length === 0;
|
||||
});
|
||||
|
||||
const isApplyDisabled = computed<boolean>(() => {
|
||||
for (const cond of trueConditions.value) {
|
||||
if (!cond.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (!internalSort.value) internalSort.value = plainClone(defaultInternalSort);
|
||||
internalSort.value.d = undefined;
|
||||
internalFilter.value = undefined;
|
||||
emit('clear');
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
if (!internalSort.value && isEmptyFilter.value) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
const value: TableHeaderDialogValue = {
|
||||
sort: internalSort.value,
|
||||
filter: internalFilter.value,
|
||||
};
|
||||
emit('apply', value);
|
||||
};
|
||||
|
||||
const onClickOutside = () => {
|
||||
const {visible} = props;
|
||||
if (!visible) return;
|
||||
cancel();
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
cancel();
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
clear();
|
||||
};
|
||||
|
||||
const onApply = () => {
|
||||
apply();
|
||||
};
|
||||
|
||||
const onSortChange = (value: string) => {
|
||||
if (!internalSort.value) internalSort.value = plainClone(defaultInternalSort);
|
||||
internalSort.value.d = value;
|
||||
};
|
||||
|
||||
const onFilterChange = (value: TableHeaderDialogFilterData) => {
|
||||
internalFilter.value = value;
|
||||
};
|
||||
|
||||
const onFilterEnter = () => {
|
||||
apply();
|
||||
};
|
||||
|
||||
watch(() => {
|
||||
const {visible} = props as TableHeaderDialogProps;
|
||||
return visible;
|
||||
}, () => {
|
||||
const {sort, filter, visible} = props as TableHeaderDialogProps;
|
||||
if (visible) {
|
||||
internalSort.value = (sort ? plainClone(sort) : plainClone(defaultInternalSort)) as SortData;
|
||||
internalFilter.value = plainClone(filter) as TableHeaderDialogFilterData;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
variables,
|
||||
internalSort,
|
||||
searchString,
|
||||
conditions,
|
||||
isApplyDisabled,
|
||||
onClickOutside,
|
||||
onCancel,
|
||||
onClear,
|
||||
onApply,
|
||||
onSortChange,
|
||||
onFilterChange,
|
||||
onFilterEnter,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header {
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
color: $infoMediumColor;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid $infoBorderColor;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $infoBorderColor;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.sort {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
&.filter {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.table-header-popper {
|
||||
min-width: 320px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
236
frontend/src/components/table/TableHeaderDialogFilter.vue
Normal file
236
frontend/src/components/table/TableHeaderDialogFilter.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="table-header-dialog-filter">
|
||||
<div class="title">
|
||||
<span>Filter</span>
|
||||
<el-input
|
||||
v-if="column.allowFilterSearch"
|
||||
:model-value="internalSearchString"
|
||||
class="search"
|
||||
placeholder="Search"
|
||||
prefix-icon="el-icon-search"
|
||||
size="mini"
|
||||
@input="onSearch"
|
||||
@clear="onClear"
|
||||
@keyup.enter="onEnter"
|
||||
/>
|
||||
<!-- <el-tooltip content="Add Condition">-->
|
||||
<!-- <span class="icon" @click="onAddCondition">-->
|
||||
<!-- <el-icon name="circle-plus-outline"/>-->
|
||||
<!-- </span>-->
|
||||
<!-- </el-tooltip>-->
|
||||
</div>
|
||||
<!-- <el-form>-->
|
||||
<!-- <FilterConditionList :conditions="internalConditions" @change="onConditionsChange"/>-->
|
||||
<!-- </el-form>-->
|
||||
<div v-if="column.allowFilterItems" class="items">
|
||||
<template v-if="filteredItems.length > 0">
|
||||
<el-checkbox-group v-model="internalItems" class="item-list" @change="onItemsChange">
|
||||
<el-checkbox
|
||||
v-for="(item, $index) in filteredItems"
|
||||
:key="$index"
|
||||
:label="item.value"
|
||||
class="item"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Empty description="No data available"></Empty>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType, ref, watch} from 'vue';
|
||||
import Empty from '@/components/empty/Empty.vue';
|
||||
import {getDefaultFilterCondition} from '@/components/filter/FilterCondition.vue';
|
||||
// import FilterConditionList from '@/components/filter/FilterConditionList.vue';
|
||||
import {debounce} from '@/utils/debounce';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableHeaderDialogFilter',
|
||||
components: {
|
||||
// FilterConditionList,
|
||||
Empty,
|
||||
},
|
||||
props: {
|
||||
column: {
|
||||
type: Object as PropType<TableColumn>,
|
||||
required: false,
|
||||
},
|
||||
searchString: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
conditions: {
|
||||
type: Array as PropType<FilterConditionData[]>,
|
||||
required: false,
|
||||
default: () => {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'change',
|
||||
'enter',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const internalConditions = ref<FilterConditionData[]>([getDefaultFilterCondition()]);
|
||||
const internalSearchString = ref<string>();
|
||||
const internalItems = ref<string[]>([]);
|
||||
|
||||
const filterData = computed<TableHeaderDialogFilterData>(() => {
|
||||
return {
|
||||
searchString: internalSearchString.value,
|
||||
conditions: internalConditions.value,
|
||||
items: internalItems.value,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredItems = computed<SelectOption[]>(() => {
|
||||
const {column} = props as TableHeaderDialogFilterProps;
|
||||
|
||||
const items = column?.filterItems;
|
||||
|
||||
// undefined items
|
||||
if (!items) {
|
||||
// console.log('undefined items');
|
||||
return [];
|
||||
}
|
||||
|
||||
// invalid type of items or empty items
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
// console.log('invalid type of items or empty items');
|
||||
return [];
|
||||
}
|
||||
|
||||
// items as an array of SelectOption
|
||||
// console.log('items as an array of SelectOption');
|
||||
return items.filter(d => filterData.value.searchString ? d.label?.toLowerCase()?.includes(filterData.value.searchString) : true);
|
||||
});
|
||||
|
||||
const onAddCondition = () => {
|
||||
internalConditions.value.push(getDefaultFilterCondition());
|
||||
};
|
||||
|
||||
const onConditionsChange = (newConditions: FilterConditionData[]) => {
|
||||
internalConditions.value = newConditions;
|
||||
emit('change', filterData.value);
|
||||
};
|
||||
|
||||
const onItemsChange = (newItems: string[]) => {
|
||||
internalItems.value = newItems;
|
||||
emit('change', filterData.value);
|
||||
};
|
||||
|
||||
const search = debounce(() => {
|
||||
if (internalSearchString.value) {
|
||||
internalItems.value = filteredItems.value.map(d => d.value);
|
||||
} else {
|
||||
internalItems.value = [];
|
||||
}
|
||||
emit('change', filterData.value);
|
||||
}, {delay: 100});
|
||||
|
||||
const onSearch = (value?: string) => {
|
||||
internalSearchString.value = value;
|
||||
search();
|
||||
};
|
||||
|
||||
const onEnter = () => {
|
||||
emit('enter');
|
||||
};
|
||||
|
||||
watch(() => {
|
||||
const {searchString} = props as TableHeaderDialogFilterProps;
|
||||
return searchString;
|
||||
}, (value) => {
|
||||
internalSearchString.value = value;
|
||||
});
|
||||
|
||||
watch(() => {
|
||||
const {conditions} = props as TableHeaderDialogFilterProps;
|
||||
return conditions;
|
||||
}, (value) => {
|
||||
if (value) {
|
||||
internalConditions.value = value;
|
||||
if (internalConditions.value.length === 0) {
|
||||
internalConditions.value.push(getDefaultFilterCondition());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
internalSearchString,
|
||||
internalConditions,
|
||||
internalItems,
|
||||
filteredItems,
|
||||
onAddCondition,
|
||||
onConditionsChange,
|
||||
onItemsChange,
|
||||
onSearch,
|
||||
onEnter,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.table-header-dialog-filter {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
color: $infoMediumColor;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search {
|
||||
margin-left: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
border: 1px solid $infoBorderColor;
|
||||
padding: 10px;
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
max-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/components/table/TableHeaderDialogSort.vue
Normal file
101
frontend/src/components/table/TableHeaderDialogSort.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="table-header-dialog-sort">
|
||||
<div class="title">
|
||||
<span>Sort</span>
|
||||
<el-tooltip v-if="value" content="Clear sort">
|
||||
<span class="icon" @click="onClear">
|
||||
<el-icon name="circle-close"/>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-radio-group :model-value="value" size="mini" type="primary" @change="onChange">
|
||||
<el-radio-button :label="ASCENDING" class="sort-btn">
|
||||
<font-awesome-icon :icon="['fa', 'sort-amount-up']"/>
|
||||
Ascending
|
||||
</el-radio-button>
|
||||
<el-radio-button :label="DESCENDING" class="sort-btn">
|
||||
<font-awesome-icon :icon="['fa', 'sort-amount-down-alt']"/>
|
||||
Descending
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {ASCENDING, DESCENDING, UNSORTED} from '@/constants/sort';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableHeaderDialogSort',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'change'
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const onChange = (value: SortDirection) => {
|
||||
if (value === UNSORTED) {
|
||||
emit('change', undefined);
|
||||
return;
|
||||
}
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
emit('change');
|
||||
};
|
||||
|
||||
return {
|
||||
onChange,
|
||||
onClear,
|
||||
UNSORTED,
|
||||
ASCENDING,
|
||||
DESCENDING,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.table-header-dialog-sort {
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
color: $infoMediumColor;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-radio-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.sort-btn.el-radio-button {
|
||||
&:not(.unsorted) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.unsorted {
|
||||
flex-basis: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.table-header-dialog-sort >>> .el-radio-group .el-radio-button .el-radio-button__inner {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
68
frontend/src/components/table/action.ts
Normal file
68
frontend/src/components/table/action.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {inject, Ref, ref, SetupContext} from 'vue';
|
||||
import {Table} from 'element-plus/lib/el-table/src/table.type';
|
||||
import {ElMessageBox} from 'element-plus';
|
||||
|
||||
const useAction = (props: TableProps, ctx: SetupContext, table: Ref<Table | undefined>, actionFunctions: ListLayoutActionFunctions) => {
|
||||
const {emit} = ctx;
|
||||
|
||||
// store context
|
||||
const storeContext = inject<ListStoreContext<BaseModel>>('store-context');
|
||||
const ns = storeContext?.namespace;
|
||||
const store = storeContext?.store;
|
||||
|
||||
// table selection
|
||||
const selection = ref<TableData>([]);
|
||||
const onSelectionChange = (value: TableData) => {
|
||||
selection.value = value;
|
||||
emit('selection-change', value);
|
||||
};
|
||||
|
||||
// action functions
|
||||
const {
|
||||
getList,
|
||||
deleteList,
|
||||
} = actionFunctions;
|
||||
|
||||
const onAdd = () => {
|
||||
emit('add');
|
||||
};
|
||||
|
||||
const onEdit = async () => {
|
||||
emit('edit', selection.value);
|
||||
if (storeContext) {
|
||||
store?.commit(`${ns}/showDialog`, 'edit');
|
||||
store?.commit(`${ns}/setIsSelectiveForm`, true);
|
||||
store?.commit(`${ns}/setFormList`, selection.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const res = await ElMessageBox.confirm('Are you sure to delete selected items?', 'Batch Delete', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
});
|
||||
if (!res) return;
|
||||
const ids = selection.value.map(d => d._id as string);
|
||||
await deleteList(ids);
|
||||
table.value?.store?.clearSelection();
|
||||
await getList();
|
||||
emit('delete', selection.value);
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
emit('export');
|
||||
};
|
||||
|
||||
return {
|
||||
// public variables and methods
|
||||
selection,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onExport,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAction;
|
||||
136
frontend/src/components/table/column.ts
Normal file
136
frontend/src/components/table/column.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {computed, onBeforeMount, onMounted, ref, Ref, SetupContext} from 'vue';
|
||||
import {Table, TableColumnCtx} from 'element-plus/lib/el-table/src/table.type';
|
||||
import {cloneArray, plainClone} from '@/utils/object';
|
||||
import useStore from '@/components/table/store';
|
||||
import {getColumnWidth, getTableWidth} from '@/utils/table';
|
||||
|
||||
const useColumns = (props: TableProps, ctx: SetupContext, table: Ref<Table | undefined>, wrapper: Ref) => {
|
||||
const {columns} = props;
|
||||
|
||||
const {store} = useStore(props, ctx, table);
|
||||
|
||||
const columnsTransferVisible = ref<boolean>(false);
|
||||
|
||||
const internalSelectedColumnKeys = ref<string[]>([]);
|
||||
|
||||
const columnsMap = computed<TableColumnsMap>(() => {
|
||||
const map = {} as TableColumnsMap;
|
||||
columns.forEach(c => {
|
||||
map[c.key] = c;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const columnsCtx = computed<TableColumnCtx[]>(() => {
|
||||
return table.value?.store.states.columns.value || [];
|
||||
});
|
||||
|
||||
const columnCtxMap = computed<TableColumnCtxMap>(() => {
|
||||
const map = {} as TableColumnCtxMap;
|
||||
columnsCtx.value.forEach(c => {
|
||||
map[c.columnKey] = c;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const selectedColumns = computed<TableColumn[]>(() => {
|
||||
return internalSelectedColumnKeys.value.map(key => columnsMap.value[key]);
|
||||
});
|
||||
|
||||
const defaultSelectedColumns = computed<TableColumn[]>(() => {
|
||||
return columns.filter(d => !d.defaultHidden);
|
||||
});
|
||||
|
||||
const onShowColumnsTransfer = () => {
|
||||
columnsTransferVisible.value = true;
|
||||
};
|
||||
|
||||
const onHideColumnsTransfer = () => {
|
||||
columnsTransferVisible.value = false;
|
||||
};
|
||||
|
||||
const isColumnsEqual = (columnKeys: string[]) => {
|
||||
const columnKeysSorted = cloneArray(columnKeys).sort().join(',');
|
||||
const internalSelectedColumnKeysSorted = cloneArray(internalSelectedColumnKeys.value).sort().join(',');
|
||||
return columnKeysSorted === internalSelectedColumnKeysSorted;
|
||||
};
|
||||
|
||||
const updateColumns = (columnKeys?: string[]) => {
|
||||
if (!store.value) return;
|
||||
|
||||
if (!columnKeys) {
|
||||
columnKeys = selectedColumns.value.map(d => d.key);
|
||||
}
|
||||
|
||||
// selection column keys
|
||||
const selectionColumnKeys = columnsCtx.value.filter(d => d.type === 'selection').map(d => d.columnKey);
|
||||
|
||||
// table width
|
||||
const tableWidth = getTableWidth();
|
||||
|
||||
// table width
|
||||
let tableFixedTotalWidth = 0;
|
||||
columns.map((d) => getColumnWidth(d) as number).filter(w => !!w).forEach((w: number) => {
|
||||
tableFixedTotalWidth += w;
|
||||
});
|
||||
|
||||
// auto width
|
||||
const autoWidth = tableWidth ? (tableWidth - tableFixedTotalWidth - 40 - 12) : 0;
|
||||
|
||||
// columns to update
|
||||
const columnsToUpdate = selectionColumnKeys.concat(columnKeys).map(key => {
|
||||
const columnCtx = columnCtxMap.value[key];
|
||||
const column = columnsMap.value[key];
|
||||
if (column && column.width === 'auto') {
|
||||
if (autoWidth) {
|
||||
columnCtx.width = autoWidth > 400 ? autoWidth : 400;
|
||||
}
|
||||
}
|
||||
return columnCtx;
|
||||
});
|
||||
|
||||
// update columns
|
||||
if (isColumnsEqual(columnKeys)) {
|
||||
store.value?.commit('setColumns', columnsToUpdate);
|
||||
store.value?.updateColumns();
|
||||
}
|
||||
internalSelectedColumnKeys.value = columnKeys;
|
||||
|
||||
// set table width to 100%
|
||||
// wrapper.value.querySelectorAll('.el-table__body').forEach((el: HTMLTableElement) => {
|
||||
// el.setAttribute('style', 'width: 100%');
|
||||
// });
|
||||
};
|
||||
|
||||
const onColumnsChange = (value: string[]) => {
|
||||
updateColumns(value);
|
||||
};
|
||||
|
||||
const initColumns = () => {
|
||||
if (defaultSelectedColumns.value.length < columns.length) {
|
||||
internalSelectedColumnKeys.value = plainClone(defaultSelectedColumns.value.map(d => d.key));
|
||||
} else {
|
||||
internalSelectedColumnKeys.value = cloneArray(columns.map(d => d.key));
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
initColumns();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(updateColumns, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
internalSelectedColumnKeys,
|
||||
columnsMap,
|
||||
columnsTransferVisible,
|
||||
selectedColumns,
|
||||
onShowColumnsTransfer,
|
||||
onHideColumnsTransfer,
|
||||
onColumnsChange,
|
||||
};
|
||||
};
|
||||
|
||||
export default useColumns;
|
||||
14
frontend/src/components/table/data.ts
Normal file
14
frontend/src/components/table/data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {computed, SetupContext} from 'vue';
|
||||
|
||||
const useData = (props: TableProps, ctx: SetupContext) => {
|
||||
const tableData = computed(() => {
|
||||
const {data} = props;
|
||||
return data;
|
||||
});
|
||||
|
||||
return {
|
||||
tableData,
|
||||
};
|
||||
};
|
||||
|
||||
export default useData;
|
||||
16
frontend/src/components/table/header.ts
Normal file
16
frontend/src/components/table/header.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {SetupContext} from 'vue';
|
||||
|
||||
const useHeader = (props: TableProps, ctx: SetupContext) => {
|
||||
const {emit} = ctx;
|
||||
|
||||
const onHeaderChange = (column: TableColumn, sort: SortData, filter: FilterConditionData[]) => {
|
||||
emit('header-change', column, sort, filter);
|
||||
};
|
||||
|
||||
return {
|
||||
// public variables and methods
|
||||
onHeaderChange,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHeader;
|
||||
28
frontend/src/components/table/pagination.ts
Normal file
28
frontend/src/components/table/pagination.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {SetupContext} from 'vue';
|
||||
|
||||
const usePagination = (props: TableProps, ctx: SetupContext) => {
|
||||
const {emit} = ctx;
|
||||
|
||||
const onCurrentChange = (page: number) => {
|
||||
const {pageSize} = props;
|
||||
emit('pagination-change', {
|
||||
page,
|
||||
size: pageSize,
|
||||
} as TablePagination);
|
||||
};
|
||||
|
||||
const onSizeChange = (size: number) => {
|
||||
const {page} = props;
|
||||
emit('pagination-change', {
|
||||
page,
|
||||
size,
|
||||
} as TablePagination);
|
||||
};
|
||||
|
||||
return {
|
||||
onCurrentChange,
|
||||
onSizeChange,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePagination;
|
||||
22
frontend/src/components/table/store.ts
Normal file
22
frontend/src/components/table/store.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {computed, Ref, SetupContext} from 'vue';
|
||||
import {Table, TableColumnCtx} from 'element-plus/lib/el-table/src/table.type';
|
||||
|
||||
const useStore = (props: TableProps, ctx: SetupContext, table: Ref<Table | undefined>) => {
|
||||
const setColumns = (states: TableStoreStates, columns: TableColumnCtx[]) => {
|
||||
states._columns.value = columns;
|
||||
};
|
||||
|
||||
const store = computed<TableStore | undefined>(() => {
|
||||
const store = (table.value?.store as unknown) as TableStore;
|
||||
if (!store) return;
|
||||
store.mutations.setColumns = setColumns;
|
||||
return store;
|
||||
});
|
||||
|
||||
return {
|
||||
// public variables and methods
|
||||
store,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStore;
|
||||
Reference in New Issue
Block a user