Explorar el Código

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

jamesread hace 2 semanas
padre
commit
37cde0d982

+ 7 - 4
frontend/resources/vue/components/DashboardComponentDirectory.vue

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

+ 5 - 2
frontend/resources/vue/components/DashboardComponentDisplay.vue

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

+ 14 - 11
frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue

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

+ 6 - 6
frontend/resources/vue/views/EntityDetailsView.vue

@@ -22,22 +22,22 @@
 				<template v-if="entityDetails.fields">
 					<template v-for="(value, key) in entityDetails.fields" :key="key">
 						<dt>{{ key }}</dt>
-						<dd>{{ value }}</dd>
+						<dd v-html="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>
 
 			<hr />
-			
+
 			<h3>Dashboard Entity Directories</h3>
 			<div v-if="filteredDirectories.length > 0" class="directories-section">
 				<ul class="directory-list">
 					<li v-for="(directory, idx) in filteredDirectories" :key="idx">
-						<router-link 
-							:to="{ 
-								name: 'Dashboard', 
-								params: { 
+						<router-link
+							:to="{
+								name: 'Dashboard',
+								params: {
 									title: directory,
 									entityType: entityType,
 									entityKey: entityKey

+ 2 - 0
integration-tests/.mocharc.yml

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

+ 11 - 0
integration-tests/tests/cssClass/cssClass.mjs

@@ -74,4 +74,15 @@ describe('config: cssClass', function () {
     const classAttr = await displayElements[0].getAttribute('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*\)/)
+  })
 })

+ 5 - 0
integration-tests/tests/cssClass/custom-webui/themes/cssclass-theme/theme.css

@@ -2,3 +2,8 @@
 .action-button button.test-custom-class {
   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);
+}

+ 16 - 0
integration-tests/tests/customJs/config.yaml

@@ -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: []

+ 1 - 0
integration-tests/tests/customJs/custom-webui/custom.js

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

+ 35 - 0
integration-tests/tests/customJs/customJs.mjs

@@ -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')
+  })
+})

+ 25 - 0
integration-tests/tests/entityHtmlDisplay/config.yaml

@@ -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 }}'

+ 2 - 0
integration-tests/tests/entityHtmlDisplay/entities/html_display.yaml

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

+ 34 - 0
integration-tests/tests/entityHtmlDisplay/entityHtmlDisplay.mjs

@@ -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')
+  })
+})

+ 126 - 0
service/internal/api/dashboard_entities_test.go

@@ -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)
+}