feat: add entity UI with list filtering and prefilled action arguments
Introduce entity list/detail components and wire related actions through the argument form so entity context can prefill values when starting actions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
afd9f9033a
commit
0c4d25e525
|
|
@ -61,6 +61,11 @@ const props = defineProps({
|
|||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
prefilledArguments: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -248,7 +253,16 @@ async function handleClick() {
|
|||
return
|
||||
}
|
||||
if (needsArgumentForm(props.actionData)) {
|
||||
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
|
||||
const bindingId = props.actionData.bindingId
|
||||
const prefilled = props.prefilledArguments || {}
|
||||
if (Object.keys(prefilled).length > 0) {
|
||||
router.push({
|
||||
path: `/actionBinding/${bindingId}/argumentForm`,
|
||||
state: { prefilledArguments: prefilled }
|
||||
})
|
||||
} else {
|
||||
router.push(`/actionBinding/${bindingId}/argumentForm`)
|
||||
}
|
||||
} else {
|
||||
await startAction()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<Section :padding="!hasTable">
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Entity:
|
||||
<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
|
||||
{{ definition.title }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="hasTable" #toolbar>
|
||||
<EntityListFilter v-model="searchText" />
|
||||
</template>
|
||||
|
||||
<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
|
||||
|
||||
<template v-if="hasTable">
|
||||
<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
|
||||
<EntityInstancesTable
|
||||
v-else
|
||||
:instances="tableInstances"
|
||||
:properties="definition.properties"
|
||||
:total-instances="totalInstances"
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ul v-else>
|
||||
<li v-for="inst in definition.instances" :key="inst.uniqueKey">
|
||||
<router-link :to="entityDetailsRoute(inst)">
|
||||
{{ inst.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
|
||||
<h3>Used on Dashboards:</h3>
|
||||
<ul>
|
||||
<li v-for="dash in usedDashboards" :key="dash">
|
||||
<template v-if="isEntityDirectory(dash)">
|
||||
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
|
||||
</template>
|
||||
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
|
||||
{{ getDashboardTitle(dash) }}
|
||||
</router-link>
|
||||
<span v-else>{{ dash }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import ActionIconGlyph from './ActionIconGlyph.vue'
|
||||
import EntityInstancesTable from './EntityInstancesTable.vue'
|
||||
import EntityListFilter from './EntityListFilter.vue'
|
||||
|
||||
const props = defineProps({
|
||||
definition: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const tableInstances = ref([])
|
||||
const totalInstances = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const tableError = ref('')
|
||||
let fetchTimer = null
|
||||
|
||||
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
|
||||
|
||||
const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
|
||||
|
||||
watch(searchText, () => {
|
||||
currentPage.value = 1
|
||||
scheduleFetchTableInstances()
|
||||
})
|
||||
|
||||
watch([currentPage, pageSize], () => {
|
||||
scheduleFetchTableInstances()
|
||||
})
|
||||
|
||||
function entityDetailsRoute(inst) {
|
||||
return {
|
||||
name: 'EntityDetails',
|
||||
params: {
|
||||
entityType: inst.type,
|
||||
entityKey: inst.uniqueKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filteredDashboards(dashboards) {
|
||||
return dashboards.filter(d => d && !d.includes('{{'))
|
||||
}
|
||||
|
||||
function isEntityDirectory(dashboardTitle) {
|
||||
return dashboardTitle.endsWith(' [Entity Directory]')
|
||||
}
|
||||
|
||||
function getDashboardTitle(dashboardTitle) {
|
||||
if (isEntityDirectory(dashboardTitle)) {
|
||||
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
|
||||
}
|
||||
return dashboardTitle
|
||||
}
|
||||
|
||||
function scheduleFetchTableInstances() {
|
||||
if (!hasTable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetchTimer) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
|
||||
fetchTimer = setTimeout(() => {
|
||||
fetchTableInstances()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async function fetchTableInstances() {
|
||||
if (!hasTable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
tableError.value = ''
|
||||
try {
|
||||
const response = await window.client.getEntities({
|
||||
entityType: props.definition.title,
|
||||
filter: searchText.value.trim(),
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value
|
||||
})
|
||||
|
||||
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
|
||||
tableInstances.value = definition?.instances ?? []
|
||||
totalInstances.value = definition?.totalInstances ?? 0
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch entity instances:', err)
|
||||
tableError.value = 'Failed to load entity instances.'
|
||||
tableInstances.value = []
|
||||
totalInstances.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasTable.value) {
|
||||
fetchTableInstances()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.entity-title-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.table-error {
|
||||
color: var(--error, #c00);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<Table
|
||||
:data="tableRows"
|
||||
:headers="headers"
|
||||
:show-pagination="false"
|
||||
>
|
||||
<template #cell-title="{ row, value }">
|
||||
<router-link :to="entityDetailsRoute(row)">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div v-if="totalInstances > 0" class="padding">
|
||||
<Pagination
|
||||
:total="totalInstances"
|
||||
v-model:page="currentPageModel"
|
||||
v-model:page-size="pageSizeModel"
|
||||
item-title="entities"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Table from 'picocrank/vue/components/Table.vue'
|
||||
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
properties: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
totalInstances: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page', 'update:pageSize'])
|
||||
|
||||
const headers = computed(() => {
|
||||
const propertyHeaders = props.properties.map(property => ({
|
||||
key: property.name,
|
||||
label: property.title,
|
||||
sortable: true
|
||||
}))
|
||||
|
||||
return [
|
||||
{ key: 'title', label: 'Name', sortable: true },
|
||||
...propertyHeaders
|
||||
]
|
||||
})
|
||||
|
||||
const tableRows = computed(() =>
|
||||
props.instances.map(instance => ({
|
||||
...instance,
|
||||
...instance.fields
|
||||
}))
|
||||
)
|
||||
|
||||
const currentPageModel = computed({
|
||||
get: () => props.page,
|
||||
set: value => emit('update:page', value)
|
||||
})
|
||||
|
||||
const pageSizeModel = computed({
|
||||
get: () => props.pageSize,
|
||||
set: value => emit('update:pageSize', value)
|
||||
})
|
||||
|
||||
function entityDetailsRoute(row) {
|
||||
return {
|
||||
name: 'EntityDetails',
|
||||
params: {
|
||||
entityType: row.type,
|
||||
entityKey: row.uniqueKey
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<label class="input-with-icons">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
||||
</svg>
|
||||
<input
|
||||
:value="modelValue"
|
||||
placeholder="Filter entities..."
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-with-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--section-background);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.input-with-icons input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-with-icons button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-with-icons button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export function readPrefilledArgumentsFromNavigation() {
|
||||
const state = window.history.state
|
||||
if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
|
||||
return { ...state.prefilledArguments }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function getInitialArgumentValue(paramName, prefilledArguments) {
|
||||
if (Object.prototype.hasOwnProperty.call(prefilledArguments, paramName)) {
|
||||
return prefilledArguments[paramName]
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search.substring(1))
|
||||
return params.get(paramName)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
|
||||
|
||||
test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
|
||||
const originalState = window.history.state
|
||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
||||
|
||||
assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
})
|
||||
|
||||
test('getInitialArgumentValue prefers navigation state over query params', () => {
|
||||
const originalState = window.history.state
|
||||
const originalSearch = window.location.search
|
||||
|
||||
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
|
||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
||||
|
||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
||||
})
|
||||
|
||||
test('getInitialArgumentValue falls back to query params when state is absent', () => {
|
||||
const originalState = window.history.state
|
||||
const originalSearch = window.location.search
|
||||
|
||||
window.history.replaceState({}, '')
|
||||
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
|
||||
|
||||
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
|
||||
|
||||
window.history.replaceState(originalState, '')
|
||||
window.history.replaceState(window.history.state, '', originalSearch || '/')
|
||||
})
|
||||
|
|
@ -1,7 +1,18 @@
|
|||
<template>
|
||||
<section id = "argument-popup">
|
||||
<div class="section-header">
|
||||
<h2>Start action: {{ title }}</h2>
|
||||
<h2>
|
||||
<span class="section-title-with-icon">
|
||||
Start action:
|
||||
<router-link
|
||||
:to="`/action/${bindingId}`"
|
||||
class="action-details-title-link"
|
||||
>
|
||||
<ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
|
||||
{{ title }}
|
||||
</router-link>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="section-content padding">
|
||||
<form @submit="handleSubmit">
|
||||
|
|
@ -65,11 +76,14 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
||||
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
|
||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
import { applyArgumentTemplate, actionJustificationTemplate, actionRequiresJustification } from '../utils/justificationTemplate.js'
|
||||
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -85,8 +99,10 @@ const formErrors = ref({})
|
|||
const actionArguments = ref([])
|
||||
const popupOnStart = ref('')
|
||||
const formReady = ref(false)
|
||||
const justificationRequired = ref(false)
|
||||
const justificationConfig = ref('')
|
||||
const justificationValue = ref('')
|
||||
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
|
||||
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
|
||||
let isComponentMounted = true
|
||||
|
||||
// Computed properties
|
||||
|
|
@ -114,18 +130,20 @@ async function setup() {
|
|||
icon.value = action.icon
|
||||
popupOnStart.value = action.popupOnStart || ''
|
||||
actionArguments.value = action.arguments || []
|
||||
justificationRequired.value = action.justification || false
|
||||
justificationConfig.value = action.justification || ''
|
||||
justificationValue.value = ''
|
||||
argValues.value = {}
|
||||
formErrors.value = {}
|
||||
confirmationChecked.value = false
|
||||
hasConfirmation.value = false
|
||||
|
||||
// Initialize values from query params or defaults
|
||||
const prefilledArguments = readPrefilledArgumentsFromNavigation()
|
||||
|
||||
// Initialize values from navigation state, query params, or defaults
|
||||
actionArguments.value.forEach(arg => {
|
||||
if (arg.type === 'confirmation') {
|
||||
hasConfirmation.value = true
|
||||
const paramValue = getQueryParamValue(arg.name)
|
||||
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||
let checkedValue = false
|
||||
if (paramValue !== null) {
|
||||
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
||||
|
|
@ -135,7 +153,7 @@ async function setup() {
|
|||
argValues.value[arg.name] = checkedValue
|
||||
confirmationChecked.value = checkedValue
|
||||
} else {
|
||||
const paramValue = getQueryParamValue(arg.name)
|
||||
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||
if (arg.type === 'checkbox') {
|
||||
// For checkboxes, handle boolean default values properly
|
||||
if (paramValue !== null) {
|
||||
|
|
@ -163,16 +181,13 @@ async function setup() {
|
|||
formReady.value = true
|
||||
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
||||
}
|
||||
|
||||
updateJustificationFromTemplate()
|
||||
} catch (err) {
|
||||
console.error('Failed to load argument form:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryParamValue(paramName) {
|
||||
const params = new URLSearchParams(window.location.search.substring(1))
|
||||
return params.get(paramName)
|
||||
}
|
||||
|
||||
function formatLabel(title) {
|
||||
const lastChar = title.charAt(title.length - 1)
|
||||
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
||||
|
|
@ -231,6 +246,7 @@ function handleInput(arg, event) {
|
|||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
||||
argValues.value[arg.name] = value
|
||||
updateUrlWithArg(arg.name, value)
|
||||
updateJustificationFromTemplate()
|
||||
}
|
||||
|
||||
function handleChange(arg, event) {
|
||||
|
|
@ -255,6 +271,7 @@ function handleChoiceUpdate(arg, value) {
|
|||
argValues.value[arg.name] = value
|
||||
updateUrlWithArg(arg.name, value)
|
||||
validateArgument(arg, value)
|
||||
updateJustificationFromTemplate()
|
||||
}
|
||||
|
||||
async function validateArgument(arg, value) {
|
||||
|
|
@ -372,6 +389,28 @@ function getArgumentValues() {
|
|||
return ret
|
||||
}
|
||||
|
||||
function getArgumentMapForTemplate() {
|
||||
const args = {}
|
||||
|
||||
for (const arg of actionArguments.value) {
|
||||
if (!shouldSendArgument(arg)) {
|
||||
continue
|
||||
}
|
||||
|
||||
args[arg.name] = formatArgumentValueForApi(arg, argValues.value[arg.name])
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function updateJustificationFromTemplate() {
|
||||
if (!justificationTemplate.value) {
|
||||
return
|
||||
}
|
||||
|
||||
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
|
||||
}
|
||||
|
||||
function getUniqueId() {
|
||||
if (window.isSecureContext) {
|
||||
return window.crypto.randomUUID()
|
||||
|
|
@ -562,6 +601,27 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-title-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.action-details-title-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-details-title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
form {
|
||||
grid-template-columns: max-content auto auto;
|
||||
|
|
|
|||
|
|
@ -9,36 +9,18 @@
|
|||
</p>
|
||||
</Section>
|
||||
<template v-else>
|
||||
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
||||
<p>{{ def.instances.length }} instances.</p>
|
||||
|
||||
<ul>
|
||||
<li v-for="inst in def.instances" :key="inst.uniqueKey">
|
||||
<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
|
||||
{{ inst.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Used on Dashboards:</h3>
|
||||
<ul>
|
||||
<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
|
||||
<template v-if="isEntityDirectory(dash)">
|
||||
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
|
||||
</template>
|
||||
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
|
||||
{{ getDashboardTitle(dash) }}
|
||||
</router-link>
|
||||
<span v-else>{{ dash }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
<EntityDefinitionSection
|
||||
v-for="def in entityDefinitions"
|
||||
:key="def.title"
|
||||
:definition="def"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
|
||||
|
||||
const definitionsLoaded = ref(false)
|
||||
const entityDefinitions = ref([])
|
||||
|
|
@ -63,21 +45,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function filteredDashboards(dashboards) {
|
||||
return dashboards.filter(d => d && !d.includes('{{'))
|
||||
}
|
||||
|
||||
function isEntityDirectory(dashboardTitle) {
|
||||
return dashboardTitle.endsWith(' [Entity Directory]')
|
||||
}
|
||||
|
||||
function getDashboardTitle(dashboardTitle) {
|
||||
if (isEntityDirectory(dashboardTitle)) {
|
||||
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
|
||||
}
|
||||
return dashboardTitle
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEntities()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<template>
|
||||
<Section title="Entity Details">
|
||||
<Section>
|
||||
<template #title>
|
||||
<span class="section-title-with-icon">
|
||||
Entity Details:
|
||||
<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
|
||||
<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<button @click="goBack" class="back-button">
|
||||
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
||||
|
|
@ -22,15 +29,15 @@
|
|||
<template v-if="entityDetails.fields">
|
||||
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
||||
<dt>{{ key }}</dt>
|
||||
<dd v-html="value"></dd>
|
||||
<dd>{{ value }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
||||
</template>
|
||||
</Section>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>Dashboard Entity Directories</h3>
|
||||
<Section v-if="entityDetails" title="Dashboard Entity Directories">
|
||||
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
||||
<ul class="directory-list">
|
||||
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
||||
|
|
@ -51,8 +58,21 @@
|
|||
<p v-else>No directories found for this entity.
|
||||
<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
|
||||
</p>
|
||||
</template>
|
||||
</Section>
|
||||
|
||||
<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
|
||||
<div class="dashboard-row">
|
||||
<fieldset>
|
||||
<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
|
||||
<ActionButton
|
||||
v-if="related.action"
|
||||
:action-data="related.action"
|
||||
:prefilled-arguments="related.prefilledArguments"
|
||||
/>
|
||||
</template>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -61,6 +81,8 @@
|
|||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import ActionButton from '../ActionButton.vue'
|
||||
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const entityDetails = ref(null)
|
||||
|
|
@ -77,6 +99,10 @@
|
|||
return entityDetails.value.directories.filter(d => d)
|
||||
})
|
||||
|
||||
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
|
||||
|
||||
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'Entities' })
|
||||
}
|
||||
|
|
@ -121,11 +147,6 @@
|
|||
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.directories-section h3 {
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.directory-list a {
|
||||
text-decoration: none;
|
||||
padding: 0.5em;
|
||||
|
|
@ -149,9 +170,22 @@
|
|||
opacity: 0.8;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-color, #ccc);
|
||||
.section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.entity-title-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 180px);
|
||||
grid-auto-rows: 1fr;
|
||||
justify-content: center;
|
||||
place-items: stretch;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
|
@ -164,11 +198,6 @@ hr {
|
|||
background-color: var(--bg-hover, #222);
|
||||
}
|
||||
|
||||
.directories-section {
|
||||
border-top-color: var(--border-color, #333);
|
||||
}
|
||||
|
||||
|
||||
.directory-list a:hover {
|
||||
background-color: var(--bg-hover, #222);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue