Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Topic hierarchy follow up #4818

Merged
merged 14 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion frontend/src/components/TextCopier.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<span class="ff-text-copier">
<span @click="copyPath">
<span v-if="showText" @click="copyPath">
<slot name="default">
<span class="text">{{ text }}</span>
</slot>
Expand Down Expand Up @@ -30,6 +30,11 @@ export default {
validator: (value) => {
return ['prompt', 'alert'].includes(value)
}
},
showText: {
required: false,
type: Boolean,
default: true
}
},
emits: ['copied'],
Expand Down
102 changes: 84 additions & 18 deletions frontend/src/pages/team/UNS/Hierarchy/components/TopicSegment.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="segment-wrapper" :class="{open: visibleChildren}" data-el="segment-wrapper" :data-value="segment">
<div class="segment-wrapper" :class="{open: isSegmentOpen, empty: isEmpty}" data-el="segment-wrapper" :data-value="segment.name">
<div class="segment flex" @click="toggleChildren">
<div class="diagram">
<span v-if="!isRoot" class="connector-elbow" />
Expand All @@ -8,34 +8,48 @@
<div class="content flex gap-1.5 items-center font-bold" :class="{'cursor-pointer': hasChildren, 'cursor-default': !hasChildren, 'pl-10': !isRoot}">
<ChevronRightIcon v-if="hasChildren" class="chevron ff-icon-sm" />
<p class="flex gap-2.5 items-end" :class="{'ml-2': !hasChildren}">
<span class="title">{{ segmentText }}</span>
<span class="title">
{{ segmentText }}
<span
v-if="segment.isEndOfTopic && segment.childrenCount"
class="separator cursor-help"
title="This topic is also able to receive events"
>
<ArchiveIcon class="ff-icon-sm" />
</span>
</span>
<span v-if="hasChildren" class="font-normal opacity-50 text-xs">{{ topicsCounterLabel }}</span>
<text-copier :text="segment.path" :show-text="false" class="ff-text-copier" />
</p>
</div>
</div>
<div v-if="hasChildren && visibleChildren" class="children" data-el="segment-children" :class="{ 'pl-10': isRoot}">
<div v-if="hasChildren && isSegmentOpen" class="children" data-el="segment-children" :class="{ 'pl-10': isRoot}">
<topic-segment
v-for="(child, key) in Object.keys(children)"
:key="child"
:segment="child"
:children="children[child]"
v-for="(child, key) in childrenSegments"
:key="'-'+child.path"
:segment="children[child]"
:children="children[child].children"
:has-siblings="Object.keys(children).length > 1"
:is-last-sibling="key === Object.keys(children).length-1"
:is-last-sibling="key === Object.keys(children).length - 1"
:class="{'pl-10': !isRoot}"
@segment-state-changed="$emit('segment-state-changed', $event)"
/>
</div>
</div>
</template>

<script>
import { ArchiveIcon } from '@heroicons/vue/outline'
import { ChevronRightIcon } from '@heroicons/vue/solid'

import TextCopier from '../../../../../components/TextCopier.vue'
export default {
name: 'TopicSegment',
components: { ChevronRightIcon },
components: { TextCopier, ChevronRightIcon, ArchiveIcon },
props: {
segment: {
required: true,
type: String
type: Object
},
children: {
required: true,
Expand All @@ -55,9 +69,10 @@ export default {
type: Boolean
}
},
emits: ['segment-state-changed'],
data () {
return {
visibleChildren: false
isSegmentOpen: false
}
},
computed: {
Expand All @@ -67,26 +82,41 @@ export default {
hasChildren () {
return this.childrenCount > 0
},
childrenSegments () {
return Object.keys(this.children).sort()
},
topicsCounterLabel () {
const label = 'topic' + (this.childrenCount <= 1 ? '' : 's')
return `(${this.childrenCount} ${label})`
const label = 'topic' + (this.segment.childrenCount <= 1 ? '' : 's')
return `(${this.segment.childrenCount} ${label})`
},
isEmpty () {
return this.segment.name.length === 0
},
segmentText () {
return this.hasChildren ? `${this.segment}/` : this.segment
return !this.isEmpty ? this.segment.name : '(empty)'
},
shouldShowTrunk () {
return !this.isRoot && this.hasSiblings && this.isLastSibling
}
},
mounted () {
if (this.hasChildren) {
this.visibleChildren = true
watch: {
isSegmentOpen: {
handler () {
this.$emit('segment-state-changed', {
state: this.isSegmentOpen,
path: this.segment.path
})
},
immediate: false
}
},
mounted () {
this.isSegmentOpen = this.segment.open
},
methods: {
toggleChildren () {
if (this.hasChildren) {
this.visibleChildren = !this.visibleChildren
this.isSegmentOpen = !this.isSegmentOpen
}
}
}
Expand Down Expand Up @@ -130,10 +160,29 @@ export default {

.content {
padding: 5px;
position: relative;

.chevron {
transition: ease .15s;
}

.title {
align-items: center;
display: flex;
gap: 3px;
}

.ff-text-copier {
display: none;
height: 17px;
}

&:hover {
.ff-text-copier {
display: inline-block;
color: $ff-grey-400;
}
}
}
}

Expand All @@ -156,5 +205,22 @@ export default {
}
}
}

&.empty > {
.segment {
.content {
.title {
color: $ff-grey-600;
font-size: 90%;
font-weight: 300;

.separator {
color: $ff-black;
font-weight: bold;
}
}
}
}
}
}
</style>
109 changes: 88 additions & 21 deletions frontend/src/pages/team/UNS/Hierarchy/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@
<template v-else>
<section v-if="topics.length > 0" class="topics">
<topic-segment
v-for="(segment, key) in Object.keys(hierarchy)"
v-for="(segment, key) in hierarchySegments"
:key="segment"
:segment="segment"
:children="hierarchy[segment]"
:segment="hierarchy[segment]"
:children="hierarchy[segment].children"
:has-siblings="Object.keys(hierarchy).length > 1"
:is-last-sibling="key === Object.keys(hierarchy).length-1"
:is-root="true"
@segment-state-changed="toggleSegmentVisibility"
/>
</section>

Expand Down Expand Up @@ -75,32 +76,94 @@ export default {
computed: {
...mapState('account', ['team']),
...mapGetters('account', ['featuresCheck']),
hierarchy () {
const map = new Map()
hierarchy: {
get () {
const hierarchy = {}
const topics = this.topics

if (!this.topics || !Array.isArray(this.topics)) return {}
// Sort topics alphabetically to ensure consistency in hierarchy generation
topics.sort().forEach(topic => {
const parts = topic.split('/')

this.topics.forEach(topic => {
const parts = topic.split('/')
let current = map
// combine empty root topics into /{child-topic}
const rootName = topic.startsWith('/')
? '/' + (parts[1] || '')
: parts[0]

parts.forEach(part => {
if (!current.has(part)) {
current.set(part, new Map())
if (!hierarchy[rootName]) {
hierarchy[rootName] = {
name: rootName,
path: topic.startsWith('/') // adjusting path for empty root topics
? `/${rootName}` // Path for topics with leading '/'
: rootName, // Path for topics without leading '/'
open: false,
childrenCount: 0,
children: {}
}
}
current = current.get(part)

let current = hierarchy[rootName].children // Start at the root's children

// Traverse through the parts to build the nested structure
parts.slice(topic.startsWith('/') ? 2 : 1) // Skip empty root and any leading part
.forEach((part, index) => {
if (!current[part]) {
const path = `${hierarchy[rootName].path}/${parts.slice(
topic.startsWith('/') ? 2 : 1,
index + 1
).join('/')}`

current[part] = {
name: part,
path,
open: false,
childrenCount: 0,
children: {}
}
}
current = current[part].children // Move to the next level
})
})
})

function mapToObject (map) {
const obj = {}
for (const [key, value] of map.entries()) {
obj[key] = value instanceof Map ? mapToObject(value) : value
function calculateChildrenCount (node) {
if (!node.children) return 0

let count = 0
for (const childKey in node.children) {
const childNode = node.children[childKey]
count += 1 + calculateChildrenCount(childNode) // Add 1 (for the child itself) and its children's count
}
node.childrenCount = count
return count
}
return obj
}

return mapToObject(map)
for (const key in hierarchy) {
calculateChildrenCount(hierarchy[key])
}

return hierarchy
},
set (segment) {
const keys = segment.path.split('/')
let current = this.hierarchy

for (let i = 0; i < keys.length; i++) {
const key = keys[i]

if (!current[key]) return

if (i === keys.length - 1) {
// if it's the last segment path, we set the state
current[key].open = segment.state
} else {
// Moves deeper into the hierarchy
current = current[key].children
}
}
}
},
hierarchySegments () {
return Object.keys(this.hierarchy).sort()
}
},
async mounted () {
Expand All @@ -117,6 +180,10 @@ export default {
.finally(() => {
this.loading = false
})
},
toggleSegmentVisibility (segment) {
// trigger's the hierarchy setter
this.hierarchy = segment
}
}
}
Expand Down
Loading
Loading