test: create proper test setup for Detail layout

This commit is contained in:
mdatelle
2025-07-24 14:47:02 -04:00
committed by Eli Bosley
parent cd15e12cdd
commit 4a0b481a2d
3 changed files with 140 additions and 404 deletions

View File

@@ -3,4 +3,4 @@ Title="Detail Layout"
Icon="icon-u-globe"
Tag="globe"
---
<unraid-detail></unraid-detail>
<unraid-detail-test></unraid-detail-test>

View File

@@ -1,403 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
UBadge,
UButton,
UCheckbox,
UDropdownMenu,
UIcon,
UNavigationMenu,
USwitch,
UTabs,
} from '#components';
import type { Component } from 'vue';
interface NavigationItem {
id: string;
label: string;
icon?: string;
badge?: string | number;
slot?: string;
status?: {
label: string;
dotColor: string;
}[];
children?: NavigationItem[];
isGroup?: boolean;
}
interface NavigationMenuItem {
id: string;
label: string;
icon?: string;
badge?: string;
slot?: string;
onClick?: () => void;
isGroup?: boolean;
status?: {
label: string;
dotColor: string;
}[];
children?: NavigationMenuItem[];
to?: string;
defaultOpen?: boolean;
}
interface TabItem {
key: string;
label: string;
component?: Component;
props?: Record<string, unknown>;
disabled?: boolean;
}
interface Props {
navigationItems?: NavigationItem[];
tabs?: TabItem[];
defaultNavigationId?: string;
defaultTabKey?: string;
}
const props = withDefaults(defineProps<Props>(), {
navigationItems: () => [],
tabs: () => [],
defaultNavigationId: undefined,
defaultTabKey: undefined,
});
const selectedNavigationId = ref(props.defaultNavigationId || props.navigationItems[0]?.id || '');
const selectedTab = ref(props.defaultTabKey || '0');
const selectedItems = ref<string[]>([]);
const expandedGroups = ref<Record<string, boolean>>({});
// Initialize expanded state for groups (defaultOpen = true)
const initializeExpandedState = () => {
props.navigationItems.forEach((item) => {
if (item.isGroup) {
expandedGroups.value[item.id] = true; // Start expanded
console.log(`Initialized group ${item.id} as expanded:`, true);
}
});
console.log('Initial expandedGroups state:', expandedGroups.value);
};
initializeExpandedState();
// Watch for changes in navigation items to reinitialize expanded state
watch(
() => props.navigationItems,
() => {
initializeExpandedState();
},
{ deep: true }
);
const selectedNavigationItem = computed(() => {
const topLevel = props.navigationItems.find((item) => item.id === selectedNavigationId.value);
if (topLevel) return topLevel;
for (const item of props.navigationItems) {
if (item.children) {
const nested = item.children.find((child) => child.id === selectedNavigationId.value);
if (nested) return nested;
}
}
return undefined;
});
const navigationMenuItems = computed((): NavigationMenuItem[] =>
props.navigationItems.map((item) => ({
label: item.label,
icon: item.icon,
id: item.id,
badge: String(item.badge || ''),
slot: item.slot,
// Only add onClick for non-group items
...(item.isGroup ? {} : { onClick: () => selectNavigationItem(item.id) }),
isGroup: item.isGroup,
status: item.status,
// Only add 'to' for non-group items to preserve chevron arrow for groups
...(item.isGroup ? {} : { to: '#' }),
defaultOpen: item.isGroup ? true : undefined,
children: item.children?.map((child) => ({
label: child.label,
icon: child.icon,
id: child.id,
badge: String(child.badge || ''),
slot: child.slot,
onClick: () => selectNavigationItem(child.id),
status: child.status,
to: '#',
})),
}))
);
const toggleItemSelection = (itemId: string) => {
const index = selectedItems.value.indexOf(itemId);
if (index > -1) {
selectedItems.value.splice(index, 1);
} else {
selectedItems.value.push(itemId);
}
};
const isItemSelected = (itemId: string) => {
return selectedItems.value.includes(itemId);
};
const tabItems = computed(() =>
props.tabs.map((tab) => ({
label: tab.label,
key: tab.key,
disabled: tab.disabled,
}))
);
const selectNavigationItem = (id: string) => {
// Don't select group items - they should only toggle expansion
const actualItem =
props.navigationItems.find((item) => item.id === id) ||
props.navigationItems.flatMap((item) => item.children || []).find((child) => child.id === id);
if (actualItem && !actualItem.isGroup) {
selectedNavigationId.value = id;
selectedTab.value = '0'; // Reset to first tab index
}
};
const toggleGroupExpansion = (groupId: string) => {
expandedGroups.value[groupId] = !expandedGroups.value[groupId];
console.log(`Manually toggled group ${groupId} to:`, expandedGroups.value[groupId]);
};
// Select all functionality
const selectAllItems = () => {
const allSelectableItems: string[] = [];
const collectSelectableItems = (items: NavigationItem[]) => {
for (const item of items) {
if (!item.isGroup) {
allSelectableItems.push(item.id);
}
if (item.children) {
collectSelectableItems(item.children);
}
}
};
collectSelectableItems(props.navigationItems);
selectedItems.value = [...allSelectableItems];
};
const clearAllSelections = () => {
selectedItems.value = [];
};
const allItemsSelected = computed(() => {
const allSelectableItems: string[] = [];
const collectSelectableItems = (items: NavigationItem[]) => {
for (const item of items) {
if (!item.isGroup) {
allSelectableItems.push(item.id);
}
if (item.children) {
collectSelectableItems(item.children);
}
}
};
collectSelectableItems(props.navigationItems);
return (
allSelectableItems.length > 0 && allSelectableItems.every((id) => selectedItems.value.includes(id))
);
});
const selectedItemsCount = computed(() => selectedItems.value.length);
// Helper to get all items with slots (including nested children)
const allItemsWithSlots = computed(() => {
const items: NavigationMenuItem[] = [];
const collectItems = (navItems: NavigationMenuItem[]) => {
for (const item of navItems) {
if (item.slot) {
items.push(item);
}
if (item.children) {
collectItems(item.children);
}
}
};
collectItems(navigationMenuItems.value);
return items;
});
// UTabs uses index, so convert to tab key
const getCurrentTabComponent = () => {
const tabIndex = parseInt(selectedTab.value);
return props.tabs[tabIndex]?.component;
};
const getCurrentTabProps = () => {
const tabIndex = parseInt(selectedTab.value);
const currentTab = props.tabs[tabIndex];
return {
item: selectedNavigationItem.value,
...currentTab?.props,
};
};
</script>
<template>
<div class="flex h-full gap-6">
<!-- Left Navigation Section -->
<div class="mr-8 w-64 flex-shrink-0">
<!-- Header Section -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Service Name</h2>
<UButton icon="i-lucide-plus" size="sm" color="primary" variant="ghost" square />
</div>
<div class="flex items-center justify-between">
<UButton
variant="link"
color="primary"
size="sm"
:label="allItemsSelected ? 'Clear all' : 'Select all'"
@click="allItemsSelected ? clearAllSelections() : selectAllItems()"
/>
<UDropdownMenu
:items="[
[{ label: 'Start', icon: 'i-lucide-play' }],
[{ label: 'Stop', icon: 'i-lucide-square' }],
[{ label: 'Restart', icon: 'i-lucide-refresh-cw' }],
[{ label: 'Remove', icon: 'i-lucide-trash-2' }],
]"
>
<UButton
variant="subtle"
color="primary"
size="sm"
trailing-icon="i-lucide-chevron-down"
:disabled="selectedItemsCount === 0"
>
Manage Selected ({{ selectedItemsCount }})
</UButton>
</UDropdownMenu>
</div>
</div>
<UNavigationMenu :items="navigationMenuItems" orientation="vertical">
<!-- Dynamic nav item slots -->
<template v-for="item in allItemsWithSlots" :key="`slot-${item.id}`" #[item.slot!]>
<div
class="flex items-center gap-3 mb-2"
@click="
item.children && item.children.length > 0 ? toggleGroupExpansion(item.id) : undefined
"
>
<UCheckbox
:model-value="isItemSelected(item.id)"
@update:model-value="toggleItemSelection(item.id)"
@click.stop
/>
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5" />
<span class="truncate flex-1">{{ item.label }}</span>
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" />
<UIcon
v-if="item.children?.length"
name="i-lucide-chevron-down"
:class="[
'h-5 w-5 text-gray-400 transition-transform duration-200',
expandedGroups[item.id] ? 'rotate-180' : 'rotate-0',
]"
/>
</div>
</template>
</UNavigationMenu>
</div>
<!-- Right Content Section -->
<div class="flex-1 min-w-0">
<div v-if="selectedNavigationItem" class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon
v-if="selectedNavigationItem.icon"
:name="selectedNavigationItem.icon"
class="h-8 w-8"
/>
<h1 class="text-2xl mr-2 font-semibold text-gray-900 dark:text-white">
{{ selectedNavigationItem.label }}
</h1>
<!-- Status Indicators -->
<div v-if="selectedNavigationItem.status" class="flex items-center gap-4">
<UBadge
v-for="(statusItem, index) in selectedNavigationItem.status"
:key="index"
variant="subtle"
color="neutral"
size="sm"
>
<div :class="['h-2 w-2 rounded-full mr-2', statusItem.dotColor]" />
{{ statusItem.label }}
</UBadge>
</div>
</div>
<!-- Right Side Controls -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-3">
<span class="text-sm font-medium">Autostart</span>
<USwitch :model-value="true" />
</div>
<!-- Manage Dropdown -->
<UDropdownMenu
:items="[
[{ label: 'Edit', icon: 'i-lucide-edit' }],
[{ label: 'Remove', icon: 'i-lucide-trash-2' }],
[{ label: 'Restart', icon: 'i-lucide-refresh-cw' }],
[{ label: 'Force Update', icon: 'i-lucide-download' }],
]"
>
<UButton variant="subtle" color="primary" size="sm" trailing-icon="i-lucide-chevron-down">
Manage
</UButton>
</UDropdownMenu>
</div>
</div>
<UTabs v-model="selectedTab" variant="link" :items="tabItems" class="w-full" />
<!-- Tab Content -->
<div class="mt-6">
<component
:is="getCurrentTabComponent()"
v-if="getCurrentTabComponent() && selectedNavigationItem"
v-bind="getCurrentTabProps()"
/>
<div v-else-if="!selectedNavigationItem" class="text-gray-500 dark:text-gray-400">
No item selected
</div>
<div v-else class="text-gray-500 dark:text-gray-400">No content available</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { definePageMeta } from '#imports';
import Console from '../Docker/Console.vue';
import Edit from '../Docker/Edit.vue';
import Logs from '../Docker/Logs.vue';
import Overview from '../Docker/Overview.vue';
import Preview from '../Docker/Preview.vue';
import Detail from '../LayoutViews/Detail.vue';
definePageMeta({
layout: 'unraid-next',
});
interface ContainerDetails {
network: string;
lanIpPort: string;
containerIp: string;
uptime: string;
containerPort: string;
creationDate: string;
containerId: string;
maintainer: string;
}
const dockerContainers = [
{
id: 'immich',
label: 'immich',
icon: 'i-lucide-play-circle',
slot: 'immich' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Started', dotColor: 'bg-green-500' },
],
},
{
id: 'organizrv2',
label: 'organizrv2',
icon: 'i-lucide-layers',
slot: 'organizrv2' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'jellyfin',
label: 'Jellyfin',
icon: 'i-lucide-film',
slot: 'jellyfin' as const,
status: [{ label: 'Stopped', dotColor: 'bg-red-500' }],
},
{
id: 'databases',
label: 'Databases',
icon: 'i-lucide-database',
slot: 'databases' as const,
isGroup: true,
children: [
{
id: 'mongodb',
label: 'MongoDB',
icon: 'i-lucide-leafy-green',
badge: 'DB',
slot: 'mongodb' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
{
id: 'postgres17',
label: 'postgres17',
icon: 'i-lucide-pyramid',
badge: 'DB',
slot: 'postgres17' as const,
status: [
{ label: 'Update available', dotColor: 'bg-orange-500' },
{ label: 'Paused', dotColor: 'bg-blue-500' },
],
},
{
id: 'redis',
label: 'Redis',
icon: 'i-lucide-panda',
badge: 'DB',
slot: 'redis' as const,
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
},
],
},
];
const containerDetails: Record<string, ContainerDetails> = {
immich: {
network: 'Bridge',
lanIpPort: '7878',
containerIp: '172.17.0.4',
uptime: '13 hours',
containerPort: '9696:TCP',
creationDate: '2 weeks ago',
containerId: '472b4c2442b9',
maintainer: 'ghcr.io/imagegenius/immich',
},
};
const getTabsWithProps = (containerId: string) => [
{
key: 'overview',
label: 'Overview',
component: Overview,
props: { details: containerDetails[containerId] },
},
{
key: 'logs',
label: 'Logs',
component: Logs,
},
{
key: 'console',
label: 'Console',
component: Console,
},
{
key: 'preview',
label: 'Preview',
component: Preview,
props: { port: containerDetails[containerId]?.lanIpPort || '8080' },
},
{
key: 'edit',
label: 'Edit',
component: Edit,
},
];
const tabs = getTabsWithProps('immich');
</script>
<template>
<div class="h-full">
<Detail :navigation-items="dockerContainers" :tabs="tabs" default-navigation-id="immich" />
</div>
</template>