click overview row to open details

This commit is contained in:
Pujit Mehrotra
2025-09-25 14:51:11 -04:00
parent 855e4656ec
commit ca2579216f
3 changed files with 210 additions and 119 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
import DockerSidebarTree from '@/components/Docker/DockerSidebarTree.vue';
import DockerEdit from '@/components/Docker/Edit.vue';
import DockerLogs from '@/components/Docker/Logs.vue';
@@ -28,7 +29,7 @@ const selectedIds = ref<string[]>([]);
const activeId = ref<string | null>(null);
const isSwitching = ref(false);
const { result, loading, error, refetch } = useQuery<{
const { result, loading, refetch } = useQuery<{
docker: {
id: string;
organizer: {
@@ -42,12 +43,15 @@ const { result, loading, error, refetch } = useQuery<{
};
}>(GET_DOCKER_CONTAINERS, {
fetchPolicy: 'cache-and-network',
variables: { skipCache: true },
});
const organizerRoot = computed(
() => result.value?.docker?.organizer?.views?.[0]?.root as ResolvedOrganizerFolder | undefined
);
const containers = computed<DockerContainer[]>(() => result.value?.docker?.containers || []);
function findContainerResourceById(
entry: ResolvedOrganizerEntry | undefined,
id: string
@@ -63,27 +67,28 @@ function findContainerResourceById(
return undefined;
}
function findFirstContainerId(entry?: ResolvedOrganizerEntry | null): string | null {
if (!entry) return null;
if (entry.__typename === 'OrganizerContainerResource') return entry.id;
if (entry.__typename === 'ResolvedOrganizerFolder') {
for (const child of entry.children) {
const found = findFirstContainerId(child);
if (found) return found;
}
}
return null;
function handleTableRowClick(payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
}) {
if (payload.type !== 'container') return;
if (activeId.value === payload.id) return;
isSwitching.value = true;
activeId.value = payload.id;
setTimeout(() => {
isSwitching.value = false;
}, 150);
}
watch(
() => organizerRoot.value,
(root) => {
if (!activeId.value) {
activeId.value = findFirstContainerId(root);
}
},
{ immediate: true }
);
function handleUpdateSelectedIds(ids: string[]) {
selectedIds.value = ids;
}
function goBackToOverview() {
activeId.value = null;
}
function handleSidebarClick(item: { id: string }) {
if (activeId.value === item.id) return;
@@ -143,89 +148,117 @@ const isDetailsDisabled = computed(() => props.disabled || isSwitching.value);
</script>
<template>
<div class="grid gap-6 md:grid-cols-[280px_1fr]">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="font-medium">Containers</div>
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="
() => {
refetch({ skipCache: true });
}
"
/>
</div>
</template>
<USkeleton v-if="loading && !organizerRoot" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
<DockerSidebarTree
v-else
:root="organizerRoot"
:selected-ids="selectedIds"
<div>
<div v-if="!activeId">
<div class="mb-4 flex items-center justify-between">
<div class="text-base font-medium">Docker Containers</div>
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="refetch({ skipCache: true })"
/>
</div>
<DockerContainersTable
:containers="containers"
:organizer-root="organizerRoot"
:loading="loading"
:active-id="activeId"
:disabled="props.disabled || loading"
@item:click="handleSidebarClick"
@item:select="handleSidebarSelect"
:selected-ids="selectedIds"
@created-folder="() => refetch({ skipCache: true })"
@row:click="handleTableRowClick"
@update:selectedIds="handleUpdateSelectedIds"
/>
</UCard>
</div>
<div>
<UCard class="mb-4">
<div v-else class="grid gap-6 md:grid-cols-[280px_1fr]">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="font-medium">Overview</div>
<UBadge
v-if="activeContainer?.state"
:label="activeContainer.state"
color="primary"
variant="subtle"
<div class="font-medium">Containers</div>
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="refetch({ skipCache: true })"
/>
</div>
</template>
<div class="relative">
<div v-if="isDetailsLoading" class="pointer-events-none opacity-50">
<DockerOverview :item="detailsItem" :details="details" />
</div>
<DockerOverview v-else :item="detailsItem" :details="details" />
<div v-if="isDetailsLoading" class="absolute inset-0 grid place-items-center">
<ULoader class="h-6 w-6" />
</div>
</div>
<USkeleton v-if="loading && !organizerRoot" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
<DockerSidebarTree
v-else
:root="organizerRoot"
:selected-ids="selectedIds"
:active-id="activeId"
:disabled="props.disabled || loading"
@item:click="handleSidebarClick"
@item:select="handleSidebarSelect"
/>
</UCard>
<div class="3xl:grid-cols-2 grid gap-4">
<UCard>
<div>
<UCard class="mb-4">
<template #header>
<div class="font-medium">Preview</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-arrow-left"
@click="goBackToOverview"
/>
<div class="font-medium">Overview</div>
</div>
<UBadge
v-if="activeContainer?.state"
:label="activeContainer.state"
color="primary"
variant="subtle"
/>
</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerPreview :item="detailsItem" :port="details?.containerPort || undefined" />
<div class="relative">
<div v-if="isDetailsLoading" class="pointer-events-none opacity-50">
<DockerOverview :item="detailsItem" :details="details" />
</div>
<DockerOverview v-else :item="detailsItem" :details="details" />
<div v-if="isDetailsLoading" class="absolute inset-0 grid place-items-center">
<USkeleton class="h-6 w-6" />
</div>
</div>
</UCard>
<UCard>
<div class="3xl:grid-cols-2 grid gap-4">
<UCard>
<template #header>
<div class="font-medium">Preview</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerPreview :item="detailsItem" :port="details?.containerPort || undefined" />
</div>
</UCard>
<UCard>
<template #header>
<div class="font-medium">Edit</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerEdit :item="detailsItem" />
</div>
</UCard>
</div>
<UCard class="mt-4">
<template #header>
<div class="font-medium">Edit</div>
<div class="font-medium">Logs</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerEdit :item="detailsItem" />
<DockerLogs :item="detailsItem" />
</div>
</UCard>
</div>
<UCard class="mt-4">
<template #header>
<div class="font-medium">Logs</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerLogs :item="detailsItem" />
</div>
</UCard>
</div>
</div>
</template>

View File

@@ -21,10 +21,10 @@ const details = {
<template>
<div class="grid gap-8">
<DockerContainerManagement />
<DockerContainerOverview />
<DockerOverview :item="item" :details="details" />
<!-- <DockerContainerOverview /> -->
<!-- <DockerOverview :item="item" :details="details" />
<DockerPreview :item="item" />
<DockerEdit :item="item" />
<DockerLogs :item="item" />
<DockerLogs :item="item" /> -->
</div>
</template>

View File

@@ -197,7 +197,14 @@ function wrapCell(row: { original: TreeRow; depth?: number }, child: VNode) {
? 'ring-2 ring-primary/40'
: ''
}`,
onClick: () => {
onClick: (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (
target &&
target.closest('input,button,textarea,a,[role=checkbox],[role=button],[data-stop-row-click]')
) {
return;
}
const r = row.original as TreeRow;
emit('row:click', { id: r.id, type: r.type, name: r.name, containerId: r.containerId });
},
@@ -268,39 +275,58 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
const cols: TableColumn<TreeRow>[] = [
{
id: 'select',
header: ({ table }) =>
props.compact
? ''
: h(UCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
}),
header: () => {
if (props.compact) return '';
const containers = flattenContainerRows(treeData.value);
const totalSelectable = containers.length;
const selectedIds = Object.entries(rowSelection.value)
.filter(([, selected]) => selected)
.map(([id]) => id);
const selectedSet = new Set(selectedIds);
const selectedCount = containers.reduce(
(count, row) => (selectedSet.has(row.id) ? count + 1 : count),
0
);
const allSelected = totalSelectable > 0 && selectedCount === totalSelectable;
const someSelected = selectedCount > 0 && !allSelected;
return h(UCheckbox, {
modelValue: someSelected ? 'indeterminate' : allSelected,
'onUpdate:modelValue': () => {
const target = someSelected || allSelected ? false : true;
const next: Record<string, boolean> = {};
if (target) {
for (const row of containers) next[row.id] = true;
}
rowSelection.value = next;
},
'aria-label': 'Select all',
});
},
cell: ({ row }) => {
switch ((row.original as TreeRow).type) {
case 'container':
return wrapCell(
row,
h(UCheckbox, {
modelValue: row.getIsSelected(),
'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(),
})
h('span', { 'data-stop-row-click': 'true' }, [
h(UCheckbox, {
modelValue: row.getIsSelected(),
'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',
role: 'checkbox',
onClick: (e: Event) => e.stopPropagation(),
}),
])
);
case 'folder':
return wrapCell(
@@ -525,6 +551,13 @@ const emit = defineEmits<{
(e: 'update:selectedIds', value: string[]): void;
}>();
function arraysEqualAsSets(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const setA = new Set(a);
for (const id of b) if (!setA.has(id)) return false;
return true;
}
function flattenContainerRows(rows: TreeRow[]): TreeRow[] {
const out: TreeRow[] = [];
for (const r of rows) {
@@ -534,11 +567,21 @@ function flattenContainerRows(rows: TreeRow[]): TreeRow[] {
return out;
}
// Sync external selectedIds into table rowSelection
// Sync external selectedIds into table rowSelection (driven only by prop changes)
watch(
[() => props.selectedIds, treeData],
() => {
const target = new Set(props.selectedIds || []);
() => props.selectedIds,
(newVal) => {
if (
arraysEqualAsSets(
newVal || [],
Object.entries(rowSelection.value)
.filter(([, s]) => s)
.map(([id]) => id)
)
) {
return;
}
const target = new Set(newVal || []);
const next: Record<string, boolean> = {};
for (const r of flattenContainerRows(treeData.value)) {
next[r.id] = target.has(r.id);
@@ -548,6 +591,20 @@ watch(
{ immediate: true }
);
// When tree changes, preserve existing selection for still-present rows
watch(
treeData,
() => {
const valid = new Set(flattenContainerRows(treeData.value).map((r) => r.id));
const next: Record<string, boolean> = {};
for (const [id, selected] of Object.entries(rowSelection.value)) {
if (valid.has(id) && selected) next[id] = true;
}
rowSelection.value = next;
},
{ deep: false }
);
// Emit external selectedIds when selection changes
watch(
rowSelection,
@@ -1368,6 +1425,7 @@ async function deleteFolderById(id: string) {
:columns="columns"
:get-row-id="(row: any) => row.id"
:get-sub-rows="(row: any) => row.children"
:get-row-can-select="(row: any) => row.original.type === 'container'"
:column-filters-options="{ filterFromLeafRows: true }"
:loading="loading"
:ui="{ td: 'p-0 empty:p-0', thead: compact ? 'hidden' : '', th: compact ? 'hidden' : '' }"