diff --git a/cypress/fixtures/flows/dashboard-layouts.json b/cypress/fixtures/flows/dashboard-layouts.json index f762f16dd..85d44582c 100644 --- a/cypress/fixtures/flows/dashboard-layouts.json +++ b/cypress/fixtures/flows/dashboard-layouts.json @@ -64,6 +64,68 @@ "groupOutline": "#cccccc" } }, + { + "id": "dashboard-ui-page-tabs", + "type": "ui-page", + "name": "Layout: Tabs", + "ui": "dashboard-ui-base", + "path": "/tabs", + "icon": "", + "layout": "tabs", + "theme": "dashboard-ui-theme", + "tabs": [ + { + "id": "tab-compose", + "name": "Compose" + }, + { + "id": "tab-inbox", + "name": "Inbox" + } + ], + "order": 1, + "className": "", + "visible": "true", + "disabled": false + }, + { + "id": "dashboard-ui-page-tabs-legacy", + "type": "ui-page", + "name": "Layout: Tabs (no page tabs)", + "ui": "dashboard-ui-base", + "path": "/tabs-legacy", + "icon": "", + "layout": "tabs", + "theme": "dashboard-ui-theme", + "order": 1, + "className": "", + "visible": "true", + "disabled": false + }, + { + "id": "dashboard-ui-page-tabs-wizard", + "type": "ui-page", + "name": "Layout: Tabs (wizard)", + "ui": "dashboard-ui-base", + "path": "/tabs-wizard", + "icon": "", + "layout": "tabs", + "theme": "dashboard-ui-theme", + "tabs": [ + { + "id": "tab-step1", + "name": "Step 1" + }, + { + "id": "tab-step2", + "name": "Step 2" + } + ], + "order": 1, + "className": "", + "visible": "true", + "disabled": false + }, { "id": "dashboard-ui-group-grid1", "type": "ui-group", @@ -181,6 +243,142 @@ "visible": "true", "disabled": "false" }, + { + "id": "dashboard-ui-group-tabs-loose", + "type": "ui-group", + "name": "Loose", + "page": "dashboard-ui-page-tabs", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false" + }, + { + "id": "dashboard-ui-group-tabs-inbox", + "type": "ui-group", + "name": "Inbox List", + "page": "dashboard-ui-page-tabs", + "width": "6", + "height": "1", + "order": 2, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false", + "tab": "tab-inbox" + }, + { + "id": "dashboard-ui-group-tabs-compose2", + "type": "ui-group", + "name": "Attachments", + "page": "dashboard-ui-page-tabs", + "width": "6", + "height": "1", + "order": 4, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false", + "tab": "tab-compose" + }, + { + "id": "dashboard-ui-group-tabs-compose1", + "type": "ui-group", + "name": "Compose", + "page": "dashboard-ui-page-tabs", + "width": "6", + "height": "1", + "order": 3, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false", + "tab": "tab-compose" + }, + { + "id": "dashboard-ui-group-legacy-b", + "type": "ui-group", + "name": "Legacy B", + "page": "dashboard-ui-page-tabs-legacy", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false" + }, + { + "id": "dashboard-ui-group-legacy-a", + "type": "ui-group", + "name": "Legacy A", + "page": "dashboard-ui-page-tabs-legacy", + "width": "6", + "height": "1", + "order": 0, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false" + }, + { + "id": "dashboard-ui-group-legacy-hidden", + "type": "ui-group", + "name": "Legacy Hidden", + "page": "dashboard-ui-page-tabs-legacy", + "width": "6", + "height": "1", + "order": 2, + "showTitle": true, + "className": "", + "visible": false, + "disabled": "false" + }, + { + "id": "dashboard-ui-group-legacy-dialog", + "type": "ui-group", + "name": "Legacy Dialog", + "page": "dashboard-ui-page-tabs-legacy", + "width": "6", + "height": "1", + "order": 3, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false", + "groupType": "dialog" + }, + { + "id": "dashboard-ui-group-wizard-step1", + "type": "ui-group", + "name": "Step 1", + "page": "dashboard-ui-page-tabs-wizard", + "width": "6", + "height": "1", + "order": 1, + "showTitle": true, + "className": "", + "visible": "true", + "disabled": "false", + "tab": "tab-step1" + }, + { + "id": "dashboard-ui-group-wizard-step2", + "type": "ui-group", + "name": "Step 2", + "page": "dashboard-ui-page-tabs-wizard", + "width": "6", + "height": "1", + "order": 2, + "showTitle": true, + "className": "", + "visible": false, + "disabled": "false", + "tab": "tab-step2" + }, { "id": "dashboard-ui-button", "type": "ui-button", @@ -246,5 +444,107 @@ "x": 250, "y": 100, "wires": [] + }, + { + "id": "dashboard-ui-button-tabs-hide-inbox", + "type": "ui-button", + "z": "node-red-tab-layouts", + "group": "dashboard-ui-group-tabs-inbox", + "name": "", + "label": "Hide Inbox", + "order": 1, + "width": 0, + "height": 0, + "emulateClick": false, + "tooltip": "", + "color": "", + "bgcolor": "", + "className": "", + "icon": "", + "iconPosition": "left", + "payload": "{\"groups\":{\"hide\":[\"dashboard-ui-group-tabs-inbox\"]}}", + "payloadType": "json", + "topic": "topic", + "topicType": "msg", + "buttonColor": "", + "textColor": "", + "iconColor": "", + "enableClick": true, + "enablePointerdown": false, + "pointerdownPayload": "", + "pointerdownPayloadType": "str", + "enablePointerup": false, + "pointerupPayload": "", + "pointerupPayloadType": "str", + "x": 170, + "y": 300, + "wires": [ + [ + "dashboard-ui-control-tabs" + ] + ] + }, + { + "id": "dashboard-inject-tabs-show-inbox", + "type": "inject", + "z": "node-red-tab-layouts", + "name": "Show Inbox", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "{\"groups\":{\"show\":[\"dashboard-ui-group-tabs-inbox\"]}}", + "payloadType": "json", + "x": 170, + "y": 340, + "wires": [ + [ + "dashboard-ui-control-tabs" + ] + ] + }, + { + "id": "dashboard-inject-tabs-wizard-next", + "type": "inject", + "z": "node-red-tab-layouts", + "name": "Wizard next", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "{\"groups\":{\"hide\":[\"dashboard-ui-group-wizard-step1\"],\"show\":[\"dashboard-ui-group-wizard-step2\"]}}", + "payloadType": "json", + "x": 170, + "y": 380, + "wires": [ + [ + "dashboard-ui-control-tabs" + ] + ] + }, + { + "id": "dashboard-ui-control-tabs", + "type": "ui-control", + "z": "node-red-tab-layouts", + "name": "", + "ui": "dashboard-ui-base", + "events": "all", + "x": 380, + "y": 300, + "wires": [ + [] + ] } ] \ No newline at end of file diff --git a/cypress/tests/config-nodes/tabs-sidebar.spec.js b/cypress/tests/config-nodes/tabs-sidebar.spec.js new file mode 100644 index 000000000..72a5eb207 --- /dev/null +++ b/cypress/tests/config-nodes/tabs-sidebar.spec.js @@ -0,0 +1,57 @@ +function openEditor () { + cy.intercept({ method: 'GET', pathname: '/settings' }, (req) => { + req.continue((res) => { + res.body.telemetryEnabled = false + res.body.editorTheme = { ...res.body.editorTheme, tours: false } + }) + }) + cy.loadFlows() + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').should('exist') +} + +describe('Node-RED Dashboard 2.0 - Editor: Sidebar tabs', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + openEditor() + cy.window().its('RED.sidebar').invoke('show', 'dashboard-2.0') + }) + + it('renders page tabs in order, followed by a No tab row holding untabbed groups', () => { + cy.contains('.nrdb2-sb-pages-list-header .nrdb2-sb-title', /^Layout: Tabs$/).closest('li').within(() => { + cy.get('.nrdb2-sb-tabs-list-header .nrdb2-sb-title').should((titles) => { + expect([...titles].map((t) => t.textContent)).to.deep.equal(['Compose', 'Inbox', 'No tab']) + }) + cy.get('.nrdb2-sb-untabbed-groups .nrdb2-sb-groups-list-header .nrdb2-sb-title').should('have.text', 'Loose') + }) + }) + + it('renders groups directly under pages without page tabs', () => { + cy.contains('.nrdb2-sb-pages-list-header .nrdb2-sb-title', 'Layout: Tabs (no page tabs)').closest('li').within(() => { + cy.get('.nrdb2-sb-tabs-list-header').should('not.exist') + cy.get('.nrdb2-sb-groups-list-header').should('have.length', 4) + }) + cy.contains('.nrdb2-sb-pages-list-header .nrdb2-sb-title', 'Layout: Grid').closest('li').within(() => { + cy.get('.nrdb2-sb-tabs-list-header').should('not.exist') + }) + }) + + it('adds a group under No tab without touching existing groups', () => { + cy.contains('.nrdb2-sb-pages-list-header .nrdb2-sb-title', /^Layout: Tabs$/).parent().find('a[title="Add Group"]').click({ force: true }) + cy.get('#node-config-dialog-ok').click() + cy.get('#node-config-dialog-ok').should('not.exist') + cy.window().its('RED').should((RED) => { + const groups = [] + RED.nodes.eachConfig((n) => { + if (n.type === 'ui-group' && n.page === 'dashboard-ui-page-tabs') { + groups.push(n) + } + }) + const added = groups.find((g) => !g.id.startsWith('dashboard-')) + expect(added.order).to.equal(5) + expect(added.tab || '').to.equal('') + expect(groups.filter((g) => g !== added && g.changed).map((g) => g.id)).to.deep.equal([]) + const touchesAdded = (e) => (e.events || [e]).some((x) => x.node === added || (x.nodes || []).includes(added.id)) + expect(RED.history.list().filter(touchesAdded)).to.have.length(2) + }) + }) +}) diff --git a/cypress/tests/config-nodes/tabs.spec.js b/cypress/tests/config-nodes/tabs.spec.js new file mode 100644 index 000000000..0b8aab9f2 --- /dev/null +++ b/cypress/tests/config-nodes/tabs.spec.js @@ -0,0 +1,99 @@ +function openEditor () { + cy.intercept({ method: 'GET', pathname: '/settings' }, (req) => { + req.continue((res) => { + res.body.telemetryEnabled = false + res.body.editorTheme = { ...res.body.editorTheme, tours: false } + }) + }) + cy.loadFlows() + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').should('exist') +} + +function editConfig (type, id) { + cy.window().its('RED.editor').invoke('editConfig', '', type, id) + cy.get('#node-config-dialog-ok').should('be.visible') +} + +describe('Node-RED Dashboard 2.0 - Editor: Page tabs', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + openEditor() + }) + + it('shows the Tabs list only for the Tabs layout, with a hint while empty', () => { + editConfig('ui-page', 'dashboard-ui-page-grid') + cy.get('#node-config-container-tabs').should('not.be.visible') + + cy.window().invoke('$', '#node-config-input-layout').invoke('typedInput', 'value', 'tabs') + cy.get('#node-config-container-tabs').should('be.visible') + cy.get('#node-config-tabs-empty').should('be.visible') + cy.get('#node-config-container-tabs .red-ui-editableList-border').should('have.css', 'display', 'none') + + cy.get('#node-config-container-tabs .red-ui-editableList-addButton').click() + cy.get('#node-config-tabs-empty').should('not.be.visible') + cy.get('#node-config-container-tabs .red-ui-editableList-border').should('not.have.css', 'display', 'none') + cy.get('.node-input-tab-name').should('have.length', 1) + }) + + it('keeps existing tab ids when renaming and generates ids for new tabs', () => { + editConfig('ui-page', 'dashboard-ui-page-tabs') + cy.get('.node-input-tab-name').should('have.length', 2) + cy.get('.node-input-tab-name').eq(0).clear() + cy.get('.node-input-tab-name').eq(0).type('Compose renamed') + cy.get('#node-config-container-tabs .red-ui-editableList-addButton').click() + cy.get('.node-input-tab-name').eq(2).type('Settings') + cy.get('#node-config-dialog-ok').click() + + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').its('tabs').should((tabs) => { + expect(tabs.map((t) => t.name)).to.deep.equal(['Compose renamed', 'Inbox', 'Settings']) + expect(tabs[0].id).to.equal('tab-compose') + expect(tabs[1].id).to.equal('tab-inbox') + expect(tabs[2].id).to.be.a('string').and.not.be.oneOf(['', 'tab-compose', 'tab-inbox']) + }) + }) + + it('flags a blank tab name and marks the page invalid when saved', () => { + editConfig('ui-page', 'dashboard-ui-page-tabs') + cy.get('#node-config-container-tabs .red-ui-editableList-addButton').click() + cy.get('.node-input-tab-name').eq(2).should('have.class', 'input-error') + cy.get('.node-input-tab-name').eq(2).type(' ') + cy.get('.node-input-tab-name').eq(2).should('have.class', 'input-error') + cy.get('#node-config-dialog-ok').click() + cy.get('#node-config-dialog-ok').should('not.exist') + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').its('valid').should('equal', false) + + editConfig('ui-page', 'dashboard-ui-page-tabs') + cy.get('.node-input-tab-name').eq(2).type('Settings') + cy.get('.node-input-tab-name').eq(2).should('not.have.class', 'input-error') + cy.get('#node-config-dialog-ok').click() + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').its('valid').should('equal', true) + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-page-tabs').its('tabs').should((tabs) => { + expect(tabs.map((t) => t.name)).to.deep.equal(['Compose', 'Inbox', 'Settings']) + }) + }) +}) + +describe('Node-RED Dashboard 2.0 - Editor: Group tab', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + openEditor() + }) + + it('lists the page tabs and selects the group\'s tab', () => { + editConfig('ui-group', 'dashboard-ui-group-tabs-inbox') + cy.get('#node-config-row-tab').should('be.visible') + cy.get('#node-config-input-tab option').should((options) => { + expect([...options].map((o) => o.textContent)).to.deep.equal(['None (own tab)', 'Compose', 'Inbox']) + }) + cy.get('#node-config-input-tab').should('have.value', 'tab-inbox') + }) + + it('hides and clears the tab when the group moves to a page without the Tabs layout', () => { + editConfig('ui-group', 'dashboard-ui-group-tabs-inbox') + cy.get('#node-config-input-page').select('dashboard-ui-page-grid') + cy.get('#node-config-row-tab').should('not.be.visible') + cy.get('#node-config-dialog-ok').click() + + cy.window().its('RED.nodes').invoke('node', 'dashboard-ui-group-tabs-inbox').its('tab').should('equal', '') + }) +}) diff --git a/cypress/tests/layouts.spec.js b/cypress/tests/layouts.spec.js index cc7836a5b..9ac81f50c 100644 --- a/cypress/tests/layouts.spec.js +++ b/cypress/tests/layouts.spec.js @@ -1,3 +1,4 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ // test admin rights & access in FlowFuse describe('Node-RED Dashboard 2.0 - Layout: Grid', () => { @@ -42,6 +43,126 @@ describe('Node-RED Dashboard 2.0 - Layout: Notebook', () => { }) }) +describe('Node-RED Dashboard 2.0 - Layout: Tabs', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + cy.visit('/dashboard/tabs') + }) + + it('should render page tabs in list order, followed by untabbed groups', () => { + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 3) + cy.get('.nrdb-layout--tabs .v-tab').eq(0).should('contain.text', 'Compose') + cy.get('.nrdb-layout--tabs .v-tab').eq(1).should('contain.text', 'Inbox') + cy.get('.nrdb-layout--tabs .v-tab').eq(2).should('contain.text', 'Loose') + }) + + it('should render every group assigned to the active tab, in group order', () => { + cy.get('.v-window-item--active .nrdb-ui-group').should('have.length', 2) + cy.get('.v-window-item--active .nrdb-ui-group').eq(0).find('.v-card-title').should('have.text', 'Compose') + cy.get('.v-window-item--active .nrdb-ui-group').eq(1).find('.v-card-title').should('have.text', 'Attachments') + }) + + it('should switch to the groups of the selected tab', () => { + cy.get('.nrdb-layout--tabs .v-tab').eq(1).click() + cy.get('.v-window-item--active .nrdb-ui-group').should('have.length', 1) + cy.get('.v-window-item--active #nrdb-ui-group-dashboard-ui-group-tabs-inbox').should('exist') + }) + + it('should render a single-group tab without a group title, matching group-per-tab tabs', () => { + cy.get('.nrdb-layout--tabs .v-tab').eq(1).click() + cy.get('.v-window-item--active #nrdb-ui-group-dashboard-ui-group-tabs-inbox .v-card').should('be.visible') + .find('.v-card-title').should('not.exist') + }) + + it('should fall back when the active tab is hidden, without jumping back when it reappears', () => { + cy.wait(1000) + cy.reloadDashboard() + cy.get('.nrdb-layout--tabs .v-tab').eq(1).click() + cy.get('.v-window-item--active #nrdb-ui-widget-dashboard-ui-button-tabs-hide-inbox').should('be.visible') + cy.wait(500) + cy.clickAndWait(cy.get('#nrdb-ui-widget-dashboard-ui-button-tabs-hide-inbox')) + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 2) + cy.get('.nrdb-layout--tabs .v-tab--selected').should('have.length', 1) + cy.get('.v-window-item--active .nrdb-ui-group').should('have.length.at.least', 1) + + cy.request('POST', '/inject/dashboard-inject-tabs-show-inbox') + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 3) + cy.wait(500) + cy.get('.nrdb-layout--tabs .v-tab--selected').should('have.length', 1).and('not.contain.text', 'Inbox') + }) +}) + +describe('Node-RED Dashboard 2.0 - Layout: Tabs across redeploys', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + cy.visit('/dashboard/tabs') + }) + + it('should keep the selected tab when flows are redeployed', () => { + cy.wait(1000) + cy.reloadDashboard() + cy.get('.nrdb-layout--tabs .v-tab').eq(1).click() + cy.get('.nrdb-layout--tabs .v-tab').eq(1).should('have.class', 'v-tab--selected') + // eslint-disable-next-line promise/catch-or-return + cy.request({ url: '/flows', headers: { 'Node-RED-API-Version': 'v2' } }).then((res) => { + return cy.deployFlow(res.body.rev, res.body.flows) + }) + cy.wait(3000) + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 3) + cy.get('.nrdb-layout--tabs .v-tab').eq(1).should('have.class', 'v-tab--selected') + }) + + it('should keep the first tab when flows are redeployed before any tab is clicked', () => { + cy.wait(1000) + cy.reloadDashboard() + cy.get('.nrdb-layout--tabs .v-tab').eq(0).should('have.class', 'v-tab--selected') + // eslint-disable-next-line promise/catch-or-return + cy.request({ url: '/flows', headers: { 'Node-RED-API-Version': 'v2' } }).then((res) => { + return cy.deployFlow(res.body.rev, res.body.flows) + }) + cy.wait(3000) + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 3) + cy.get('.nrdb-layout--tabs .v-tab').eq(0).should('have.class', 'v-tab--selected') + }) +}) + +describe('Node-RED Dashboard 2.0 - Layout: Tabs wizard', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + cy.visit('/dashboard/tabs-wizard') + }) + + it('should select the revealed tab when the active tab is hidden and another shown together', () => { + cy.wait(1000) + cy.reloadDashboard() + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 1) + cy.request('POST', '/inject/dashboard-inject-tabs-wizard-next') + cy.wait(1000) + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 1).and('contain.text', 'Step 2') + cy.get('.nrdb-layout--tabs .v-tab--selected').should('have.length', 1) + cy.get('.v-window-item--active #nrdb-ui-group-dashboard-ui-group-wizard-step2').should('be.visible') + }) +}) + +describe('Node-RED Dashboard 2.0 - Layout: Tabs without page tabs', () => { + beforeEach(() => { + cy.deployFixture('dashboard-layouts') + cy.visit('/dashboard/tabs-legacy') + }) + + it('should render each visible, non-dialog group as its own tab, in group order', () => { + cy.get('.nrdb-layout--tabs .v-tab').should('have.length', 2) + cy.get('.nrdb-layout--tabs .v-tab').eq(0).should('contain.text', 'Legacy A') + cy.get('.nrdb-layout--tabs .v-tab').eq(1).should('contain.text', 'Legacy B') + }) + + it('should render the group full width without a title', () => { + cy.get('.v-window-item--active #nrdb-ui-group-dashboard-ui-group-legacy-a .v-card').should('be.visible') + .find('.v-card-title').should('not.exist') + cy.get('.v-window-item--active .nrdb-layout--tabs-grid').should('not.exist') + }) +}) + describe('Node-RED Dashboard 2.0 - Groups', () => { beforeEach(() => { cy.deployFixture('dashboard-layouts') diff --git a/docs/en/layouts/types/tabs.md b/docs/en/layouts/types/tabs.md index 44a388d0f..8c81ff8c0 100644 --- a/docs/en/layouts/types/tabs.md +++ b/docs/en/layouts/types/tabs.md @@ -1,5 +1,5 @@ --- -description:Render each group as a standalone Tab in a tabbed view for the page +description: Render a page's groups in a tabbed view, with one or more groups per tab ---