feat: add entity UI with list filtering and prefilled action arguments (#1069)
This commit is contained in:
commit
6ce93629e7
|
|
@ -61,6 +61,11 @@ const props = defineProps({
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
prefilledArguments: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -248,7 +253,16 @@ async function handleClick() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (needsArgumentForm(props.actionData)) {
|
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 {
|
} else {
|
||||||
await startAction()
|
await startAction()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<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, onBeforeUnmount } 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'
|
||||||
|
import { entityDetailsRoute } from '../utils/entityRoutes.js'
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (fetchTimer) {
|
||||||
|
clearTimeout(fetchTimer)
|
||||||
|
fetchTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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,96 @@
|
||||||
|
<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'
|
||||||
|
import { entityDetailsRoute } from '../utils/entityRoutes.js'
|
||||||
|
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'title', label: 'Name' },
|
||||||
|
...propertyHeaders
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableRows = computed(() =>
|
||||||
|
props.instances.map(instance => ({
|
||||||
|
...instance.fields,
|
||||||
|
title: instance.title,
|
||||||
|
type: instance.type,
|
||||||
|
uniqueKey: instance.uniqueKey
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentPageModel = computed({
|
||||||
|
get: () => props.page,
|
||||||
|
set: value => emit('update:page', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSizeModel = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: value => emit('update:pageSize', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<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"
|
||||||
|
aria-label="Filter entities"
|
||||||
|
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,9 @@
|
||||||
|
export function entityDetailsRoute (entity) {
|
||||||
|
return {
|
||||||
|
name: 'EntityDetails',
|
||||||
|
params: {
|
||||||
|
entityType: entity.type,
|
||||||
|
entityKey: entity.uniqueKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<section id = "argument-popup">
|
<section id = "argument-popup">
|
||||||
<div class="section-header">
|
<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>
|
||||||
<div class="section-content padding">
|
<div class="section-content padding">
|
||||||
<form @submit="handleSubmit">
|
<form @submit="handleSubmit">
|
||||||
|
|
@ -44,7 +55,7 @@
|
||||||
|
|
||||||
<template v-if="justificationRequired">
|
<template v-if="justificationRequired">
|
||||||
<label for="justification">Justification:</label>
|
<label for="justification">Justification:</label>
|
||||||
<input id="justification" name="justification" type="text" v-model="justificationValue" required />
|
<input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="actionArguments.length === 0 && !justificationRequired">
|
<div v-if="actionArguments.length === 0 && !justificationRequired">
|
||||||
|
|
@ -65,16 +76,18 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRouter } from 'vue-router'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
||||||
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
|
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
|
||||||
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
import {
|
import {
|
||||||
actionJustificationTemplate,
|
actionJustificationTemplate,
|
||||||
actionRequiresJustification,
|
actionRequiresJustification,
|
||||||
applyArgumentTemplate
|
applyArgumentTemplate
|
||||||
} from '../utils/justificationTemplate.js'
|
} from '../utils/justificationTemplate.js'
|
||||||
|
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -90,8 +103,11 @@ const formErrors = ref({})
|
||||||
const actionArguments = ref([])
|
const actionArguments = ref([])
|
||||||
const popupOnStart = ref('')
|
const popupOnStart = ref('')
|
||||||
const formReady = ref(false)
|
const formReady = ref(false)
|
||||||
const justificationRequired = ref(false)
|
const justificationConfig = ref('')
|
||||||
const justificationValue = ref('')
|
const justificationValue = ref('')
|
||||||
|
const justificationEditedManually = ref(false)
|
||||||
|
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
|
||||||
|
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
|
||||||
let isComponentMounted = true
|
let isComponentMounted = true
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
|
|
@ -119,17 +135,21 @@ async function setup() {
|
||||||
icon.value = action.icon
|
icon.value = action.icon
|
||||||
popupOnStart.value = action.popupOnStart || ''
|
popupOnStart.value = action.popupOnStart || ''
|
||||||
actionArguments.value = action.arguments || []
|
actionArguments.value = action.arguments || []
|
||||||
justificationRequired.value = actionRequiresJustification(action.justification)
|
justificationConfig.value = action.justification || ''
|
||||||
|
justificationValue.value = ''
|
||||||
|
justificationEditedManually.value = false
|
||||||
argValues.value = {}
|
argValues.value = {}
|
||||||
formErrors.value = {}
|
formErrors.value = {}
|
||||||
confirmationChecked.value = false
|
confirmationChecked.value = false
|
||||||
hasConfirmation.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 => {
|
actionArguments.value.forEach(arg => {
|
||||||
if (arg.type === 'confirmation') {
|
if (arg.type === 'confirmation') {
|
||||||
hasConfirmation.value = true
|
hasConfirmation.value = true
|
||||||
const paramValue = getQueryParamValue(arg.name)
|
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||||
let checkedValue = false
|
let checkedValue = false
|
||||||
if (paramValue !== null) {
|
if (paramValue !== null) {
|
||||||
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
||||||
|
|
@ -139,7 +159,7 @@ async function setup() {
|
||||||
argValues.value[arg.name] = checkedValue
|
argValues.value[arg.name] = checkedValue
|
||||||
confirmationChecked.value = checkedValue
|
confirmationChecked.value = checkedValue
|
||||||
} else {
|
} else {
|
||||||
const paramValue = getQueryParamValue(arg.name)
|
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
|
||||||
if (arg.type === 'checkbox') {
|
if (arg.type === 'checkbox') {
|
||||||
// For checkboxes, handle boolean default values properly
|
// For checkboxes, handle boolean default values properly
|
||||||
if (paramValue !== null) {
|
if (paramValue !== null) {
|
||||||
|
|
@ -155,12 +175,6 @@ async function setup() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const prefilledJustification = applyArgumentTemplate(
|
|
||||||
actionJustificationTemplate(action.justification),
|
|
||||||
argValues.value
|
|
||||||
)
|
|
||||||
justificationValue.value = prefilledJustification.trim() === '' ? '' : prefilledJustification
|
|
||||||
|
|
||||||
// Run initial validation on all fields after DOM is updated
|
// Run initial validation on all fields after DOM is updated
|
||||||
await nextTick()
|
await nextTick()
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
|
|
@ -173,16 +187,13 @@ async function setup() {
|
||||||
formReady.value = true
|
formReady.value = true
|
||||||
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
document.body.setAttribute('loaded-argument-form', props.bindingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateJustificationFromTemplate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load argument form:', 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) {
|
function formatLabel(title) {
|
||||||
const lastChar = title.charAt(title.length - 1)
|
const lastChar = title.charAt(title.length - 1)
|
||||||
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
|
||||||
|
|
@ -237,10 +248,16 @@ function getArgumentValue(arg) {
|
||||||
return argValues.value[arg.name] || ''
|
return argValues.value[arg.name] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleJustificationInput(event) {
|
||||||
|
justificationValue.value = event.target.value
|
||||||
|
justificationEditedManually.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput(arg, event) {
|
function handleInput(arg, event) {
|
||||||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
||||||
argValues.value[arg.name] = value
|
argValues.value[arg.name] = value
|
||||||
updateUrlWithArg(arg.name, value)
|
updateUrlWithArg(arg.name, value)
|
||||||
|
updateJustificationFromTemplate()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(arg, event) {
|
function handleChange(arg, event) {
|
||||||
|
|
@ -265,6 +282,7 @@ function handleChoiceUpdate(arg, value) {
|
||||||
argValues.value[arg.name] = value
|
argValues.value[arg.name] = value
|
||||||
updateUrlWithArg(arg.name, value)
|
updateUrlWithArg(arg.name, value)
|
||||||
validateArgument(arg, value)
|
validateArgument(arg, value)
|
||||||
|
updateJustificationFromTemplate()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateArgument(arg, value) {
|
async function validateArgument(arg, value) {
|
||||||
|
|
@ -382,6 +400,28 @@ function getArgumentValues() {
|
||||||
return ret
|
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 || justificationEditedManually.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
|
||||||
|
}
|
||||||
|
|
||||||
function getUniqueId() {
|
function getUniqueId() {
|
||||||
if (window.isSecureContext) {
|
if (window.isSecureContext) {
|
||||||
return window.crypto.randomUUID()
|
return window.crypto.randomUUID()
|
||||||
|
|
@ -572,6 +612,27 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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: var(--link-color, #0066cc);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-details-title-link:hover {
|
||||||
|
color: var(--link-hover-color, #004499);
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
grid-template-columns: max-content auto auto;
|
grid-template-columns: max-content auto auto;
|
||||||
|
|
|
||||||
|
|
@ -9,36 +9,18 @@
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
|
<EntityDefinitionSection
|
||||||
<p>{{ def.instances.length }} instances.</p>
|
v-for="def in entityDefinitions"
|
||||||
|
:key="def.title"
|
||||||
<ul>
|
:definition="def"
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
|
||||||
|
|
||||||
const definitionsLoaded = ref(false)
|
const definitionsLoaded = ref(false)
|
||||||
const entityDefinitions = ref([])
|
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(() => {
|
onMounted(() => {
|
||||||
fetchEntities()
|
fetchEntities()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<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>
|
<template #toolbar>
|
||||||
<button @click="goBack" class="back-button">
|
<button @click="goBack" class="back-button">
|
||||||
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
|
||||||
|
|
@ -22,15 +29,15 @@
|
||||||
<template v-if="entityDetails.fields">
|
<template v-if="entityDetails.fields">
|
||||||
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
<template v-for="(value, key) in entityDetails.fields" :key="key">
|
||||||
<dt>{{ key }}</dt>
|
<dt>{{ key }}</dt>
|
||||||
<dd v-html="value"></dd>
|
<dd>{{ value }}</dd>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
|
||||||
|
</template>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<hr />
|
<Section v-if="entityDetails" title="Dashboard Entity Directories">
|
||||||
|
|
||||||
<h3>Dashboard Entity Directories</h3>
|
|
||||||
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
<div v-if="filteredDirectories.length > 0" class="directories-section">
|
||||||
<ul class="directory-list">
|
<ul class="directory-list">
|
||||||
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
|
||||||
|
|
@ -49,10 +56,23 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>No directories found for this entity.
|
<p v-else>No directories found for this entity.
|
||||||
<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
|
<a href="https://docs.olivetin.app/dashboards/entity-directories.html" target="_blank" rel="noopener noreferrer">Learn more</a>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
|
||||||
</Section>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -61,6 +81,8 @@
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
import ActionButton from '../ActionButton.vue'
|
||||||
|
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const entityDetails = ref(null)
|
const entityDetails = ref(null)
|
||||||
|
|
@ -77,6 +99,10 @@
|
||||||
return entityDetails.value.directories.filter(d => d)
|
return entityDetails.value.directories.filter(d => d)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
|
||||||
|
|
||||||
|
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'Entities' })
|
router.push({ name: 'Entities' })
|
||||||
}
|
}
|
||||||
|
|
@ -121,11 +147,6 @@
|
||||||
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
|
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 {
|
.directory-list a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
|
@ -149,9 +170,22 @@
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
.section-title-with-icon {
|
||||||
border: 0;
|
display: inline-flex;
|
||||||
border-top: 1px solid var(--border-color, #ccc);
|
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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
@ -164,11 +198,6 @@ hr {
|
||||||
background-color: var(--bg-hover, #222);
|
background-color: var(--bg-hover, #222);
|
||||||
}
|
}
|
||||||
|
|
||||||
.directories-section {
|
|
||||||
border-top-color: var(--border-color, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.directory-list a:hover {
|
.directory-list a:hover {
|
||||||
background-color: var(--bg-hover, #222);
|
background-color: var(--bg-hover, #222);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue