use compacted table as sidebar tree

This commit is contained in:
Pujit Mehrotra
2025-09-24 11:28:42 -04:00
parent a551859f0d
commit 6f7bddce4c
2 changed files with 147 additions and 249 deletions

View File

@@ -26,10 +26,16 @@ interface Props {
containers: DockerContainer[];
organizerRoot?: ResolvedOrganizerFolder;
loading?: boolean;
compact?: boolean;
activeId?: string | null;
selectedIds?: string[];
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
compact: false,
activeId: null,
selectedIds: () => [],
});
const UButton = resolveComponent('UButton');
@@ -133,13 +139,22 @@ type CheckboxDropdownItem = {
type DropdownMenuItem = ActionDropdownItem | CheckboxDropdownItem;
type DropdownMenuItems = DropdownMenuItem[][];
function wrapCell(row: { original: TreeRow }, child: VNode) {
function wrapCell(row: { original: TreeRow; depth?: number }, child: VNode) {
const isBusy = busyRowIds.value.has((row.original as TreeRow).id);
const isActive = props.activeId !== null && props.activeId === (row.original as TreeRow).id;
const content = h(
'div',
{
'data-row-id': row.original.id,
class: `block w-full h-full px-3 py-2 ${isBusy ? 'opacity-50 pointer-events-none select-none' : ''}`,
class: `block w-full h-full px-3 py-2 ${
isBusy ? 'opacity-50 pointer-events-none select-none' : ''
} ${isActive ? 'bg-primary-50 dark:bg-primary-950/30' : ''} ${
(row.original as TreeRow).type === 'container' ? 'cursor-pointer' : ''
}`,
onClick: () => {
const r = row.original as TreeRow;
emit('row:click', { id: r.id, type: r.type, name: r.name, containerId: r.containerId });
},
},
[child]
);
@@ -165,14 +180,16 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
{
id: 'select',
header: ({ table }) =>
h(UCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
}),
props.compact
? ''
: h(UCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
}),
cell: ({ row }) => {
switch ((row.original as TreeRow).type) {
case 'container':
@@ -180,8 +197,20 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
row,
h(UCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => {
const next = !!value;
row.toggleSelected(next);
const r = row.original as TreeRow;
emit('row:select', {
id: r.id,
type: r.type,
name: r.name,
containerId: r.containerId,
selected: next,
});
},
'aria-label': 'Select row',
onClick: (e: Event) => e.stopPropagation(),
})
);
case 'folder':
@@ -201,7 +230,10 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
row.getIsExpanded() ? 'duration-200 rotate-0' : '',
],
},
onClick: () => row.toggleExpanded(),
onClick: (e: Event) => {
e.stopPropagation();
row.toggleExpanded();
},
})
);
default:
@@ -214,7 +246,7 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
},
{
accessorKey: 'name',
header: 'Name',
header: props.compact ? '' : 'Name',
cell: ({ row }) => {
const depth = row.depth;
const indent = h('span', { class: 'inline-block', style: { width: `calc(${depth} * 1rem)` } });
@@ -335,6 +367,23 @@ watch(
{ deep: true }
);
// Compact mode defaults: only select + name columns visible
watch(
() => props.compact,
(isCompact) => {
if (isCompact) {
columnVisibility.value = {
state: false,
ports: false,
autoStart: false,
updates: false,
actions: false,
};
}
},
{ immediate: true }
);
const columnsMenuItems = computed<DropdownMenuItems>(() => {
const keysFromColumns = (columns.value || [])
.map((col: TableColumn<TreeRow>) => {
@@ -371,8 +420,55 @@ const columnsMenuItems = computed<DropdownMenuItems>(() => {
const emit = defineEmits<{
(e: 'created-folder'): void;
(
e: 'row:click',
payload: { id: string; type: 'container' | 'folder'; name: string; containerId?: string }
): void;
(
e: 'row:select',
payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
selected: boolean;
}
): void;
(e: 'update:selectedIds', value: string[]): void;
}>();
function flattenContainerRows(rows: TreeRow[]): TreeRow[] {
const out: TreeRow[] = [];
for (const r of rows) {
if (r.type === 'container') out.push(r);
if (r.children && r.children.length) out.push(...flattenContainerRows(r.children as TreeRow[]));
}
return out;
}
// Sync external selectedIds into table rowSelection
watch(
[() => props.selectedIds, treeData],
() => {
const target = new Set(props.selectedIds || []);
const next: Record<string, boolean> = {};
for (const r of flattenContainerRows(treeData.value)) {
next[r.id] = target.has(r.id);
}
rowSelection.value = next;
},
{ immediate: true }
);
// Emit external selectedIds when selection changes
watch(
rowSelection,
() => {
emit('update:selectedIds', getSelectedContainerIds());
},
{ deep: true }
);
const { mutate: createFolderMutation, loading: creating } = useMutation(CREATE_DOCKER_FOLDER);
const { mutate: moveEntriesMutation, loading: moving } = useMutation(MOVE_DOCKER_ENTRIES_TO_FOLDER);
const { mutate: deleteEntriesMutation, loading: deleting } = useMutation(DELETE_DOCKER_ENTRIES);
@@ -953,7 +1049,7 @@ function getRowActionItems(row: TreeRow): DropdownMenuItems {
<template>
<div class="w-full">
<div class="mb-3 flex items-center gap-2">
<div v-if="!compact" class="mb-3 flex items-center gap-2">
<UInput v-model="globalFilter" class="max-w-sm min-w-[12ch]" placeholder="Filter..." />
<UDropdownMenu :items="columnsMenuItems" size="md" :ui="{ content: 'z-40' }">
<UButton color="neutral" variant="outline" size="md" trailing-icon="i-lucide-chevron-down">
@@ -990,7 +1086,7 @@ function getRowActionItems(row: TreeRow): DropdownMenuItems {
:get-sub-rows="(row: any) => row.children"
:column-filters-options="{ filterFromLeafRows: true }"
:loading="loading"
:ui="{ td: 'p-0 empty:p-0' }"
:ui="{ td: 'p-0 empty:p-0', thead: compact ? 'hidden' : '', th: compact ? 'hidden' : '' }"
sticky
class="flex-1"
/>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { Box, ChevronRight, Cog, FolderOpen, FolderTree } from 'lucide-vue-next';
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
import type { ResolvedOrganizerEntry, ResolvedOrganizerFolder } from '@/composables/gql/graphql';
import type { ResolvedOrganizerFolder } from '@/composables/gql/graphql';
interface Emits {
(e: 'item:click', item: { id: string; type: string; name: string }): void;
@@ -25,246 +25,48 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>();
const isSelected = (id: string) => props.selectedIds?.includes(id);
const containers = computed(() => []);
function onClickEntry(entry: ResolvedOrganizerEntry) {
if (props.disabled) return;
if (entry.__typename === 'OrganizerContainerResource') {
emit('item:click', { id: entry.id, type: entry.type, name: entry.name });
function onRowClick(payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
}) {
if (payload.type === 'container') {
emit('item:click', { id: payload.id, type: payload.type, name: payload.name });
}
}
function isFolderEntry(entry: ResolvedOrganizerEntry): entry is ResolvedOrganizerFolder {
return entry.__typename === 'ResolvedOrganizerFolder';
}
function entryName(entry: ResolvedOrganizerEntry): string {
if (isFolderEntry(entry)) return entry.name;
if (entry.__typename === 'OrganizerContainerResource') return entry.name;
return '';
}
function onSelectEntry(entry: ResolvedOrganizerEntry, selected: boolean) {
emit('item:select', { id: entry.id, type: entry.type, name: entryName(entry), selected });
}
function onCheckboxChange(entry: ResolvedOrganizerEntry, value: boolean | 'indeterminate') {
onSelectEntry(entry, value === true);
}
const hasChildren = (entry: ResolvedOrganizerEntry) =>
isFolderEntry(entry) && entry.children && entry.children.length > 0;
const entries = computed(() => props.root?.children ?? []);
const expandedIds = ref<Set<string>>(new Set());
const isExpanded = (id: string) => expandedIds.value.has(id);
function toggleExpanded(id: string) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id);
} else {
expandedIds.value.add(id);
}
expandedIds.value = new Set(expandedIds.value);
}
function expand(id: string) {
if (!expandedIds.value.has(id)) {
expandedIds.value.add(id);
expandedIds.value = new Set(expandedIds.value);
}
}
function flattenContainerEntries(entry: ResolvedOrganizerEntry): ResolvedOrganizerEntry[] {
if (entry.__typename === 'OrganizerContainerResource') return [entry];
if (isFolderEntry(entry)) {
const children = entry.children ?? [];
return children.flatMap((child) => flattenContainerEntries(child));
}
return [];
}
function folderSelectionState(folder: ResolvedOrganizerFolder): 'none' | 'partial' | 'all' {
const containers = flattenContainerEntries(folder);
if (containers.length === 0) return 'none';
const selectedCount = containers.reduce((acc, e) => acc + (isSelected(e.id) ? 1 : 0), 0);
if (selectedCount === 0) return 'none';
if (selectedCount === containers.length) return 'all';
return 'partial';
}
function onFolderCheckboxChange(folder: ResolvedOrganizerFolder, value: boolean | 'indeterminate') {
const containers = flattenContainerEntries(folder);
const shouldSelectAll =
value === true || (value === 'indeterminate' && folderSelectionState(folder) !== 'all');
if (shouldSelectAll) expand(folder.id);
for (const entry of containers) {
const currentlySelected = isSelected(entry.id);
if (shouldSelectAll && !currentlySelected) onSelectEntry(entry, true);
if (!shouldSelectAll && currentlySelected) onSelectEntry(entry, false);
function onRowSelect(payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
selected: boolean;
}) {
if (payload.type === 'container') {
emit('item:select', {
id: payload.id,
type: payload.type,
name: payload.name,
selected: payload.selected,
});
}
}
</script>
<template>
<div class="space-y-2">
<div v-if="!root" class="text-gray-500 dark:text-gray-400">No items</div>
<ul v-else class="space-y-1">
<li v-for="entry in entries" :key="entry.id">
<div class="flex items-center gap-2">
<button
v-if="isFolderEntry(entry)"
class="inline-block h-4 w-4 transition-transform"
:class="isExpanded(entry.id) ? 'rotate-90' : ''"
:aria-expanded="isExpanded(entry.id) ? 'true' : 'false'"
:disabled="disabled === true"
@click="toggleExpanded(entry.id)"
>
<ChevronRight class="h-4 w-4" />
</button>
<span v-else class="inline-block h-4 w-4" />
<UCheckbox
v-if="!isFolderEntry(entry)"
:model-value="isSelected(entry.id)"
:disabled="disabled === true"
@click.stop
@update:model-value="(v) => onCheckboxChange(entry, v)"
/>
<UCheckbox
v-else
:indeterminate="folderSelectionState(entry) === 'partial'"
:model-value="folderSelectionState(entry) === 'all'"
:disabled="disabled === true"
@click.stop
@update:model-value="(v) => onFolderCheckboxChange(entry, v)"
/>
<button
class="flex-1 flex-row justify-around truncate"
:class="[
isFolderEntry(entry)
? 'cursor-pointer text-gray-700 dark:text-gray-200'
: 'cursor-pointer',
activeId === entry.id ? 'text-primary-600 dark:text-primary-400 font-medium' : '',
]"
:disabled="disabled === true"
@click="isFolderEntry(entry) ? toggleExpanded(entry.id) : onClickEntry(entry)"
>
<FolderTree
v-if="isFolderEntry(entry)"
class="mr-1 inline-block h-4 w-4 align-middle text-amber-600 dark:text-amber-400"
/>
<Box
v-else
class="mr-1 inline-block h-4 w-4 align-middle text-blue-600 dark:text-blue-400"
/>
{{ entryName(entry) }}
<FolderOpen
v-if="isFolderEntry(entry)"
class="ml-2 inline-block h-4 w-4 align-middle text-amber-600/80 dark:text-amber-400/80"
/>
<Cog
v-else
class="ml-2 inline-block h-4 w-4 align-middle text-blue-600/70 dark:text-blue-400/70"
/>
</button>
</div>
<ul v-if="hasChildren(entry) && isExpanded(entry.id)" class="mt-1 ml-5 space-y-1">
<li v-for="child in (entry as any).children" :key="child.id">
<div class="flex items-center gap-2 text-sm md:text-[0.95rem]">
<button
v-if="isFolderEntry(child)"
class="inline-block h-4 w-4 transition-transform"
:class="isExpanded(child.id) ? 'rotate-90' : ''"
:aria-expanded="isExpanded(child.id) ? 'true' : 'false'"
:disabled="disabled === true"
@click="toggleExpanded(child.id)"
>
<ChevronRight class="h-4 w-4" />
</button>
<span v-else class="inline-block h-4 w-4" />
<UCheckbox
v-if="!isFolderEntry(child)"
:model-value="isSelected(child.id)"
:disabled="disabled === true"
@click.stop
@update:model-value="(v) => onCheckboxChange(child, v)"
/>
<UCheckbox
v-else
:indeterminate="folderSelectionState(child) === 'partial'"
:model-value="folderSelectionState(child) === 'all'"
:disabled="disabled === true"
@click.stop
@update:model-value="(v) => onFolderCheckboxChange(child, v)"
/>
<button
class="flex-1 truncate text-left"
:class="[
isFolderEntry(child)
? 'cursor-pointer text-gray-700 dark:text-gray-200'
: 'cursor-pointer',
activeId === child.id ? 'text-primary-600 dark:text-primary-400 font-medium' : '',
]"
:disabled="disabled === true"
@click="isFolderEntry(child) ? toggleExpanded(child.id) : onClickEntry(child)"
>
<FolderTree
v-if="isFolderEntry(child)"
class="mr-1 inline-block h-4 w-4 align-middle text-amber-600 dark:text-amber-400"
/>
<Box
v-else
class="mr-1 inline-block h-4 w-4 align-middle text-blue-600 dark:text-blue-400"
/>
{{ entryName(child) }}
<FolderOpen
v-if="isFolderEntry(child)"
class="ml-2 inline-block h-4 w-4 align-middle text-amber-600/80 dark:text-amber-400/80"
/>
<Cog
v-else
class="ml-2 inline-block h-4 w-4 align-middle text-blue-600/70 dark:text-blue-400/70"
/>
</button>
</div>
<ul v-if="hasChildren(child) && isExpanded(child.id)" class="mt-1 ml-5 space-y-1">
<li v-for="grand in (child as any).children" :key="grand.id">
<div class="flex items-center gap-2 text-sm md:text-[0.95rem]">
<span class="inline-block h-4 w-4" />
<UCheckbox
:model-value="isSelected(grand.id)"
:disabled="disabled === true"
@click.stop
@update:model-value="(v) => onCheckboxChange(grand, v)"
/>
<button
class="flex-1 truncate text-left"
:class="[
activeId === grand.id ? 'text-primary-600 dark:text-primary-400 font-medium' : '',
]"
:disabled="disabled === true"
@click="onClickEntry(grand)"
>
<Box
class="mr-1 inline-block h-4 w-4 align-middle text-blue-600 dark:text-blue-400"
/>
{{ entryName(grand) }}
<Cog
class="ml-2 inline-block h-4 w-4 align-middle text-blue-600/70 dark:text-blue-400/70"
/>
</button>
</div>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<DockerContainersTable
:containers="containers"
:organizer-root="root"
compact
:active-id="activeId"
:selected-ids="selectedIds"
:loading="props.disabled"
@row:click="onRowClick"
@row:select="onRowSelect"
/>
</div>
</template>