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)) { -
- + FILTER : {{ queryText }} + @if (filterOverflows) { + + }
} diff --git a/client/src/app/editor/editor.component.ts b/client/src/app/editor/editor.component.ts index 285e899..99f9ee9 100644 --- a/client/src/app/editor/editor.component.ts +++ b/client/src/app/editor/editor.component.ts @@ -1,4 +1,5 @@ import { + AfterViewChecked, AfterViewInit, ChangeDetectorRef, Component, @@ -112,7 +113,9 @@ import { ParseResult } from '../models/ParseResult.interface' encapsulation: ViewEncapsulation.None, standalone: false }) -export class EditorComponent implements OnInit, AfterViewInit, OnDestroy { +export class EditorComponent + implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy +{ @ViewChildren('uploadStater') uploadStaterCompList: QueryList = new QueryList() @ViewChildren('queryFilter') @@ -525,6 +528,19 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy { public tableData: Array = [] public queryText = '' public queryTextSaved = '' + + /** + * The applied-filter panel is collapsed to a single line by default. The + * chevron that expands it is only useful when the clause does not fit that + * line, which depends on the rendered width - so it is measured from the DOM + * rather than guessed from the length of the text. + */ + public filterExpanded = false + public filterOverflows = false + + @ViewChild('infoBar') infoBar?: ElementRef + + private filterMeasuredKey = '' public showApprovers = false public pkDups = false public validationDone = 0 @@ -3547,6 +3563,48 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy { } } + ngAfterViewChecked() { + this.scheduleFilterOverflowCheck() + } + + /** + * Measures whether the applied-filter clause fits the collapsed single line, + * and shows or hides the chevron accordingly. The measurement is deferred + * because `filterOverflows` and `filterExpanded` are bound in this view - + * setting them inside the change-detection cycle would raise + * ExpressionChangedAfterItHasBeenChecked. + */ + private scheduleFilterOverflowCheck() { + const el = this.infoBar?.nativeElement + if (!el) { + return + } + + const text = el.querySelector('.infoBar-text') as HTMLElement | null + if (!text) { + return + } + + // re-measure only when the clause, the available width, or the state changes + const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}` + if (key === this.filterMeasuredKey) { + return + } + this.filterMeasuredKey = key + + setTimeout(() => { + // while expanded the clause wraps, so there is nothing to measure + if (this.filterExpanded) { + return + } + this.filterOverflows = text.scrollWidth > text.clientWidth + }) + } + + public toggleFilterPanel() { + this.filterExpanded = !this.filterExpanded + } + ngAfterViewInit() { // Fix ARIA accessibility issues after table initialization setTimeout(() => { diff --git a/client/src/app/viewer/viewer.component.html b/client/src/app/viewer/viewer.component.html index b3f134a..d57ee7c 100644 --- a/client/src/app/viewer/viewer.component.html +++ b/client/src/app/viewer/viewer.component.html @@ -499,10 +499,29 @@ !['', ' '].includes(queryText) && !abortActive ) { -
- + FILTER : {{ queryText }} + @if (filterOverflows) { + + }
} diff --git a/client/src/app/viewer/viewer.component.ts b/client/src/app/viewer/viewer.component.ts index 7cd36d2..b6ae98c 100644 --- a/client/src/app/viewer/viewer.component.ts +++ b/client/src/app/viewer/viewer.component.ts @@ -3,6 +3,8 @@ import { AfterContentInit, ChangeDetectorRef, AfterViewInit, + AfterViewChecked, + ElementRef, OnDestroy, ViewChildren, QueryList, @@ -58,7 +60,7 @@ import { buildColInfoHtml } from '../shared/utils/col-info-html' standalone: false }) export class ViewerComponent - implements AfterContentInit, AfterViewInit, OnDestroy + implements AfterContentInit, AfterViewInit, AfterViewChecked, OnDestroy { @ViewChildren('queryFilter') queryFilterCompList: QueryList = new QueryList() @@ -96,6 +98,19 @@ export class ViewerComponent public libTab!: string public queryText: string = '' public webQueryText: string = '' + + /** + * The applied-filter panel is collapsed to a single line by default. The + * chevron that expands it is only useful when the clause does not fit that + * line, which depends on the rendered width - so it is measured from the DOM + * rather than guessed from the length of the text. + */ + public filterExpanded = false + public filterOverflows = false + + @ViewChild('infoBar') infoBar?: ElementRef + + private filterMeasuredKey = '' public submitLoading!: boolean public queryErr: boolean = false public queryErrMessage!: string @@ -1445,6 +1460,48 @@ export class ViewerComponent } } + ngAfterViewChecked() { + this.scheduleFilterOverflowCheck() + } + + /** + * Measures whether the applied-filter clause fits the collapsed single line, + * and shows or hides the chevron accordingly. The measurement is deferred + * because `filterOverflows` and `filterExpanded` are bound in this view - + * setting them inside the change-detection cycle would raise + * ExpressionChangedAfterItHasBeenChecked. + */ + private scheduleFilterOverflowCheck() { + const el = this.infoBar?.nativeElement + if (!el) { + return + } + + const text = el.querySelector('.infoBar-text') as HTMLElement | null + if (!text) { + return + } + + // re-measure only when the clause, the available width, or the state changes + const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}` + if (key === this.filterMeasuredKey) { + return + } + this.filterMeasuredKey = key + + setTimeout(() => { + // while expanded the clause wraps, so there is nothing to measure + if (this.filterExpanded) { + return + } + this.filterOverflows = text.scrollWidth > text.clientWidth + }) + } + + public toggleFilterPanel() { + this.filterExpanded = !this.filterExpanded + } + ngAfterViewInit() { // Fix ARIA accessibility issues after table initialization setTimeout(() => { diff --git a/client/src/styles.scss b/client/src/styles.scss index 7715a39..3872362 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -120,30 +120,6 @@ app-editor { .infoBar { margin-top: 14px; - background: #495967; - color: white; - text-align: center; - padding: 3px; - font-size: 16px; - - height: 30px; - - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - - span { - width: 80%; - } - - &:hover { - height: unset; - white-space: normal; - - span { - width: unset; - } - } } .pkHeader { @@ -1228,11 +1204,6 @@ app-viewer { .infoBar { margin-top: 10px; - background: #495967; - color: white; - text-align: center; - padding: 3px; - font-size: 16px; } .filterSide { @@ -5448,3 +5419,55 @@ body[cds-theme='dark'] { cursor: pointer; text-decoration: underline; } + +/* The applied-filter panel. Collapsed to a single line by default so that a + long filter does not take over the header; the chevron - rendered only when + the clause does not fit that line - expands the panel to show it in full. */ +.infoBar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #495967; + color: white; + text-align: center; + padding: 3px 8px; + font-size: 16px; + + .infoBar-text { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &.expanded { + align-items: flex-start; + + .infoBar-text { + overflow: visible; + white-space: normal; + text-overflow: clip; + overflow-wrap: anywhere; + } + } + + .infoBar-toggle { + flex: none; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + padding: 0 2px; + line-height: 1; + + clr-icon { + transition: transform 0.15s ease-in; + } + } + + &.expanded .infoBar-toggle clr-icon { + transform: rotate(180deg); + } +}