diff --git a/test/store/ui.spec.js b/test/store/ui.spec.js new file mode 100644 index 000000000..f9ac67514 --- /dev/null +++ b/test/store/ui.spec.js @@ -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']) + }) + }) +}) diff --git a/ui/src/store/ui.mjs b/ui/src/store/ui.mjs index 8a41f8b41..652144812 100644 --- a/ui/src/store/ui.mjs +++ b/ui/src/store/ui.mjs @@ -102,9 +102,13 @@ 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) => { @@ -112,38 +116,43 @@ const getters = { 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) } }, /**