feat: collapsible groups in IP addressing view
Groups are collapsed by default. Click a header to toggle it; chevron rotates to indicate state. "Expand all" / "Collapse all" buttons in the page header; each is disabled when the action is already complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{{ t('ipAddressing') }}</h1>
|
<h1>{{ t('ipAddressing') }}</h1>
|
||||||
|
<div v-if="!allHaveNoIp" class="header-actions">
|
||||||
|
<button class="btn-toggle-all" @click="expandAll" :disabled="allExpanded">{{ t('expandAll') }}</button>
|
||||||
|
<button class="btn-toggle-all" @click="collapseAll" :disabled="allCollapsed">{{ t('collapseAll') }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="allHaveNoIp" class="empty">
|
<div v-if="allHaveNoIp" class="empty">
|
||||||
@@ -18,7 +22,10 @@
|
|||||||
|
|
||||||
<div v-else class="groups">
|
<div v-else class="groups">
|
||||||
<div v-for="group in groups" :key="group.key" class="group">
|
<div v-for="group in groups" :key="group.key" class="group">
|
||||||
<div class="group-header" :style="{ borderLeftColor: group.color }">
|
<div class="group-header" :style="{ borderLeftColor: group.color }" @click="toggleGroup(group.key)">
|
||||||
|
<svg class="chevron" :class="{ open: expanded.has(group.key) }" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
<span class="badge-vlan" :style="{ background: group.color }">
|
<span class="badge-vlan" :style="{ background: group.color }">
|
||||||
{{ group.badge }}
|
{{ group.badge }}
|
||||||
</span>
|
</span>
|
||||||
@@ -27,7 +34,7 @@
|
|||||||
<span class="group-count">{{ group.rows.length }}</span>
|
<span class="group-count">{{ group.rows.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div v-show="expanded.has(group.key)" class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -65,7 +72,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { t } from '../i18n.js'
|
import { t } from '../i18n.js'
|
||||||
import { detectOs } from '../brandIcons.js'
|
import { detectOs } from '../brandIcons.js'
|
||||||
import DeviceFormModal from './DeviceFormModal.vue'
|
import DeviceFormModal from './DeviceFormModal.vue'
|
||||||
@@ -77,6 +84,24 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['refresh'])
|
const emit = defineEmits(['refresh'])
|
||||||
|
|
||||||
const editingDevice = ref(null)
|
const editingDevice = ref(null)
|
||||||
|
const expanded = ref(new Set())
|
||||||
|
|
||||||
|
function toggleGroup(key) {
|
||||||
|
const s = new Set(expanded.value)
|
||||||
|
s.has(key) ? s.delete(key) : s.add(key)
|
||||||
|
expanded.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
expanded.value = new Set(groups.value.map(g => g.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
expanded.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allExpanded = computed(() => groups.value.length > 0 && groups.value.every(g => expanded.value.has(g.key)))
|
||||||
|
const allCollapsed = computed(() => groups.value.every(g => !expanded.value.has(g.key)))
|
||||||
|
|
||||||
function ipToNum(ip) {
|
function ipToNum(ip) {
|
||||||
if (!ip) return 0
|
if (!ip) return 0
|
||||||
@@ -162,6 +187,7 @@ const allHaveNoIp = computed(() => groups.value.length === 0)
|
|||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +197,32 @@ const allHaveNoIp = computed(() => groups.value.length === 0)
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-all {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-chip);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-toggle-all:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.btn-toggle-all:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -200,6 +252,20 @@ const allHaveNoIp = computed(() => groups.value.length === 0)
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
border-left: 4px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
background: var(--bg-thead);
|
background: var(--bg-thead);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.group-header:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-vlan {
|
.badge-vlan {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const LANGS = {
|
|||||||
colOs: 'OS',
|
colOs: 'OS',
|
||||||
fieldOs: "Système d'exploitation",
|
fieldOs: "Système d'exploitation",
|
||||||
noIpConfigured: 'Aucune adresse IP configurée.',
|
noIpConfigured: 'Aucune adresse IP configurée.',
|
||||||
|
expandAll: 'Tout déplier',
|
||||||
|
collapseAll: 'Tout replier',
|
||||||
discovery: '🔍 Découverte auto',
|
discovery: '🔍 Découverte auto',
|
||||||
statsNetwork: 'Réseau',
|
statsNetwork: 'Réseau',
|
||||||
statsNetworks: 'Réseaux',
|
statsNetworks: 'Réseaux',
|
||||||
@@ -199,6 +201,8 @@ const LANGS = {
|
|||||||
colOs: 'OS',
|
colOs: 'OS',
|
||||||
fieldOs: 'Operating system',
|
fieldOs: 'Operating system',
|
||||||
noIpConfigured: 'No IP address configured.',
|
noIpConfigured: 'No IP address configured.',
|
||||||
|
expandAll: 'Expand all',
|
||||||
|
collapseAll: 'Collapse all',
|
||||||
discovery: '🔍 Auto discovery',
|
discovery: '🔍 Auto discovery',
|
||||||
statsNetwork: 'Network',
|
statsNetwork: 'Network',
|
||||||
statsNetworks: 'Networks',
|
statsNetworks: 'Networks',
|
||||||
@@ -373,6 +377,8 @@ const LANGS = {
|
|||||||
colOs: 'SO',
|
colOs: 'SO',
|
||||||
fieldOs: 'Sistema operativo',
|
fieldOs: 'Sistema operativo',
|
||||||
noIpConfigured: 'No hay ninguna dirección IP configurada.',
|
noIpConfigured: 'No hay ninguna dirección IP configurada.',
|
||||||
|
expandAll: 'Expandir todo',
|
||||||
|
collapseAll: 'Contraer todo',
|
||||||
discovery: '🔍 Descubrimiento auto',
|
discovery: '🔍 Descubrimiento auto',
|
||||||
statsNetwork: 'Red',
|
statsNetwork: 'Red',
|
||||||
statsNetworks: 'Redes',
|
statsNetworks: 'Redes',
|
||||||
|
|||||||
Reference in New Issue
Block a user