Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
118 changes: 118 additions & 0 deletions test/store/ui.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const should = require('should') // eslint-disable-line no-unused-vars

// Unit tests for the getters of the Vuex `ui` store (ui/src/store/ui.mjs).
describe('store: ui getters', function () {
let ui
before(async function () {
// ui/src/store/ui.mjs is an ES module with no imports of its own, so it can be dynamically imported straight
// into this (CommonJS) mocha spec.
ui = (await import('../../ui/src/store/ui.mjs')).default
})

// `widgetsByGroup` orders the widgets rendered inside a group. See FlowFuse/node-red-dashboard#710: widgets housed
// in a subflow could only be positioned before or after the whole block of regular widgets, never interleaved
// between them.
describe('widgetsByGroup', function () {

// Each test lists the widgets present (in arbitrary input order) and asserts the ids we expect back in render
// order. Widgets use the same shape as the `state.widgets` entries built by the ui-base node:
// { id, type, props, layout }.
function orderOf (widgets, group = 'g1') {
const state = { widgets: Object.fromEntries(widgets.map((w) => [w.id, w])) }
return ui.getters.widgetsByGroup(state)(group).map((w) => w.id)
}

it('interleaves a subflow between regular widgets (#710)', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 3 } },
{ id: 's', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sf1', order: 2 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['a', 's', 'b'])
})

it('places a subflow at the top of the group', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 2 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 3 } },
{ id: 's', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sf1', order: 1 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['s', 'a', 'b'])
})

it('places a subflow at the bottom of the group', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 2 } },
{ id: 's', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sf1', order: 99 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['a', 'b', 's'])
})

it('sorts a regular-only group purely by layout.order', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 3 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'c', type: 'ui-text', props: { group: 'g1' }, layout: { order: 2 } }
]
orderOf(widgets).should.eql(['b', 'c', 'a'])
})

it('keeps widgets of one subflow instance (mixed types) contiguous and internally ordered by layout.order', function () {
// Subflow "sf1" contributes two widgets of different types (a ui-text and a ui-template), with s2 before s1
// by layout.order, and sits as a block at subflow.order 2 - i.e. between regular widgets a and b.
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 3 } },
{ id: 's1', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sf1', order: 2 } }, layout: { order: 2 } },
{ id: 's2', type: 'ui-text', props: { group: 'g1', subflow: { id: 'sf1', order: 2 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['a', 's2', 's1', 'b'])
})

it('interleaves multiple subflows and regular widgets in the shared order space', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 3 } },
{ id: 'sfA', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sfA', order: 2 } }, layout: { order: 1 } },
{ id: 'sfB', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sfB', order: 4 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['a', 'sfA', 'b', 'sfB'])
})

it('excludes non-local scoped ui-templates', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'global', type: 'ui-template', props: { group: 'g1', templateScope: 'global' }, layout: { order: 2 } },
{ id: 'site', type: 'ui-template', props: { group: 'g1', templateScope: 'site:foo' }, layout: { order: 3 } }
]
orderOf(widgets).should.eql(['a'])
})

it('only returns widgets belonging to the requested group', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g2' }, layout: { order: 1 } }
]
orderOf(widgets, 'g1').should.eql(['a'])
})

it('sinks widgets with an unset/zero order to the end', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 0 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 2 } },
{ id: 'c', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['c', 'b', 'a'])
})

it('sinks a subflow with an unset/zero order to the end', function () {
const widgets = [
{ id: 'a', type: 'ui-text', props: { group: 'g1' }, layout: { order: 1 } },
{ id: 'b', type: 'ui-text', props: { group: 'g1' }, layout: { order: 2 } },
{ id: 's', type: 'ui-template', props: { group: 'g1', templateScope: 'local', subflow: { id: 'sf1', order: 0 } }, layout: { order: 1 } }
]
orderOf(widgets).should.eql(['a', 'b', 's'])
})
})
})
71 changes: 40 additions & 31 deletions ui/src/store/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,48 +102,57 @@ const getters = {
},
widgetsByGroup: (state) => (groupId) => {
if (state.widgets) {
// Since there could be several widgets in a subflow, we need to first group them by subflow.id
// and then sort each widget in the grouped data by layout.order
// then sort the groups by subflow.order
// Widgets in a group share a single ordering space (assigned in the editor's layout manager,
// where both regular widgets and subflow instances get a sequential order). The complication
// is that a single subflow instance can contribute several widgets which must stay contiguous.
// So we build a list of sortable "units": each regular widget is its own unit (ordered by its
// layout.order), while all widgets belonging to the same subflow instance collapse into one
// unit (ordered by subflow.order). Sorting the units together lets a subflow be positioned
// freely between regular widgets, rather than only before/after the whole block of them.

// First though, filter out any non-local scoped ui-templates
const widgetsInGroup = Object.values(state.widgets).filter((w) => {
// return all widgets that belong to the specified group (so long as it is not a non-local scoped ui-template)
return w.props.group === groupId && !(w.type === 'ui-template' && w.props.templateScope !== 'local')
})

// SORTING:
// 1. Group elements by subflow.id (or '_' if no subflow)
const groupedByZ = widgetsInGroup.reduce((acc, curr) => {
const subflowId = curr.props?.subflow?.id || '_'
if (!acc[subflowId]) {
acc[subflowId] = []
// 1. Build sortable units. A regular widget => its own unit; widgets from the same subflow
// instance => a single shared unit so they render contiguously.
const subflowUnits = {}
const units = []
widgetsInGroup.forEach((w) => {
const subflowId = w.props?.subflow?.id
if (subflowId) {
if (!subflowUnits[subflowId]) {
subflowUnits[subflowId] = {
order: w.props.subflow.order || Number.MAX_SAFE_INTEGER,
widgets: []
}
units.push(subflowUnits[subflowId])
}
subflowUnits[subflowId].widgets.push(w)
} else {
units.push({
order: w.layout?.order || Number.MAX_SAFE_INTEGER,
widgets: [w]
})
}
acc[subflowId].push(curr)
return acc
}, {})

// 2. Sort widgets inside each grouping by layout.order so that the widgets are in the correct order inside their respective sections
for (const groupId in groupedByZ) {
groupedByZ[groupId].sort((a, b) => {
// If props.subflow.order is the same, sort by layout.order
const aOrder = a.layout.order || Number.MAX_SAFE_INTEGER
const bOrder = b.layout.order || Number.MAX_SAFE_INTEGER
return aOrder - bOrder
})
}
})

// 3. now sort each grouping by subflow.order (use the first object in the group)
// NOTE: in cases where the grouping is NOT a subflow (i.e. a normal flow with ui elements), the use layout.order
const sortedGroups = Object.entries(groupedByZ).sort((a, b) => {
const o1 = a[1][0]?.props?.subflow?.order || a[1][0]?.layout?.order || Number.MAX_SAFE_INTEGER
const o2 = b[1][0]?.props?.subflow?.order || b[1][0]?.layout?.order || Number.MAX_SAFE_INTEGER
return o1 - o2
// 2. Sort widgets within each subflow unit by their own layout.order.
units.forEach((unit) => {
if (unit.widgets.length > 1) {
unit.widgets.sort((a, b) => {
const aOrder = a.layout?.order || Number.MAX_SAFE_INTEGER
const bOrder = b.layout?.order || Number.MAX_SAFE_INTEGER
return aOrder - bOrder
})
}
})

// 4. Flatten the grouped data back into a single array
const sorted = sortedGroups.flatMap((e) => e[1])
return sorted
// 3. Sort the units together in the shared ordering space, then flatten back to a widget list.
units.sort((a, b) => a.order - b.order)
return units.flatMap((unit) => unit.widgets)
}
},
/**
Expand Down