mirror of
https://github.com/unraid/api.git
synced 2026-01-23 17:08:36 -06:00
use compacted table as sidebar tree
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user