Skip to content

Commit

Permalink
Merge pull request #4872 from FlowFuse/team-bom-ui
Browse files Browse the repository at this point in the history
Team Bill Of Materials UI
  • Loading branch information
joepavitt authored Dec 13, 2024
2 parents d636c16 + 66d50a0 commit 4884866
Show file tree
Hide file tree
Showing 22 changed files with 1,437 additions and 612 deletions.
11 changes: 6 additions & 5 deletions forge/db/views/BOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ module.exports = {
nullable: true
},
ownerId: { type: 'string', nullable: true },
dependencies: { type: 'array', items: { $ref: 'dependency' } }
dependencies: { type: 'array', items: { $ref: 'dependency' } },
state: { type: 'string', nullable: true }
}
})
},
Expand All @@ -54,17 +55,17 @@ module.exports = {
if (type !== null) {
const dependenciesArray = Object.entries(dependencies || {}).map(([name, version]) => app.db.views.BOM.dependency(name, version?.wanted, version?.current))
if (type === 'device') {
const { hashid, name, ownerType } = model
const { hashid, name, ownerType, state } = model
let ownerId = null
if (ownerType === 'instance') {
ownerId = model.ProjectId
} else if (ownerType === 'application') {
ownerId = model.Application ? model.Application.id : app.db.models.Application.encodeHashid(model.ApplicationId)
}
return { id: hashid, name, type, ownerType, ownerId, dependencies: dependenciesArray }
return { id: hashid, name, type, ownerType, ownerId, dependencies: dependenciesArray, state }
} else if (type === 'instance') {
const { id, name } = model
return { id, name, type, dependencies: dependenciesArray }
const { id, name, state } = model
return { id, name, type, dependencies: dependenciesArray, state }
}
}
return null
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/api/team.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,16 @@ const bulkDeviceMove = async (teamId, devices, moveTo, id = undefined) => {
return res.data
}

/**
* Get a list of Dependencies / Bill of Materials
* @param teamId
* @returns {Promise<axios.AxiosResponse<any>>}
*/
const getDependencies = (teamId) => {
return client.get(`/api/v1/teams/${teamId}/bom`)
.then(res => res.data)
}

/**
* Calls api routes in team.js
* See [routes/api/team.js](../../../forge/routes/api/team.js)
Expand Down Expand Up @@ -453,5 +463,6 @@ export default {
updateTeamDeviceProvisioningToken,
deleteTeamDeviceProvisioningToken,
bulkDeviceDelete,
bulkDeviceMove
bulkDeviceMove,
getDependencies
}
8 changes: 5 additions & 3 deletions frontend/src/components/Accordion.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="ff-accordion" :class="{'open': isOpen}" data-el="accordion">
<div class="ff-accordion" data-el="accordion">
<button class="ff-accordion--button" :disabled="disabled" @click="toggle()">
<slot name="label">
<label>{{ label }}</label>
Expand All @@ -9,7 +9,7 @@
<ChevronLeftIcon v-if="!disabled" class="ff-icon chevron" />
</div>
</button>
<div ref="content" class="ff-accordion--content">
<div v-if="isOpen" ref="content" class="ff-accordion--content">
<slot name="content" />
</div>
</div>
Expand Down Expand Up @@ -38,6 +38,7 @@ export default {
default: false
}
},
emits: ['state-changed'],
data () {
return {
isOpen: false
Expand All @@ -51,6 +52,7 @@ export default {
const content = this.$refs.content
return (2 * content.scrollHeight) + 'px'
})
this.$emit('state-changed', value)
} else {
return null
}
Expand All @@ -72,7 +74,7 @@ export default {
}
}
},
// externally facing open function so we can call all accordians open/close at once
// externally facing open function so we can call all accordions open/close at once
open: function () {
this.isOpen = true
},
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/bill-of-materials/BomDependencies.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div v-if="Object.keys(dependencies).length > 0" class="dependencies" data-el="dependencies">
<dependency-item
v-for="(versions, dependencyTitle) in dependencies"
:key="dependencyTitle"
:title="dependencyTitle"
:versions="versions"
:start-closed="startClosed"
/>
</div>
<div v-else class="empty text-center opacity-60">
<p>Oops! We couldn't find any matching results.</p>
</div>
</template>

<script>
import DependencyItem from './DependencyItem.vue'
export default {
name: 'BomDependencies',
components: { DependencyItem },
props: {
payload: {
required: true,
type: Object
},
searchTerm: {
required: false,
type: String,
default: ''
},
startClosed: {
required: false,
type: Boolean,
default: false
}
},
computed: {
dependencies () {
return this.payload.children
.reduce((acc, currentInstance) => {
currentInstance.dependencies.forEach(dep => {
const searchTerm = this.searchTerm.trim()
const installedDependencyVersion = dep.version?.current ?? dep.version?.wanted ?? 'N/A'
const dependencyNameMatchesSearch = dep.name.toLowerCase().includes(searchTerm.toLowerCase())
const dependencyVersionMatchesSearch = installedDependencyVersion.toLowerCase().includes(searchTerm.toLowerCase())
const matchesInstanceName = currentInstance.name.toLowerCase().includes(searchTerm.toLowerCase())
const includeDependency = () => {
if (!Object.prototype.hasOwnProperty.call(acc, dep.name)) {
acc[dep.name] = {}
}
if (!Object.prototype.hasOwnProperty.call(acc[dep.name], installedDependencyVersion)) {
acc[dep.name][installedDependencyVersion] = []
}
acc[dep.name][installedDependencyVersion].push(currentInstance)
}
switch (true) {
case !searchTerm.length:
includeDependency()
break
case matchesInstanceName:
includeDependency()
break
case dependencyVersionMatchesSearch || dependencyNameMatchesSearch:
includeDependency()
break
default:
break
}
})
return acc
}, {})
}
}
}
</script>
<style scoped lang="scss">
</style>
179 changes: 179 additions & 0 deletions frontend/src/components/bill-of-materials/DependencyItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<template>
<section class="dependency-item" data-el="dependency-item" :data-item="title">
<div class="dependency-header" :class="{open: isOpen}" @click="toggleOpenState">
<ChevronRightIcon class="ff-icon-sm ff-toggle" />
<div class="title truncate">
<h3 class="truncate">{{ title }}</h3>
<p class="truncate">({{ versionsCount }} {{ pluralize('Version', versionsCount) }})</p>
</div>
<div class="details truncate">
<span class="truncate">Latest: {{ externalLatest }}</span>
<span class="truncate">Released: {{ externalLastModified }}</span>
</div>
</div>
<template v-if="isOpen">
<versions-list
v-for="(entry, key) in sortedVersions" :key="key"
:instances="entry[1]"
:version="entry[0]"
/>
</template>
</section>
</template>

<script>
import { ChevronRightIcon } from '@heroicons/vue/outline'
import ExternalClient from '../../api/external.js'
import { pluralize } from '../../composables/String.js'
import daysSince from '../../utils/daysSince.js'
import VersionsList from './VersionsList.vue'
export default {
name: 'DependencyItem',
components: {
VersionsList,
ChevronRightIcon
},
props: {
title: {
required: true,
type: String
},
versions: {
required: true,
type: Object
},
startClosed: {
required: false,
type: Boolean,
default: false
}
},
data () {
return {
externalDependency: null,
isOpen: true
}
},
computed: {
sortedVersions () {
return Object.entries(this.versions).sort((a, b) => {
return b[0].localeCompare(a[0])
})
},
externalLatest () {
if (
!this.externalDependency ||
(
!Object.prototype.hasOwnProperty.call(this.externalDependency, 'dist-tags') &&
!Object.prototype.hasOwnProperty.call(this.externalDependency['dist-tags'], 'latest') &&
!Object.prototype.hasOwnProperty.call(this.externalDependency, 'versions') &&
!Object.prototype.hasOwnProperty.call(
this.externalDependency.versions,
this.externalDependency['dist-tags'].latest
)
)
) {
return 'N/A'
}
return this.externalDependency.versions[this.externalDependency['dist-tags'].latest].version
},
externalLastModified () {
if (
!this.externalDependency ||
(
!Object.prototype.hasOwnProperty.call(this.externalDependency, 'time') &&
!Object.prototype.hasOwnProperty.call(this.externalDependency.time, 'modified')
)
) {
return 'N/A'
}
return daysSince(this.externalDependency.time.modified, true)
},
versionsCount () {
return Object.keys(this.versions).length
}
},
created () {
this.isOpen = !this.startClosed
},
mounted () {
this.getExternalDependency()
},
methods: {
pluralize,
async getExternalDependency () {
this.externalDependency = await ExternalClient.getNpmDependency(this.title)
},
toggleOpenState () {
this.isOpen = !this.isOpen
}
}
}
</script>

<style lang="scss">
.dependency-item {
border: 1px solid $ff-grey-300;
margin-bottom: 12px;
.dependency-header {
cursor: pointer;
background: $ff-grey-100;
display: flex;
padding: 6px 9px;
align-items: center;
gap: 15px;
.title {
flex: 1;
display: flex;
align-items: center;
gap: 15px;
h3, p {
margin: 0;
line-height: 1;
}
p {
color: $ff-grey-500;
font-weight: 400;
font-size: 80%;
}
}
.details {
display: flex;
flex-direction: column;
text-align: right;
font-size: 0.875rem;
font-weight: 500;
}
.ff-toggle {
transition: ease-in-out .3s;
}
&.open {
border-bottom: 1px solid $ff-grey-300;
.ff-toggle {
transform: rotate(90deg);
}
}
}
&:last-of-type {
.ff-accordion {
button {
border-bottom: none;
}
}
}
}
</style>
Loading

0 comments on commit 4884866

Please sign in to comment.