diff --git a/client/cypress/e2e/filter-panel.cy.ts b/client/cypress/e2e/filter-panel.cy.ts new file mode 100644 index 0000000..7e2e5e6 --- /dev/null +++ b/client/cypress/e2e/filter-panel.cy.ts @@ -0,0 +1,249 @@ +export {} +// +// The applied-filter panel shows the clause that is in force. A long clause +// does not fit the collapsed single line, so the panel carries a chevron that +// expands it to show the clause in full - in the viewer and in the editor. +// +// The chevron is only rendered when the clause actually overflows, so a short +// filter looks exactly as it did before. + +const hostUrl = Cypress.env('hosturl') +const appLocation = Cypress.env('appLocation') +const longerCommandTimeout = Cypress.env('longerCommandTimeout') + +context('applied filter panel: ', function () { + this.beforeAll(() => { + cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout }) + cy.loginAndUpdateValidKey(true) + }) + + // the panel behaviour depends on how much room the clause has, so pin a + // laptop-sized window rather than the wider CI viewport + this.beforeEach(() => { + cy.viewport(1280, 800) + bootApp() + }) + + it('1 | without a filter the panel is not rendered', () => { + openTable('DC_JSLIB.MPE_X_TEST') + + cy.get('.infoBar').should('not.exist') + + cy.screenshot('filter-panel-1-no-filter', { capture: 'viewport' }) + }) + + it('2 | a short filter is shown in full, with no chevron', () => { + openTable('DC_JSLIB.MPE_X_TEST') + + openFilterPopup() + setFilterVariable('SOME_DROPDOWN') + setFilterValue('Option 1') + submitFilter() + + cy.get('.infoBar-text').should('contain.text', "SOME_DROPDOWN = 'Option 1'") + // the clause fits the collapsed line, so there is nothing to expand + assertClauseFitsCollapsedLine() + cy.get('.infoBar-toggle').should('not.exist') + + cy.screenshot('filter-panel-2-filter', { capture: 'viewport' }) + }) + + it('3 | a long filter is collapsed to a chevron and expands to the full clause', () => { + openTable('DC_JSLIB.MPE_X_TEST') + + // SOME_CHAR holds a very long free-text value, so an IN over every value + // produces a clause of several hundred characters + openFilterPopup() + setFilterVariable('SOME_CHAR') + setFilterOperator('IN') + chooseAllFilterValues() + submitFilter() + + cy.get('.infoBar-text').should('contain.text', 'SOME_CHAR IN') + assertClauseOverflowsCollapsedLine() + cy.get('.infoBar-toggle') + .should('exist') + .and('have.attr', 'aria-expanded', 'false') + + cy.screenshot('filter-panel-3-big-filter-collapsed', { + capture: 'viewport' + }) + + // expand: the whole clause is rendered, on several lines, unclipped + cy.get('.infoBar-toggle').click() + cy.get('.infoBar').should('have.class', 'expanded') + cy.get('.infoBar-toggle').should('have.attr', 'aria-expanded', 'true') + assertClauseFullyVisible() + assertClauseSpansSeveralLines() + + cy.screenshot('filter-panel-3-big-filter-expanded', { capture: 'viewport' }) + + // collapse again + cy.get('.infoBar-toggle').click() + cy.get('.infoBar').should('not.have.class', 'expanded') + assertClauseOverflowsCollapsedLine() + }) + + it('4 | the editor panel collapses and expands in the same way', () => { + openTable('DC_JSLIB.MPE_X_TEST') + + openFilterPopup() + setFilterVariable('SOME_CHAR') + setFilterOperator('IN') + chooseAllFilterValues() + submitFilter() + + // the editor shows the filter the viewer just stored + cy.url().then((url) => { + const filterId = url.split('/').pop() + cy.visit( + `${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST/${filterId}`, + { timeout: longerCommandTimeout } + ) + }) + + cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should( + 'contain.text', + 'SOME_CHAR IN' + ) + assertClauseOverflowsCollapsedLine() + + cy.get('.infoBar-toggle').click() + cy.get('.infoBar').should('have.class', 'expanded') + assertClauseFullyVisible() + assertClauseSpansSeveralLines() + }) +}) + +/** + * Cypress clears cookies between tests, which drops the SASjs Server session + * and lands the app back on the evaluation agreement card. Accept it when it is + * shown - a no-op when the session survived. + */ +const bootApp = () => { + cy.visit(hostUrl, { timeout: longerCommandTimeout }) + cy.get('body').then(($body: any) => { + if ($body.find('#TCS input[type="checkbox"]').length) { + cy.get('#TCS input[type="checkbox"]').check({ force: true }) + cy.wait(4000) + } + }) +} + +/** Opens a table in the viewer and waits for its grid to render. */ +const openTable = (libMem: string) => { + cy.visit(`${hostUrl}${appLocation}/#/view/data/${libMem}`, { + timeout: longerCommandTimeout + }) + cy.get('.filterSide', { timeout: longerCommandTimeout }).should('exist') + cy.wait(2500) +} + +const openFilterPopup = () => { + cy.get('.filterSide', { timeout: longerCommandTimeout }).click() + cy.get('clr-dropdown-menu', { timeout: longerCommandTimeout }) + .contains('Filter') + .click() + cy.get('.filter-modal', { timeout: longerCommandTimeout }).should( + 'be.visible' + ) +} + +/** + * The soft-select inputs drop a transparent .overlay click-catcher over the + * modal while their suggestion list is open, so they need force. + */ +const setFilterVariable = (column: string) => { + cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true }) + cy.get('#datalist_vals_var_id0_0 option').contains(column).click({ + force: true + }) + cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true }) + cy.wait(400) +} + +const setFilterValue = (value: string) => { + cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true }) + cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true }) + cy.wait(400) +} + +const setFilterOperator = (operator: string) => { + cy.get('.filter-modal .operator-col select').first().select(operator) + cy.wait(500) +} + +/** IN/NOT IN take their values from a modal of checkboxes - take them all. */ +const chooseAllFilterValues = () => { + cy.contains('.filter-modal button', 'Choose values').click() + cy.get('.in-values-modal', { timeout: longerCommandTimeout }).should( + 'be.visible' + ) + cy.get('.in-values-modal input[type=checkbox]').then(($checkboxes: any) => { + for (let i = 0; i < $checkboxes.length; i++) { + cy.get('.in-values-modal input[type=checkbox]') + .eq(i) + .click({ force: true }) + } + }) + cy.contains('.in-values-modal button', 'Apply').click() + cy.wait(500) +} + +const submitFilter = () => { + cy.contains('.filter-modal button', 'Ok').click() + cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist') + cy.wait(2500) +} + +/** The clause is wider than the collapsed line, so it is clipped. */ +const assertClauseOverflowsCollapsedLine = () => { + cy.get('.infoBar-text').then(($text: any) => { + const el = $text[0] as HTMLElement + expect( + el.scrollWidth, + 'the clause is wider than the collapsed line' + ).to.be.greaterThan(el.clientWidth) + expect( + el.ownerDocument.defaultView!.getComputedStyle(el).whiteSpace, + 'collapsed panel does not wrap' + ).to.equal('nowrap') + }) +} + +/** The clause fits the collapsed line, so nothing is hidden. */ +const assertClauseFitsCollapsedLine = () => { + cy.get('.infoBar-text').then(($text: any) => { + const el = $text[0] as HTMLElement + expect(el.scrollWidth, 'the clause fits the collapsed line').to.be.at.most( + el.clientWidth + ) + }) +} + +/** Expanded, the whole clause is rendered and nothing is clipped. */ +const assertClauseFullyVisible = () => { + cy.get('.infoBar-text').then(($text: any) => { + const el = $text[0] as HTMLElement + const style = el.ownerDocument.defaultView!.getComputedStyle(el) + expect(style.whiteSpace, 'expanded panel wraps').to.equal('normal') + expect(el.scrollWidth, 'expanded panel clips nothing').to.be.at.most( + el.clientWidth + ) + }) +} + +/** Expanded, the clause occupies several lines rather than one. */ +const assertClauseSpansSeveralLines = () => { + cy.get('.infoBar-text').then(($text: any) => { + const el = $text[0] as HTMLElement + const lineHeight = + parseFloat( + el.ownerDocument.defaultView!.getComputedStyle(el).lineHeight + ) || 20 + expect( + el.clientHeight / lineHeight, + 'the clause spans several lines' + ).to.be.greaterThan(3) + }) +} diff --git a/client/src/app/editor/editor.component.html b/client/src/app/editor/editor.component.html index 68b43e3..503618b 100644 --- a/client/src/app/editor/editor.component.html +++ b/client/src/app/editor/editor.component.html @@ -369,11 +369,30 @@ } @if (!['', ' '].includes(queryText)) { -