Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion frontend/src/components/immersive-editor/DrawerTrigger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<button
title="Toggle drawer"
class="drawer-trigger"
:class="{ 'hidden': isHidden }"
:class="{ 'hidden': isHidden, 'nr5-plus': isNr5Plus }"
:aria-label="isHidden ? 'Open drawer' : 'Close drawer'"
:aria-expanded="!isHidden"
type="button"
Expand All @@ -25,6 +25,10 @@ export default {
isHidden: {
type: Boolean,
default: false
},
isNr5Plus: {
type: Boolean,
default: false
}
},
emits: ['toggle']
Expand All @@ -42,6 +46,15 @@ export default {
.ff-layout--immersive--fullscreen & {
top: 10px;
}

&.nr5-plus {
top: 63px;

.ff-layout--immersive--fullscreen & {
top: 4px;
}
}

left: 0;
z-index: 100;
padding: 8px 2px 8px 8px;
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/pages/admin/Template/sections/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ import FormHeading from '../../../../components/FormHeading.vue'
import FormRow from '../../../../components/FormRow.vue'
import FeatureUnavailableToTeam from '../../../../components/banners/FeatureUnavailableToTeam.vue'
import timezonesData from '../../../../data/timezones.json'
import { isInstanceOnNR5Plus } from '../../../../utils/instanceVersion'
import ChangeIndicator from '../components/ChangeIndicator.vue'
import LockSetting from '../components/LockSetting.vue'

Expand Down Expand Up @@ -242,11 +243,7 @@ export default {
emits: ['update:modelValue'],
data () {
return {
timezones: timezonesData.timezones,
defaultThemes: [
{ label: 'FlowFuse Light', value: 'forge-light' },
{ label: 'FlowFuse Dark', value: 'forge-dark' }
] // FUTURE: Get from theme plugins
timezones: timezonesData.timezones
}
},
computed: {
Expand Down Expand Up @@ -291,6 +288,20 @@ export default {
}
return SemVer.satisfies(SemVer.coerce(launcherVersion), '>=2.12.0')
},
instanceOnNR5Plus () {
return isInstanceOnNR5Plus(this.instance)
},
defaultThemes () {
// NR5+ runtime gate collapses Light/Dark to `forge`; show one option.
// Admin Template stays version-agnostic since it targets any NR version.
if (!this.editTemplate && this.instanceOnNR5Plus) {
return [{ label: 'FlowFuse', value: 'forge' }]
}
return [
{ label: 'FlowFuse Light', value: 'forge-light' },
{ label: 'FlowFuse Dark', value: 'forge-dark' }
]
},
themeOptions () {
if (this.modelValue?.settings?.theme && !this.defaultThemes.map(th => th.value).includes(this.modelValue.settings.theme)) {
// set the custom theme as one of the available options
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/device/Editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

<DrawerTrigger
:is-hidden="editorImmersiveDrawer.state"
:is-nr5-plus="isDeviceOnNR5Plus"
@toggle="toggleEditorImmersiveDrawer"
/>
</div>
Expand Down Expand Up @@ -72,6 +73,7 @@ import EditorWrapper from '../../../components/immersive-editor/RemoteInstanceEd
import { useDeviceHelper } from '../../../composables/DeviceHelper.js'
import usePermissions from '../../../composables/Permissions.js'
import Alerts from '../../../services/alerts.js'
import { isInstanceOnNR5Plus } from '../../../utils/instanceVersion'

import DeviceAssignApplicationDialog from '../../team/Devices/dialogs/DeviceAssignApplicationDialog.vue'
import DeviceAssignInstanceDialog from '../../team/Devices/dialogs/DeviceAssignInstanceDialog.vue'
Expand Down Expand Up @@ -140,6 +142,9 @@ export default {
isExpertRoute () {
return this.$route.name === 'device-editor-expert'
},
isDeviceOnNR5Plus () {
return isInstanceOnNR5Plus(this.device)
},
isDevModeAvailable: function () {
return !!this.features.deviceEditor
},
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/instance/Editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

<DrawerTrigger
:is-hidden="editorImmersiveDrawer.state"
:is-nr5-plus="isInstanceOnNR5Plus"
@toggle="toggleEditorImmersiveDrawer"
/>
</div>
Expand All @@ -60,6 +61,7 @@ import InstanceActionsButton from '../../../components/instance/ActionButton.vue
import usePermissions from '../../../composables/Permissions.js'
import instanceMixin from '../../../mixins/Instance.js'

import { isInstanceOnNR5Plus } from '../../../utils/instanceVersion'
import { Roles } from '../../../utils/roles.js'
import ConfirmInstanceDeleteDialog from '../Settings/dialogs/ConfirmInstanceDeleteDialog.vue'
import DashboardLink from '../components/DashboardLink.vue'
Expand Down Expand Up @@ -166,6 +168,9 @@ export default {
},
isExpertRoute () {
return this.$route.name === 'instance-editor-expert'
},
isInstanceOnNR5Plus () {
return isInstanceOnNR5Plus(this.instance)
}
},
watch: {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/pages/instance/Settings/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useRouter } from 'vue-router'
import InstanceApi from '../../../api/instances.js'
import usePermissions from '../../../composables/Permissions.js'
import Dialog from '../../../services/dialog.js'
import { isInstanceOnNR5Plus } from '../../../utils/instanceVersion'
import TemplateSettingsEditor from '../../admin/Template/sections/Editor.vue'
import {
getObjectValue,
Expand Down Expand Up @@ -141,6 +142,16 @@ export default {
this.original.settings[field] = projectSettingsValue
}
})
// On NR5+ the runtime gate renders forge-light/forge-dark as `forge`.
// Normalize editable + original together so the dropdown matches without
// tripping the "changed" indicator. Lazy DB migration on next save.
if (isInstanceOnNR5Plus(this.project)) {
const storedTheme = this.editable.settings.theme
if (storedTheme === 'forge-light' || storedTheme === 'forge-dark') {
this.editable.settings.theme = 'forge'
this.original.settings.theme = 'forge'
}
}
}
},
async saveSettings () {
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/utils/instanceVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import SemVer from 'semver'

import { Maybe } from '@/types/common/types'

/** Either an instance (Node-RED version under `meta.versions['node-red']`)
* or a device (top-level `nodeRedVersion` field). */
interface IInstanceVersionProps {
meta?: {
versions?: Record<string, string | undefined>
}
nodeRedVersion?: string | null
}

export function isInstanceOnNR5Plus (target: Maybe<IInstanceVersionProps> | undefined): boolean {
const nrVersion = target?.meta?.versions?.['node-red'] ?? target?.nodeRedVersion
if (!nrVersion) {
return false
}
return SemVer.satisfies(SemVer.coerce(nrVersion), '>=5.0.0')
}
55 changes: 55 additions & 0 deletions test/unit/frontend/utils/instanceVersion.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from 'vitest'

import { isInstanceOnNR5Plus } from '../../../../frontend/src/utils/instanceVersion.ts'

describe('isInstanceOnNR5Plus', () => {
test('returns false for null / undefined / empty objects', () => {
expect(isInstanceOnNR5Plus(null)).toBe(false)
expect(isInstanceOnNR5Plus(undefined)).toBe(false)
expect(isInstanceOnNR5Plus({})).toBe(false)
expect(isInstanceOnNR5Plus({ meta: {} })).toBe(false)
expect(isInstanceOnNR5Plus({ meta: { versions: {} } })).toBe(false)
})

test('returns false when the reported NR version is below 5', () => {
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '4.1.8' } } })).toBe(false)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '3.1.0' } } })).toBe(false)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '4.99.99' } } })).toBe(false)
})

test('returns true for NR 5.x stable releases', () => {
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.0.0' } } })).toBe(true)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.1.0' } } })).toBe(true)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.10.5' } } })).toBe(true)
})

test('returns true for NR 5.x prereleases (SemVer.coerce strips the tag)', () => {
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.0.0-beta.6' } } })).toBe(true)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.0.0-rc.1' } } })).toBe(true)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '5.0.0-alpha.0' } } })).toBe(true)
})

test('returns true for future major versions', () => {
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '6.0.0' } } })).toBe(true)
expect(isInstanceOnNR5Plus({ meta: { versions: { 'node-red': '10.2.3' } } })).toBe(true)
})

test('reads the device shape (top-level nodeRedVersion)', () => {
expect(isInstanceOnNR5Plus({ nodeRedVersion: '5.0.0-beta.6' })).toBe(true)
expect(isInstanceOnNR5Plus({ nodeRedVersion: '4.1.8' })).toBe(false)
expect(isInstanceOnNR5Plus({ nodeRedVersion: null })).toBe(false)
expect(isInstanceOnNR5Plus({ nodeRedVersion: '' })).toBe(false)
})

test('prefers instance shape over device shape when both are present', () => {
// Edge case: an object that has both fields. instance.meta.versions wins.
expect(isInstanceOnNR5Plus({
meta: { versions: { 'node-red': '5.0.0' } },
nodeRedVersion: '4.1.8'
})).toBe(true)
expect(isInstanceOnNR5Plus({
meta: { versions: { 'node-red': '4.1.8' } },
nodeRedVersion: '5.0.0'
})).toBe(false)
})
})
Loading