fix: custom CSS and custom JS (#804 and #803) hopefully

This commit is contained in:
jamesread 2026-06-16 11:56:14 +01:00
parent 9d64b64bf9
commit 37cde0d982
14 changed files with 289 additions and 23 deletions

View File

@ -32,17 +32,19 @@ const unicodeIcon = computed(() => {
function navigateToDirectory() { function navigateToDirectory() {
const params = { title: props.component.title } const params = { title: props.component.title }
if (props.component.entityType && props.component.entityKey) { if (props.component.entityType && props.component.entityKey) {
params.entityType = props.component.entityType params.entityType = props.component.entityType
params.entityKey = props.component.entityKey params.entityKey = props.component.entityKey
} }
router.push({ name: 'Dashboard', params }) router.push({ name: 'Dashboard', params })
} }
</script> </script>
<style scoped> <style>
@layer components {
.folder-container { .folder-container {
display: grid; display: grid;
} }
@ -93,5 +95,6 @@ button .title {
color: #fff; color: #fff;
} }
} }
}
</style> </style>

View File

@ -13,7 +13,9 @@ const props = defineProps({
}) })
</script> </script>
<style scoped> <style>
@layer components {
.display { .display {
padding: 1em; padding: 1em;
border-radius: .7em; border-radius: .7em;
@ -33,5 +35,6 @@ const props = defineProps({
box-shadow: 0 0 .6em #000; box-shadow: 0 0 .6em #000;
} }
} }
}
</style> </style>

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="mre-container" :class="component.cssClass"> <div class="mre-container" :class="component.cssClass">
<router-link <router-link
v-if="executionTrackingId" v-if="executionTrackingId"
:to="`/logs/${executionTrackingId}`" :to="`/logs/${executionTrackingId}`"
class="mre-link" class="mre-link"
> >
<pre class="mre-output">{{ output }}</pre> <pre class="mre-output">{{ output }}</pre>
@ -58,7 +58,7 @@ async function fetchMostRecentExecution() {
} }
const result = await window.client.executionStatus(executionStatusArgs) const result = await window.client.executionStatus(executionStatusArgs)
if (result.logEntry) { if (result.logEntry) {
updateFromLogEntry(result.logEntry) updateFromLogEntry(result.logEntry)
} else { } else {
@ -79,7 +79,7 @@ async function fetchMostRecentExecution() {
onMounted(() => { onMounted(() => {
fetchMostRecentExecution() fetchMostRecentExecution()
unwatchButtonResults = watch( unwatchButtonResults = watch(
buttonResults, buttonResults,
() => { () => {
@ -87,7 +87,7 @@ onMounted(() => {
const bindingId = props.component.title const bindingId = props.component.title
let mostRecent = null let mostRecent = null
let mostRecentTime = null let mostRecentTime = null
for (const trackingId in buttonResults) { for (const trackingId in buttonResults) {
const logEntry = buttonResults[trackingId] const logEntry = buttonResults[trackingId]
if (logEntry && logEntry.bindingId === bindingId && logEntry.executionFinished) { if (logEntry && logEntry.bindingId === bindingId && logEntry.executionFinished) {
@ -98,7 +98,7 @@ onMounted(() => {
} }
} }
} }
if (mostRecent) { if (mostRecent) {
updateFromLogEntry(mostRecent) updateFromLogEntry(mostRecent)
} }
@ -114,7 +114,9 @@ onBeforeUnmount(() => {
}) })
</script> </script>
<style scoped> <style>
@layer components {
.mre-container { .mre-container {
display: grid; display: grid;
grid-column: span 2; grid-column: span 2;
@ -154,10 +156,11 @@ onBeforeUnmount(() => {
border: 1px dashed #444; border: 1px dashed #444;
box-shadow: 0 0 .6em #444; box-shadow: 0 0 .6em #444;
} }
.mre-link:hover .mre-output { .mre-link:hover .mre-output {
border-color: #666; border-color: #666;
} }
} }
</style> }
</style>

View File

@ -22,22 +22,22 @@
<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>{{ value }}</dd> <dd v-html="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>
<hr /> <hr />
<h3>Dashboard Entity Directories</h3> <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">
<router-link <router-link
:to="{ :to="{
name: 'Dashboard', name: 'Dashboard',
params: { params: {
title: directory, title: directory,
entityType: entityType, entityType: entityType,
entityKey: entityKey entityKey: entityKey

View File

@ -2,3 +2,5 @@
recursive: true recursive: true
require: require:
- mochaSetup.mjs - mochaSetup.mjs
ignore:
- 'tests/**/custom-webui/**'

View File

@ -74,4 +74,15 @@ describe('config: cssClass', function () {
const classAttr = await displayElements[0].getAttribute('class') const classAttr = await displayElements[0].getAttribute('class')
expect(classAttr).to.include('test-display-class') expect(classAttr).to.include('test-display-class')
}) })
it('custom theme applies background color to display component via cssClass', async function () {
await getRootAndWait()
const displayElements = await webdriver.findElements(By.css('.display.test-display-class'))
expect(displayElements).to.have.length.at.least(1, 'Display with test-display-class should exist')
const bgColor = await displayElements[0].getCssValue('background-color')
expect(bgColor, 'Theme theme.css should set .display.test-display-class background to rgb(64, 128, 192)')
.to.match(/rgba?\(\s*64\s*,\s*128\s*,\s*192\s*(,\s*1)?\s*\)/)
})
}) })

View File

@ -2,3 +2,8 @@
.action-button button.test-custom-class { .action-button button.test-custom-class {
background-color: rgb(32, 64, 128); background-color: rgb(32, 64, 128);
} }
/* Display cssClass must be overridable by theme rules (#804) */
.display.test-display-class {
background-color: rgb(64, 128, 192);
}

View File

@ -0,0 +1,16 @@
#
# Integration Test Config: custom JavaScript (#803)
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
enableCustomJs: true
actions: []
dashboards:
- title: Custom JS Dashboard
contents: []

View File

@ -0,0 +1 @@
window.olivetinCustomJsLoaded = true

View File

@ -0,0 +1,35 @@
import { describe, it, before, after, afterEach } from 'mocha'
import { expect } from 'chai'
import { By } from 'selenium-webdriver'
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: customJs', function () {
before(async function () {
await runner.start('customJs')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('loads custom.js when enableCustomJs is true (#803)', async function () {
await getRootAndWait()
await webdriver.wait(async () => {
return await webdriver.executeScript('return window.olivetinCustomJsLoaded === true')
}, 5000, 'custom.js should set window.olivetinCustomJsLoaded when enableCustomJs is enabled')
const loaded = await webdriver.executeScript('return window.olivetinCustomJsLoaded === true')
expect(loaded).to.equal(true)
const scripts = await webdriver.findElements(By.css('#olivetin-custom-js'))
expect(scripts).to.have.length(1, 'custom.js script tag should be injected into the page')
})
})

View File

@ -0,0 +1,25 @@
#
# Integration Test Config: entity HTML in display components (#804)
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
entities:
- file: entities/html_display.yaml
name: html_display
actions: []
dashboards:
- title: Html Display Dashboard
contents:
- title: Compare result
type: fieldset
entity: html_display
contents:
- type: display
cssClass: test-html-display
title: '{{ html_display.content }}'

View File

@ -0,0 +1,2 @@
- content: |
<div class="content">entity-html-test</div>

View File

@ -0,0 +1,34 @@
import { describe, it, before, after, afterEach } from 'mocha'
import { expect } from 'chai'
import { By } from 'selenium-webdriver'
import {
getRootAndWait,
takeScreenshotOnFailure,
waitForDashboardLoaded,
} from '../../lib/elements.js'
describe('config: entityHtmlDisplay', function () {
before(async function () {
await runner.start('entityHtmlDisplay')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('renders entity HTML content inside display components (#804)', async function () {
await getRootAndWait()
await webdriver.get(runner.baseUrl() + 'dashboards/Html%20Display%20Dashboard')
await waitForDashboardLoaded()
const contentDiv = await webdriver.findElements(By.css('.display.test-html-display .content'))
expect(contentDiv).to.have.length.at.least(1, 'Entity HTML should render inside the display component')
const text = await contentDiv[0].getText()
expect(text).to.equal('entity-html-test')
})
})

View File

@ -0,0 +1,126 @@
package api
import (
"context"
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
)
func TestBuildEntityFieldsetDisplayRendersEntityHtmlTitleAndCssClass(t *testing.T) {
entities.ClearEntitiesOfType("html_display")
defer entities.ClearEntitiesOfType("html_display")
entities.AddEntity("html_display", "0", map[string]any{
"content": "<div class=\"content\">test</div>",
})
cfg := config.DefaultConfig()
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Stream status",
Contents: []*config.DashboardComponent{
{
Title: "Compare result",
Type: "fieldset",
Entity: "html_display",
Contents: []*config.DashboardComponent{
{
Type: "display",
CssClass: "full_screen",
Title: "{{ html_display.content }}",
},
},
},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
rr := &DashboardRenderRequest{
cfg: cfg,
ex: ex,
}
fieldsets := buildEntityFieldsets("html_display", cfg.Dashboards[0].Contents[0], rr)
require.Len(t, fieldsets, 1)
display := findComponentByType(fieldsets[0].Contents, "display")
require.NotNil(t, display)
assert.Equal(t, "full_screen", display.CssClass)
assert.Equal(t, "<div class=\"content\">test</div>", display.Title)
}
func findComponentByType(components []*apiv1.DashboardComponent, componentType string) *apiv1.DashboardComponent {
for _, component := range components {
if component.Type == componentType {
return component
}
if found := findNestedComponent(component, componentType); found != nil {
return found
}
}
return nil
}
func findNestedComponent(component *apiv1.DashboardComponent, componentType string) *apiv1.DashboardComponent {
if len(component.Contents) == 0 {
return nil
}
return findComponentByType(component.Contents, componentType)
}
func TestGetDashboardEntityDisplayHtmlTitle(t *testing.T) {
entities.ClearEntitiesOfType("html_display")
defer entities.ClearEntitiesOfType("html_display")
entities.AddEntity("html_display", "0", map[string]any{
"content": "<div class=\"content\">test</div>",
})
cfg := config.DefaultConfig()
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Html Dashboard",
Contents: []*config.DashboardComponent{
{
Title: "Compare result",
Type: "fieldset",
Entity: "html_display",
Contents: []*config.DashboardComponent{
{
Type: "display",
CssClass: "full_screen",
Title: "{{ html_display.content }}",
},
},
},
},
},
}
ts, client := getNewTestServerAndClient(cfg)
defer ts.Close()
resp, err := client.GetDashboard(context.Background(), connect.NewRequest(&apiv1.GetDashboardRequest{
Title: "Html Dashboard",
}))
require.NoError(t, err)
display := findComponentByType(resp.Msg.Dashboard.Contents, "display")
require.NotNil(t, display)
assert.Equal(t, "full_screen", display.CssClass)
assert.Equal(t, "<div class=\"content\">test</div>", display.Title)
}