mirror of
https://github.com/DeNNiiInc/dbgate.git
synced 2026-04-18 00:56:02 +00:00
476 lines
14 KiB
Svelte
476 lines
14 KiB
Svelte
<script lang="ts" context="module">
|
|
export interface TableControlColumn {
|
|
fieldName: string;
|
|
header: string;
|
|
component?: any;
|
|
getProps?: any;
|
|
props?: any;
|
|
formatter?: any;
|
|
slot?: number;
|
|
slotKey?: string;
|
|
isHighlighted?: Function;
|
|
sortable?: boolean;
|
|
filterable?: boolean;
|
|
filteredExpression?: (row: any) => string;
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import _ from 'lodash';
|
|
|
|
import { onMount } from 'svelte';
|
|
import keycodes from '../utility/keycodes';
|
|
import { createEventDispatcher } from 'svelte';
|
|
import FontIcon from '../icons/FontIcon.svelte';
|
|
import DataFilterControl from '../datagrid/DataFilterControl.svelte';
|
|
import { evalFilterBehaviour } from 'dbgate-tools';
|
|
import { evaluateCondition } from 'dbgate-sqltree';
|
|
import { compileCompoudEvalCondition } from 'dbgate-filterparser';
|
|
import { chevronExpandIcon } from '../icons/expandIcons';
|
|
import { _val } from '../translations';
|
|
|
|
export let columns: (TableControlColumn | false)[];
|
|
export let rows = null;
|
|
export let groupedRows = null;
|
|
export let focusOnCreate = false;
|
|
export let selectable = false;
|
|
export let selectedIndex = 0;
|
|
export let selectedKey = null;
|
|
export let clickable = false;
|
|
export let disableFocusOutline = false;
|
|
export let emptyMessage = null;
|
|
export let noCellPadding = false;
|
|
export let allowMultiSelect = false;
|
|
export let selectedIndexes = [];
|
|
export let onChangeMultipleSelection = null;
|
|
|
|
export let domTable = undefined;
|
|
export let stickyHeader = false;
|
|
|
|
export let checkedKeys = null;
|
|
export let onSetCheckedKeys = null;
|
|
export let extractTableItemKey = x => x.id;
|
|
export let itemSupportsCheckbox = x => true;
|
|
export let filters = null;
|
|
|
|
export let selectionMode: 'index' | 'key' = 'index';
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
let dragStartIndex = null;
|
|
let dragCurrentIndex = null;
|
|
|
|
$: columnList = _.compact(_.flatten(columns));
|
|
|
|
onMount(() => {
|
|
if (focusOnCreate) domTable.focus();
|
|
});
|
|
|
|
const handleKeyDown = event => {
|
|
const oldSelectedIndex =
|
|
selectionMode == 'index' ? selectedIndex : _.findIndex(flatRowsShown, x => extractTableItemKey(x) == selectedKey);
|
|
let newIndex = oldSelectedIndex;
|
|
|
|
switch (event.keyCode) {
|
|
case keycodes.downArrow:
|
|
newIndex = Math.min(newIndex + 1, flatRowsShown.length - 1);
|
|
break;
|
|
case keycodes.upArrow:
|
|
newIndex = Math.max(0, newIndex - 1);
|
|
break;
|
|
case keycodes.home:
|
|
newIndex = 0;
|
|
break;
|
|
case keycodes.end:
|
|
newIndex = rows.length - 1;
|
|
break;
|
|
case keycodes.pageUp:
|
|
newIndex -= 10;
|
|
break;
|
|
case keycodes.pageDown:
|
|
newIndex += 10;
|
|
break;
|
|
}
|
|
if (newIndex < 0) {
|
|
newIndex = 0;
|
|
}
|
|
if (newIndex >= flatRowsShown.length) {
|
|
newIndex = flatRowsShown.length - 1;
|
|
}
|
|
|
|
if (clickable && oldSelectedIndex != newIndex) {
|
|
event.preventDefault();
|
|
domRows[newIndex]?.scrollIntoView();
|
|
if (clickable) {
|
|
dispatch('clickrow', flatRowsShown[newIndex]);
|
|
}
|
|
if (selectionMode == 'index') {
|
|
selectedIndex = newIndex;
|
|
} else {
|
|
selectedKey = extractTableItemKey(flatRowsShown[newIndex]);
|
|
}
|
|
}
|
|
};
|
|
|
|
function filterRows(grows, filters) {
|
|
const condition = compileCompoudEvalCondition(filters);
|
|
|
|
if (!condition) return grows;
|
|
|
|
return grows
|
|
.map(gitem => {
|
|
return {
|
|
group: gitem.group,
|
|
rows: gitem.rows.filter(row => {
|
|
const newrow = { ...row };
|
|
for (const col of columnList) {
|
|
if (col.filteredExpression) {
|
|
newrow[col.fieldName] = col.filteredExpression(row);
|
|
}
|
|
}
|
|
return evaluateCondition(condition, newrow);
|
|
}),
|
|
};
|
|
})
|
|
.filter(gitem => gitem.rows.length > 0);
|
|
}
|
|
|
|
// function computeGroupedRows(array) {
|
|
// if (!extractGroupName) {
|
|
// return [{ label: null, rows: array }];
|
|
// }
|
|
// const res = [];
|
|
// let lastGroupName = null;
|
|
// let buildArray = [];
|
|
// for (const item of array) {
|
|
// const groupName = extractGroupName(item);
|
|
// if (lastGroupName != groupName) {
|
|
// if (buildArray.length > 0) {
|
|
// res.push({ label: lastGroupName, rows: buildArray });
|
|
// }
|
|
// lastGroupName = groupName;
|
|
// buildArray = [];
|
|
// }
|
|
// buildArray.push(item);
|
|
// }
|
|
// if (buildArray.length > 0) {
|
|
// res.push({ label: lastGroupName, rows: buildArray });
|
|
// }
|
|
// }
|
|
|
|
let sortedByField = null;
|
|
let sortOrderIsDesc = false;
|
|
let collapsedGroupIndexes = [];
|
|
let domRows = {};
|
|
|
|
$: rowsSource = groupedRows ? groupedRows : [{ group: null, rows }];
|
|
|
|
$: filteredRows = filters ? filterRows(rowsSource, $filters) : rowsSource;
|
|
|
|
$: sortedRows = sortedByField
|
|
? filteredRows.map(gitem => {
|
|
let res = _.sortBy(gitem.rows, sortedByField);
|
|
if (sortOrderIsDesc) res = [...res].reverse();
|
|
return { group: gitem.group, rows: res };
|
|
})
|
|
: filteredRows;
|
|
|
|
// $: console.log('sortedRows', sortedRows);
|
|
|
|
$: flatRowsShown = sortedRows.map(gitem => gitem.rows).flat();
|
|
$: checkableFlatRowsShown = flatRowsShown.filter(x => itemSupportsCheckbox(x));
|
|
|
|
// $: groupedRows = computeGroupedRows(sortedRows);
|
|
|
|
$: if (onChangeMultipleSelection && flatRowsShown) {
|
|
onChangeMultipleSelection(selectedIndexes.map(index => flatRowsShown[index]));
|
|
}
|
|
|
|
$: if (flatRowsShown) {
|
|
// reset selection on items changed
|
|
selectedIndexes = [];
|
|
}
|
|
</script>
|
|
|
|
<table
|
|
bind:this={domTable}
|
|
class:selectable
|
|
class:disableFocusOutline
|
|
on:keydown
|
|
tabindex={selectable ? -1 : undefined}
|
|
on:keydown={handleKeyDown}
|
|
class:stickyHeader
|
|
data-testid={$$props['data-testid']}
|
|
>
|
|
<thead class:stickyHeader>
|
|
<tr>
|
|
{#if checkedKeys}
|
|
<th>
|
|
<input
|
|
type="checkbox"
|
|
checked={checkableFlatRowsShown.every(r => checkedKeys.includes(extractTableItemKey(r)))}
|
|
data-testid="TableControl_selectAllCheckBox"
|
|
on:change={e => {
|
|
if (e.target['checked']) onSetCheckedKeys(checkableFlatRowsShown.map(r => extractTableItemKey(r)));
|
|
else onSetCheckedKeys([]);
|
|
}}
|
|
/>
|
|
</th>
|
|
{/if}
|
|
{#each columnList as col}
|
|
<th
|
|
class:clickable={col.sortable}
|
|
on:click={() => {
|
|
if (col.sortable) {
|
|
if (sortedByField == col.fieldName) {
|
|
if (sortOrderIsDesc) {
|
|
sortOrderIsDesc = false;
|
|
sortedByField = null;
|
|
} else {
|
|
sortOrderIsDesc = true;
|
|
}
|
|
} else {
|
|
sortOrderIsDesc = false;
|
|
sortedByField = col.fieldName;
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{col.header || ''}
|
|
{#if sortedByField == col.fieldName}
|
|
<FontIcon icon={sortOrderIsDesc ? 'img sort-desc' : 'img sort-asc'} padLeft />
|
|
{/if}
|
|
</th>
|
|
{/each}
|
|
</tr>
|
|
{#if filters}
|
|
<tr>
|
|
{#if checkedKeys}
|
|
<td class="empty-cell"></td>
|
|
{/if}
|
|
{#each columnList as col}
|
|
<td class="filter-cell" class:empty-cell={!col.filterable}>
|
|
{#if col.filterable}
|
|
<DataFilterControl
|
|
filterBehaviour={evalFilterBehaviour}
|
|
filter={$filters[col.fieldName]}
|
|
setFilter={value => filters.update(f => ({ ...f, [col.fieldName]: value }))}
|
|
placeholder="Data filter"
|
|
/>
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
{/if}
|
|
</thead>
|
|
<tbody>
|
|
{#each sortedRows as gitem, groupIndex}
|
|
{#if gitem.group}
|
|
<tr class="group-row">
|
|
<td
|
|
colspan={columnList.length + (checkedKeys ? 1 : 0)}
|
|
class="groupcell"
|
|
on:click={() => {
|
|
if (collapsedGroupIndexes.includes(groupIndex)) {
|
|
collapsedGroupIndexes = collapsedGroupIndexes.filter(x => x != groupIndex);
|
|
} else {
|
|
collapsedGroupIndexes = [...collapsedGroupIndexes, groupIndex];
|
|
}
|
|
}}
|
|
>
|
|
<FontIcon icon={chevronExpandIcon(!collapsedGroupIndexes.includes(groupIndex))} padRight />
|
|
<strong>{gitem.group} ({gitem.rows.length})</strong>
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{#if !collapsedGroupIndexes.includes(groupIndex)}
|
|
{#each gitem.rows as row}
|
|
{@const index = _.indexOf(flatRowsShown, row)}
|
|
<tr
|
|
class:selected={(selectable &&
|
|
(selectionMode == 'index' ? selectedIndex == index : selectedKey == extractTableItemKey(row))) ||
|
|
(allowMultiSelect && selectedIndexes.includes(index))}
|
|
class:clickable
|
|
bind:this={domRows[index]}
|
|
on:click={() => {
|
|
if (selectable) {
|
|
if (selectionMode == 'index') {
|
|
selectedIndex = index;
|
|
} else {
|
|
selectedKey = extractTableItemKey(row);
|
|
}
|
|
domTable.focus();
|
|
}
|
|
if (clickable) {
|
|
dispatch('clickrow', row);
|
|
}
|
|
}}
|
|
on:mousedown={event => {
|
|
if (allowMultiSelect && !event.ctrlKey && !event.metaKey) {
|
|
selectedIndexes = [];
|
|
dragStartIndex = index;
|
|
}
|
|
}}
|
|
on:mousemove={() => {
|
|
if (dragStartIndex != null && allowMultiSelect) {
|
|
dragCurrentIndex = index;
|
|
if (dragCurrentIndex != dragStartIndex || selectedIndexes.length > 0) {
|
|
if (dragCurrentIndex < dragStartIndex) {
|
|
selectedIndexes = _.range(dragCurrentIndex, dragStartIndex + 1);
|
|
} else {
|
|
selectedIndexes = _.range(dragStartIndex, dragCurrentIndex + 1);
|
|
}
|
|
} else if (selectedIndexes.length > 0) {
|
|
selectedIndexes = [dragCurrentIndex];
|
|
}
|
|
}
|
|
}}
|
|
on:mouseup={event => {
|
|
dragCurrentIndex = null;
|
|
dragStartIndex = null;
|
|
}}
|
|
data-testid={`TableControl_row_${index}`}
|
|
>
|
|
{#if checkedKeys}
|
|
<td>
|
|
{#if itemSupportsCheckbox(row)}
|
|
<input
|
|
type="checkbox"
|
|
checked={checkedKeys.includes(extractTableItemKey(row))}
|
|
on:change={e => {
|
|
if (e.target['checked']) onSetCheckedKeys(_.uniq([...checkedKeys, extractTableItemKey(row)]));
|
|
else onSetCheckedKeys(checkedKeys.filter(x => x != extractTableItemKey(row)));
|
|
}}
|
|
data-testid={`TableControl_row_${index}_checkbox`}
|
|
/>
|
|
{/if}
|
|
</td>
|
|
{/if}
|
|
{#each columnList as col}
|
|
{@const rowProps = { ...col.props, ...(col.getProps ? col.getProps(row) : null) }}
|
|
<td class:isHighlighted={col.isHighlighted && col.isHighlighted(row)} class:noCellPadding>
|
|
{#if col.component}
|
|
<svelte:component this={col.component} {...rowProps} />
|
|
{:else if col.formatter}
|
|
{col.formatter(row, col)}
|
|
{:else if col.slot != null}
|
|
{#key row[col.slotKey] || 'key'}
|
|
{#if col.slot == -1}<slot name="-1" {row} {col} {index} />
|
|
{:else if col.slot == 0}<slot name="0" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 1}<slot name="1" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 2}<slot name="2" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 3}<slot name="3" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 4}<slot name="4" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 5}<slot name="5" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 6}<slot name="6" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 7}<slot name="7" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 8}<slot name="8" {row} {col} {index} {...rowProps} />
|
|
{:else if col.slot == 9}<slot name="9" {row} {col} {index} {...rowProps} />
|
|
{/if}
|
|
{/key}
|
|
{:else}
|
|
{ _val(row[col.fieldName]) || '' }
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
{#if emptyMessage && sortedRows.length == 0}
|
|
<tr>
|
|
<td colspan={columnList.length}>{emptyMessage}</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
|
|
<style>
|
|
table.disableFocusOutline:focus {
|
|
outline: none;
|
|
}
|
|
table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
}
|
|
table.selectable {
|
|
user-select: none;
|
|
}
|
|
tbody tr {
|
|
background: var(--theme-bg-0);
|
|
}
|
|
tbody tr.selected {
|
|
background: var(--theme-bg-3);
|
|
}
|
|
table:focus tbody tr.selected {
|
|
background: var(--theme-bg-selected);
|
|
}
|
|
tbody tr.clickable:hover {
|
|
background: var(--theme-bg-hover);
|
|
}
|
|
|
|
thead th {
|
|
border: 1px solid var(--theme-border);
|
|
background-color: var(--theme-bg-1);
|
|
padding: 5px;
|
|
}
|
|
tbody td {
|
|
border: 1px solid var(--theme-border);
|
|
}
|
|
|
|
tbody td:not(.noCellPadding) {
|
|
padding: 5px;
|
|
}
|
|
|
|
td.isHighlighted {
|
|
background-color: var(--theme-bg-1);
|
|
}
|
|
|
|
td.clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
thead.stickyHeader {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
border-top: 1px solid var(--theme-border);
|
|
}
|
|
|
|
table.stickyHeader th {
|
|
border-left: none;
|
|
}
|
|
|
|
thead.stickyHeader :global(tr:first-child) :global(th) {
|
|
border-top: 1px solid var(--theme-border);
|
|
}
|
|
|
|
table.stickyHeader td {
|
|
border: 0px;
|
|
border-bottom: 1px solid var(--theme-border);
|
|
border-right: 1px solid var(--theme-border);
|
|
}
|
|
|
|
table.stickyHeader {
|
|
border-spacing: 0;
|
|
border-collapse: separate;
|
|
border-left: 1px solid var(--theme-border);
|
|
}
|
|
|
|
.filter-cell {
|
|
text-align: left;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.empty-cell {
|
|
background-color: var(--theme-bg-1);
|
|
}
|
|
|
|
.groupcell {
|
|
background-color: var(--theme-bg-1);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|