feat(stage): add Formatted/Unformatted toggle to the staging page
Build / Build-and-ng-test (pull_request) Successful in 4m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 20m17s

- Mirrors the review page's toggle: getstagetable.sas now also webouts
  fmt_stagetable (SAS-formatted), selectFormattedRows() picks between it
  and the raw stagetable, falling back to raw if fmt data is absent
- Adds stage.cy.ts e2e coverage and wires it into both build.yaml and
  release.yaml's cypress --spec lists (previously out of sync with
  each other on viewbox.cy.ts too)
This commit is contained in:
YuryShkoda
2026-08-03 09:28:52 +03:00
parent 42e02cdb05
commit 74c38e1641
12 changed files with 168 additions and 4 deletions
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress videos
if: always()
+1 -1
View File
@@ -143,7 +143,7 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress videos
if: always()
+61
View File
@@ -0,0 +1,61 @@
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('stage tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('stage/DC20221007T122326121_612316_7259')
})
// getstagetable's mock ignores the table_id param entirely, so any id in
// the URL resolves to the same fixture row - no need to submit a real
// table first just to reach this page.
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
// Toggling back reverts to the formatted view.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index - same helper as editor.cy.ts's own.
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
@@ -0,0 +1,20 @@
import { selectFormattedRows } from './select-formatted-rows'
describe('selectFormattedRows', () => {
const rawRows = [{ SOME_NUM: 42, SOME_DATE: 42 }]
const formattedRows = [{ SOME_NUM: '42', SOME_DATE: '11FEB1960' }]
it('returns the formatted rows when showFormatted is true and formatted rows exist', () => {
expect(selectFormattedRows(rawRows, formattedRows, true)).toBe(
formattedRows
)
})
it('returns the raw rows when showFormatted is false, even if formatted rows exist', () => {
expect(selectFormattedRows(rawRows, formattedRows, false)).toBe(rawRows)
})
it('falls back to the raw rows when showFormatted is true but no formatted rows were provided', () => {
expect(selectFormattedRows(rawRows, undefined, true)).toBe(rawRows)
})
})
@@ -0,0 +1,13 @@
/**
* Picks which row set to render - raw or SAS-formatted - the same
* formatted/unformatted toggle already used on the review page, extracted
* so it isn't duplicated inline. Falls back to raw rows if the backend
* hasn't sent a formatted variant (e.g. an older service response).
*/
export function selectFormattedRows(
rawRows: any[],
formattedRows: any[] | undefined,
showFormatted: boolean
): any[] {
return showFormatted && formattedRows ? formattedRows : rawRows
}
+15 -1
View File
@@ -66,7 +66,7 @@
<div class="card-title text-center">Actions</div>
</div>
<div class="mt-20">
<div class="row">
<div class="row stage-actions-row">
<button
class="btn btn-sm btn-outline text-center mr-5i"
(click)="viewerTableScreen()"
@@ -105,6 +105,20 @@
>
<clr-icon shape="download" aria-hidden="true"></clr-icon>
</button>
<clr-toggle-container class="m-0 flex-shrink-0">
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
checked
[(ngModel)]="formattedValues"
(change)="formattingChanged()"
/>
<label class="formatted-values-toggle">{{
formattedValues ? 'Formatted' : 'Unformatted'
}}</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<clr-tooltip>
@if (tableDetails?.['ALLOW_RESTORE'] === 'YES') {
<button
+24
View File
@@ -0,0 +1,24 @@
// Named specifically (not a generic utility) since this component uses
// ViewEncapsulation.None - a generic name would leak globally.
//
// .row here has no CSS establishing it as a flex/grid container (Clarity
// doesn't define one) - the buttons lay out via normal inline flow, since
// .btn is display: inline-flex (an INLINE outer box). flex-wrap/align-items
// would be no-ops on it; vertical-align is what actually governs alignment
// between inline-level boxes of different heights.
.stage-actions-row {
clr-toggle-container {
// clr-form-control (Clarity's own class here) sets display: flex, not
// inline-flex - a block-level box takes the full width of its
// containing block regardless of its own width: auto, which is why it
// wrapped onto its own line. inline-flex gives it an inline outer box
// (sized to content) so it flows with the buttons instead.
display: inline-flex;
width: auto;
margin-top: 0;
// .btn already sets vertical-align: middle - matching it here is what
// actually centers this against the buttons, since neither is a flex
// item of a shared flex container.
vertical-align: middle;
}
}
+20 -1
View File
@@ -16,6 +16,7 @@ import { LicenceService } from '../services/licence.service'
import { globals } from '../_globals'
import { EditorsRestoreServiceResponse } from '../models/sas/editors-restore.model'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { selectFormattedRows } from '../shared/utils/select-formatted-rows'
@Component({
selector: 'app-stage',
@@ -34,6 +35,9 @@ export class StageComponent implements OnInit, AfterViewInit {
public tableDetails: any
public loaded: boolean = false
public revertingChanges: boolean = false
public formattedValues: boolean = true
private rawStageTable: any[] = []
private fmtStageTable: any[] | undefined
public licenceState = this.licenceService.licenceState
public hotTable: HotTableInterface = {
data: [],
@@ -118,6 +122,14 @@ export class StageComponent implements OnInit, AfterViewInit {
}
}
public formattingChanged() {
this.hotTable.data = selectFormattedRows(
this.rawStageTable,
this.fmtStageTable,
this.formattedValues
)
}
public download(id: any) {
let sasjsConfig = this.sasService.getSasjsConfig()
let storage = sasjsConfig.serverUrl
@@ -193,7 +205,14 @@ export class StageComponent implements OnInit, AfterViewInit {
return cellProperties
}
this.hotTable.data = res.stagetable
this.rawStageTable = res.stagetable
this.fmtStageTable = res.fmt_stagetable
this.hotTable.data = selectFormattedRows(
this.rawStageTable,
this.fmtStageTable,
this.formattedValues
)
this.hotTable.colHeaders = colHeaders
this.hotTable.columns = columns
this.hotTable.cells = cells
+2
View File
@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { StageComponent } from './stage.component'
import { HotTableModule } from '@handsontable/angular-wrapper'
import { ClarityModule } from '@clr/angular'
@@ -11,6 +12,7 @@ const routes: Routes = [{ path: ':tableId', component: StageComponent }]
declarations: [StageComponent],
imports: [
CommonModule,
FormsModule,
ClarityModule,
RouterModule.forChild(routes),
HotTableModule
@@ -4,6 +4,10 @@ _webout=`{"SYSDATE" : "26SEP22"
[
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
]
, "fmt_stagetable":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
]
,"_DEBUG" : ""
,"_METAUSER": "sasdemo@SAS"
,"_METAPERSON": "sasdemo"
@@ -4,6 +4,10 @@ _webout = `{"SYSDATE" : "26SEP22"
[
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
]
, "fmt_stagetable":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
]
,"_DEBUG" : ""
,"_METAUSER": "sasdemo@SAS"
,"_METAPERSON": "sasdemo"
@@ -29,6 +29,9 @@ run;
%webout(OPEN)
%webout(OBJ,stagetable,missing=STRING)
/* same table again with SAS formats applied, for the frontend's
Formatted/Unformatted toggle - same pattern as postdata.sas */
%webout(OBJ,stagetable,dslabel=fmt_stagetable,fmt=Y,missing=STRING)
%webout(CLOSE)