Skip to content

Commit 81dc760

Browse files
committed
feat: reorder columns
1 parent 802901c commit 81dc760

File tree

4 files changed

+195
-29
lines changed

4 files changed

+195
-29
lines changed

web/src/components/Common/BaseTreeTable.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,26 @@ interface Props {
4747
searchableKeys?: string[];
4848
searchAccessor?: SearchAccessor<T>;
4949
includeMetaInSearch?: boolean;
50-
// Allow parent to control expansion if needed (e.g. for persistent state)
51-
// But usually BaseTreeTable manages it for UI
5250
canExpand?: (row: TreeRow<T>) => boolean;
5351
canSelect?: (row: TreeRow<T>) => boolean;
5452
canDrag?: (row: TreeRow<T>) => boolean;
5553
canDropInside?: (row: TreeRow<T>) => boolean;
5654
enableResizing?: boolean;
5755
columnSizing?: Record<string, number>;
56+
columnOrder?: string[];
5857
}
5958
6059
const props = withDefaults(defineProps<Props>(), {
6160
loading: false,
6261
compact: false,
6362
activeId: null,
6463
selectedIds: () => [],
65-
// selectableType default removed
6664
enableDragDrop: false,
6765
busyRowIds: () => new Set(),
68-
// searchableKeys default removed, will handle in getter if needed or empty
6966
includeMetaInSearch: true,
7067
enableResizing: false,
7168
columnSizing: () => ({}),
69+
columnOrder: () => [],
7270
});
7371
7472
const emit = defineEmits<{
@@ -84,6 +82,7 @@ const emit = defineEmits<{
8482
(e: 'row:drop', payload: DropEvent<T>): void;
8583
(e: 'update:selectedIds', value: string[]): void;
8684
(e: 'update:columnSizing', value: Record<string, number>): void;
85+
(e: 'update:columnOrder', value: string[]): void;
8786
}>();
8887
8988
const UButton = resolveComponent('UButton');
@@ -96,6 +95,7 @@ const tableContainerRef = ref<HTMLElement | null>(null);
9695
const columnVisibility = ref<Record<string, boolean>>({});
9796
9897
const columnSizing = defineModel<Record<string, number>>('columnSizing', { default: () => ({}) });
98+
const columnOrderState = defineModel<string[]>('columnOrder', { default: () => [] });
9999
100100
type ColumnHeaderRenderer = TableColumn<TreeRow<T>>['header'];
101101
@@ -451,6 +451,7 @@ function enhanceRowInstance(row: TableInstanceRow<T>): EnhancedRow<T> {
451451
:selected-count="selectedCount"
452452
:global-filter="globalFilter"
453453
:column-visibility="columnVisibility"
454+
:column-order="columnOrderState"
454455
:row-selection="rowSelection"
455456
:set-global-filter="setGlobalFilter"
456457
>
@@ -465,6 +466,7 @@ function enhanceRowInstance(row: TableInstanceRow<T>): EnhancedRow<T> {
465466
v-model:row-selection="rowSelection"
466467
v-model:column-visibility="columnVisibility"
467468
v-model:column-sizing="columnSizing"
469+
v-model:column-order="columnOrderState"
468470
:data="flattenedData"
469471
:columns="processedColumns"
470472
:get-row-id="(row: any) => row.id"
Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,129 @@
11
<script setup lang="ts">
2-
import { computed, resolveComponent } from 'vue';
2+
import { computed, ref } from 'vue';
3+
import { onClickOutside } from '@vueuse/core';
4+
5+
import { useColumnDragDrop } from '@/composables/useColumnDragDrop';
36
47
import type { ColumnVisibilityTableInstance } from '@/composables/usePersistentColumnVisibility';
58
69
interface Props {
710
table: ColumnVisibilityTableInstance | null;
11+
columnOrder?: string[];
812
}
913
1014
const props = defineProps<Props>();
1115
1216
const emit = defineEmits<{
1317
(e: 'change'): void;
18+
(e: 'update:columnOrder', value: string[]): void;
1419
}>();
1520
16-
const UDropdownMenu = resolveComponent('UDropdownMenu');
17-
const UButton = resolveComponent('UButton');
21+
const isOpen = ref(false);
22+
const dropdownRef = ref<HTMLElement | null>(null);
23+
24+
onClickOutside(dropdownRef, () => {
25+
isOpen.value = false;
26+
});
27+
28+
const columnOrderState = ref<string[]>([]);
1829
19-
const items = computed(() => {
20-
if (!props.table?.tableApi) return [[]];
30+
const orderedColumns = computed(() => {
31+
if (!props.table?.tableApi) return [];
2132
2233
const availableColumns = props.table.tableApi.getAllColumns().filter((column) => column.getCanHide());
34+
const columnIds = availableColumns.map((col) => col.id);
35+
36+
const order = props.columnOrder && props.columnOrder.length > 0 ? props.columnOrder : columnIds;
37+
columnOrderState.value = order;
38+
39+
const columnMap = new Map(availableColumns.map((col) => [col.id, col]));
40+
41+
const ordered = order
42+
.map((id) => columnMap.get(id))
43+
.filter((col): col is NonNullable<typeof col> => col !== undefined);
44+
45+
const missing = availableColumns.filter((col) => !order.includes(col.id));
2346
24-
const list = availableColumns.map((column) => {
25-
return {
26-
label: column.id,
27-
type: 'checkbox' as const,
28-
checked: column.getIsVisible(),
29-
onUpdateChecked(checked: boolean) {
30-
props.table?.tableApi?.getColumn?.(column.id)?.toggleVisibility(!!checked);
31-
emit('change');
32-
},
33-
onSelect(e: Event) {
34-
e.preventDefault();
35-
},
36-
};
37-
});
38-
39-
return [list];
47+
return [...ordered, ...missing];
4048
});
49+
50+
const {
51+
draggingColumnId,
52+
dragOverColumnId,
53+
handleDragStart,
54+
handleDragEnd,
55+
handleDragOver,
56+
handleDrop,
57+
} = useColumnDragDrop({
58+
columnOrder: columnOrderState,
59+
onReorder: (newOrder) => {
60+
emit('update:columnOrder', newOrder);
61+
emit('change');
62+
},
63+
});
64+
65+
function toggleColumnVisibility(columnId: string, checked: boolean | 'indeterminate') {
66+
if (checked === 'indeterminate') return;
67+
props.table?.tableApi?.getColumn?.(columnId)?.toggleVisibility(checked);
68+
emit('change');
69+
}
70+
71+
function toggleDropdown() {
72+
isOpen.value = !isOpen.value;
73+
}
4174
</script>
4275

4376
<template>
44-
<UDropdownMenu :items="items" size="md" :ui="{ content: 'z-40' }">
45-
<UButton color="neutral" variant="outline" size="md" trailing-icon="i-lucide-chevron-down">
77+
<div ref="dropdownRef" class="relative">
78+
<UButton
79+
color="neutral"
80+
variant="outline"
81+
size="md"
82+
trailing-icon="i-lucide-chevron-down"
83+
@click="toggleDropdown"
84+
>
4685
Columns
4786
</UButton>
48-
</UDropdownMenu>
87+
88+
<Transition
89+
enter-active-class="transition ease-out duration-100"
90+
enter-from-class="transform opacity-0 scale-95"
91+
enter-to-class="transform opacity-100 scale-100"
92+
leave-active-class="transition ease-in duration-75"
93+
leave-from-class="transform opacity-100 scale-100"
94+
leave-to-class="transform opacity-0 scale-95"
95+
>
96+
<div
97+
v-if="isOpen"
98+
class="ring-opacity-5 absolute left-0 z-10 mt-2 min-w-[220px] origin-top-right overflow-y-auto rounded-md bg-white shadow-lg ring-1 ring-black focus:outline-none dark:bg-gray-800"
99+
>
100+
<div class="py-1">
101+
<div
102+
v-for="column in orderedColumns"
103+
:key="column.id"
104+
:draggable="true"
105+
:class="[
106+
'flex cursor-move items-center gap-2 px-3 py-2 transition-colors select-none hover:bg-gray-50 dark:hover:bg-gray-700',
107+
draggingColumnId === column.id && 'opacity-50',
108+
dragOverColumnId === column.id && 'bg-primary-50 dark:bg-primary-900/20',
109+
]"
110+
@dragstart="(e: DragEvent) => handleDragStart(e, column.id)"
111+
@dragend="handleDragEnd"
112+
@dragover="(e: DragEvent) => handleDragOver(e, column.id)"
113+
@drop="(e: DragEvent) => handleDrop(e, column.id)"
114+
>
115+
<UIcon name="i-lucide-grip-vertical" class="h-4 w-4 text-gray-400" />
116+
<UCheckbox
117+
:model-value="column.getIsVisible()"
118+
@update:model-value="
119+
(checked: boolean | 'indeterminate') => toggleColumnVisibility(column.id, checked)
120+
"
121+
@click.stop
122+
/>
123+
<span class="flex-1 text-sm">{{ column.id }}</span>
124+
</div>
125+
</div>
126+
</div>
127+
</Transition>
128+
</div>
49129
</template>

web/src/components/Docker/DockerContainersTable.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ const { visibleFolders, expandedFolders, toggleExpandFolder, setExpandedFolders
210210
});
211211
const busyRowIds = ref<Set<string>>(new Set());
212212
const columnSizing = useStorage<Record<string, number>>('docker-table-column-sizing', {});
213+
const columnOrder = useStorage<string[]>('docker-table-column-order', []);
213214
214215
const logs = useDockerLogSessions();
215216
const contextMenu = useContextMenu<DockerContainer>();
@@ -1027,6 +1028,7 @@ function handleSelectAllChildren(row: TreeRow<DockerContainer>) {
10271028
:enable-drag-drop="!!flatEntries"
10281029
enable-resizing
10291030
v-model:column-sizing="columnSizing"
1031+
v-model:column-order="columnOrder"
10301032
:searchable-keys="searchableKeys"
10311033
:search-accessor="dockerSearchAccessor"
10321034
:can-expand="(row: TreeRow<DockerContainer>) => row.type === 'folder'"
@@ -1041,7 +1043,14 @@ function handleSelectAllChildren(row: TreeRow<DockerContainer>) {
10411043
@row:drop="handleDropOnRow"
10421044
@update:selected-ids="handleUpdateSelectedIds"
10431045
>
1044-
<template #toolbar="{ selectedCount: count, globalFilter: filterText, setGlobalFilter }">
1046+
<template
1047+
#toolbar="{
1048+
selectedCount: count,
1049+
globalFilter: filterText,
1050+
setGlobalFilter,
1051+
columnOrder: tableColumnOrder,
1052+
}"
1053+
>
10451054
<div :class="['mb-4 flex flex-wrap items-center gap-2', compact ? 'sm:px-0.5' : '']">
10461055
<UInput
10471056
:model-value="filterText"
@@ -1054,7 +1063,9 @@ function handleSelectAllChildren(row: TreeRow<DockerContainer>) {
10541063
<TableColumnMenu
10551064
v-if="!compact"
10561065
:table="baseTableRef"
1066+
:column-order="tableColumnOrder"
10571067
@change="persistCurrentColumnVisibility"
1068+
@update:column-order="(order) => (columnOrder = order)"
10581069
/>
10591070
<UDropdownMenu
10601071
:items="bulkItems"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ref } from 'vue';
2+
3+
import type { Ref } from 'vue';
4+
5+
export interface ColumnDragDropOptions {
6+
columnOrder: Ref<string[]>;
7+
onReorder: (newOrder: string[]) => void;
8+
}
9+
10+
export function useColumnDragDrop(options: ColumnDragDropOptions) {
11+
const { columnOrder, onReorder } = options;
12+
13+
const draggingColumnId = ref<string | null>(null);
14+
const dragOverColumnId = ref<string | null>(null);
15+
16+
function handleDragStart(e: DragEvent, columnId: string) {
17+
draggingColumnId.value = columnId;
18+
if (e.dataTransfer) {
19+
e.dataTransfer.effectAllowed = 'move';
20+
e.dataTransfer.setData('text/plain', columnId);
21+
}
22+
}
23+
24+
function handleDragEnd() {
25+
draggingColumnId.value = null;
26+
dragOverColumnId.value = null;
27+
}
28+
29+
function handleDragOver(e: DragEvent, targetColumnId: string) {
30+
e.preventDefault();
31+
if (!draggingColumnId.value || draggingColumnId.value === targetColumnId) return;
32+
33+
dragOverColumnId.value = targetColumnId;
34+
35+
if (e.dataTransfer) {
36+
e.dataTransfer.dropEffect = 'move';
37+
}
38+
}
39+
40+
function handleDrop(e: DragEvent, targetColumnId: string) {
41+
e.preventDefault();
42+
43+
const sourceColumnId = draggingColumnId.value;
44+
if (!sourceColumnId || sourceColumnId === targetColumnId) {
45+
handleDragEnd();
46+
return;
47+
}
48+
49+
const newOrder = [...columnOrder.value];
50+
const sourceIndex = newOrder.indexOf(sourceColumnId);
51+
const targetIndex = newOrder.indexOf(targetColumnId);
52+
53+
if (sourceIndex === -1 || targetIndex === -1) {
54+
handleDragEnd();
55+
return;
56+
}
57+
58+
newOrder.splice(sourceIndex, 1);
59+
newOrder.splice(targetIndex, 0, sourceColumnId);
60+
61+
onReorder(newOrder);
62+
handleDragEnd();
63+
}
64+
65+
return {
66+
draggingColumnId,
67+
dragOverColumnId,
68+
handleDragStart,
69+
handleDragEnd,
70+
handleDragOver,
71+
handleDrop,
72+
};
73+
}

0 commit comments

Comments
 (0)