feat(deploy): pick and validate the DC location from the deploy screens
The deploy screens asked for a physical folder the deployment user can write to, as a single text box, and found out whether it worked only by running the whole deployment. They now get a picker and a Validate button, backed by the makedata actions that already answer those two questions. A new app-dc-location component renders the path as an editable input - the picker is a convenience over it, never a replacement, because a folder the deployment user cannot open is not listed and a customer may want a path that does not exist yet. Browse lists one level at a time (ACTION=DIRLIST, DEPTH 1, MAXOBS 500), with Up, a "Use this folder" for where you are standing, and a per row Use. Validate calls ACTION=WRITECHECK and reports the verdict inline - writeable, or the reason it is not - instead of leaving it to the deployment to fail. Both screens use it: the Viya setup screen (which runs makedata through the adapter) and the Sasjs configurator (which opens makedata as a URL). The mkdata calls now send ACTION=MAKEDATA, since the service requires it. Details worth knowing: - every value read from the service is trimmed: SAS has no zero length character value, so a blank field arrives as a single space and reads as a value to any `||` or `@if`. - the listing's level 0 row is the folder being listed, not a folder to drill into - except when the parent is blank, where the level 0 rows are the platform roots. - failures are shown inline rather than as abort modals: the two requests pass suppressErrorAbortModal/suppressSuccessAbortModal, because a refused path is an expected answer here, not a crash. - parentOf handles a drive root (C:\) and a unix root without inventing an empty path. Frontend tests: 14 new specs (38 in the deploy folder, all passing), including a template spec for the browser panel and one for the verdict line, both mutation checked.
This commit is contained in:
1 parent
c5448c7d5b
commit
7d2cc846ff
12 files changed
+849
-22
No files matched your search
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'
|
||||
import { DeployComponent } from './deploy.component'
|
||||
import { AutomaticComponent } from './sections/automatic/automatic.component'
|
||||
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
|
||||
import { DcLocationComponent } from './sections/dc-location/dc-location.component'
|
||||
import { ClarityModule } from '@clr/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { DeployRoutingModule } from './deploy-routing.module'
|
||||
@@ -11,7 +12,8 @@ import { DeployRoutingModule } from './deploy-routing.module'
|
||||
declarations: [
|
||||
DeployComponent,
|
||||
AutomaticComponent,
|
||||
SasjsConfiguratorComponent
|
||||
SasjsConfiguratorComponent,
|
||||
DcLocationComponent
|
||||
],
|
||||
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
||||
})
|
||||
|
||||
@@ -89,9 +89,7 @@
|
||||
history here.
|
||||
</p>
|
||||
<div class="clr-control-container dc-loc-input-wrapper">
|
||||
<div class="clr-input-wrapper small-mt">
|
||||
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
|
||||
</div>
|
||||
<app-dc-location [(path)]="dcPath" inputId="dcloc"></app-dc-location>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ describe('AutomaticComponent (Viya setup)', () => {
|
||||
|
||||
expect(requests[0][0]).toBe('services/admin/makedata')
|
||||
expect(requests[0][1]).toEqual({
|
||||
fromjs: [{ ADMIN: 'viyagroup18', DCPATH: '/export/dc' }]
|
||||
fromjs: [
|
||||
{ ACTION: 'MAKEDATA', ADMIN: 'viyagroup18', DCPATH: '/export/dc' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +113,7 @@ describe('AutomaticComponent (Viya setup)', () => {
|
||||
expect(opened[0]).toContain('_contextname=Compute%20Reusable')
|
||||
expect(opened[0]).toContain('admin=viyagroup18')
|
||||
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc')
|
||||
expect(opened[0]).toContain('action=MAKEDATA')
|
||||
expect(opened[0]).toContain('_debug=131')
|
||||
})
|
||||
|
||||
|
||||
@@ -425,6 +425,7 @@ export class AutomaticComponent implements OnInit {
|
||||
let data = {
|
||||
fromjs: [
|
||||
{
|
||||
ACTION: 'MAKEDATA',
|
||||
ADMIN: this.selectedAdminGroup,
|
||||
DCPATH: this.dcPath
|
||||
}
|
||||
@@ -522,6 +523,8 @@ export class AutomaticComponent implements OnInit {
|
||||
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
|
||||
let admin = `&admin=${encodeURIComponent(params.admin)}`
|
||||
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
|
||||
// makedata requires ACTION; MAKEDATA is the seeding run
|
||||
let action = '&action=MAKEDATA'
|
||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
||||
// deployment and the full log is needed to diagnose any issues.
|
||||
let debug = '&_debug=131'
|
||||
@@ -535,6 +538,7 @@ export class AutomaticComponent implements OnInit {
|
||||
contextname +
|
||||
admin +
|
||||
dcPath +
|
||||
action +
|
||||
debug
|
||||
|
||||
window.open(programUrl)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<div class="dc-location">
|
||||
<div class="clr-input-wrapper dc-location-input">
|
||||
<input
|
||||
clrInput
|
||||
[id]="inputId"
|
||||
[name]="inputId"
|
||||
[ngModel]="path"
|
||||
(ngModelChange)="onPathChange($event)"
|
||||
placeholder="Type a folder, or browse below"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="DCBrowse"
|
||||
class="btn btn-sm btn-outline dc-location-button"
|
||||
(click)="toggleBrowse()"
|
||||
>
|
||||
{{ browsing ? 'Hide' : 'Browse' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="DCValidate"
|
||||
class="btn btn-sm btn-outline dc-location-button"
|
||||
[disabled]="validating"
|
||||
(click)="validate()"
|
||||
>
|
||||
@if (validating) {
|
||||
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||
}
|
||||
@if (!validating) {
|
||||
Validate
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (checkResult) {
|
||||
<p
|
||||
id="DCValidateResult"
|
||||
class="dc-location-result"
|
||||
[class.dc-location-ok]="checkResult.writeable"
|
||||
[class.dc-location-bad]="!checkResult.writeable"
|
||||
>
|
||||
@if (checkResult.writeable) {
|
||||
<clr-icon shape="check"></clr-icon>
|
||||
<span>{{ checkResult.path }} is writeable</span>
|
||||
}
|
||||
@if (!checkResult.writeable) {
|
||||
<clr-icon shape="exclamation-triangle"></clr-icon>
|
||||
<span>{{ checkResult.msg }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (browsing) {
|
||||
<div class="dc-browser" id="DCBrowser">
|
||||
<div class="dc-browser-bar">
|
||||
<button
|
||||
type="button"
|
||||
id="DCUp"
|
||||
class="btn btn-sm btn-link dc-browser-up"
|
||||
[disabled]="listingIsRoot"
|
||||
(click)="up()"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<span class="dc-browser-path">
|
||||
@if (listingIsRoot) {
|
||||
Select a drive or root folder
|
||||
}
|
||||
@if (!listingIsRoot) {
|
||||
{{ listingParent }}
|
||||
}
|
||||
</span>
|
||||
@if (!listingIsRoot) {
|
||||
<button
|
||||
type="button"
|
||||
id="DCUseCurrent"
|
||||
class="btn btn-sm btn-outline dc-browser-use"
|
||||
(click)="useCurrent()"
|
||||
>
|
||||
Use this folder
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="dc-browser-loading">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && !!listingError) {
|
||||
<p class="dc-location-bad" id="DCBrowserError">
|
||||
<clr-icon shape="exclamation-triangle"></clr-icon>
|
||||
<span>{{ listingError }}</span>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!loading && !listingError) {
|
||||
<ul class="dc-folder-list">
|
||||
@for (folder of folders; track folder.filepath) {
|
||||
<li class="dc-folder">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link dc-folder-open"
|
||||
(click)="enter(folder)"
|
||||
>
|
||||
<clr-icon shape="folder"></clr-icon>
|
||||
<span>{{ folder.filename }}</span>
|
||||
</button>
|
||||
@if (!!folder.info) {
|
||||
<span class="dc-folder-info">{{ folder.info }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link dc-folder-use"
|
||||
(click)="choose(folder)"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@if (!folders.length) {
|
||||
<li class="dc-folder dc-folder-empty" id="DCNoFolders">
|
||||
No folders here
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (truncated) {
|
||||
<p class="dc-browser-truncated" id="DCTruncated">
|
||||
Only the first folders are shown - type the path if it is not listed.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,123 @@
|
||||
.dc-location-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-location-input input {
|
||||
flex: 1 1 22rem;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.dc-location-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dc-location-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dc-location-ok {
|
||||
color: #306b00;
|
||||
}
|
||||
|
||||
.dc-location-bad {
|
||||
color: #c21d00;
|
||||
}
|
||||
|
||||
body[cds-theme='dark'] .dc-location-ok {
|
||||
color: #7fd14a;
|
||||
}
|
||||
|
||||
body[cds-theme='dark'] .dc-location-bad {
|
||||
color: #ff8a73;
|
||||
}
|
||||
|
||||
.dc-browser {
|
||||
margin-top: 8px;
|
||||
border: 1px solid light-dark(#ccc, #4a4a4a);
|
||||
border-radius: 4px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dc-browser-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid light-dark(#e8e8e8, #4a4a4a);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: light-dark(#fafafa, #1b1b1b);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dc-browser-path {
|
||||
flex: 1 1 auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dc-browser-up,
|
||||
.dc-browser-use {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dc-browser-loading {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dc-folder-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dc-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.dc-folder:hover {
|
||||
background: light-dark(#f0f0f0, #2a2a2a);
|
||||
}
|
||||
|
||||
.dc-folder-open {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dc-folder-info {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.dc-folder-use {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dc-folder-empty {
|
||||
opacity: 0.75;
|
||||
font-size: 0.7rem;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dc-browser-truncated {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SasService } from 'src/app/services/sas.service'
|
||||
import { DcLocationComponent } from './dc-location.component'
|
||||
|
||||
describe('DcLocationComponent', () => {
|
||||
let component: DcLocationComponent
|
||||
let fixture: ComponentFixture<DcLocationComponent>
|
||||
|
||||
let requests: { url: string; data: any }[]
|
||||
let response: any
|
||||
let rejection: any
|
||||
|
||||
const sasService = {
|
||||
request: (url: string, data: any) => {
|
||||
requests.push({ url, data })
|
||||
if (rejection) return Promise.reject(rejection)
|
||||
return Promise.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
const dirResponse = (parent: string, rows: any[], extra: any = {}) => ({
|
||||
adapterResponse: {
|
||||
dirinfo: [
|
||||
{
|
||||
PARENT: parent,
|
||||
SEP: '/',
|
||||
TRUNCATED: 'N',
|
||||
PLATFORM: 'UNIX',
|
||||
DEPTH: 1,
|
||||
...extra
|
||||
}
|
||||
],
|
||||
dirlist: rows
|
||||
}
|
||||
})
|
||||
|
||||
const folder = (filepath: string, level: number, filename?: string) => ({
|
||||
FILEPATH: filepath,
|
||||
FILENAME: filename || filepath.split('/').pop(),
|
||||
FILE_OR_FOLDER: 'folder',
|
||||
LEVEL: level,
|
||||
MSG: ' '
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
requests = []
|
||||
response = undefined
|
||||
rejection = undefined
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [DcLocationComponent],
|
||||
imports: [FormsModule],
|
||||
providers: [{ provide: SasService, useValue: sasService }],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(DcLocationComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule()
|
||||
})
|
||||
|
||||
it('treats the SAS blank padding as blank', () => {
|
||||
expect(component.trimSas(' ')).toBe('')
|
||||
expect(component.trimSas(undefined)).toBe('')
|
||||
expect(component.trimSas(' /tmp ')).toBe('/tmp')
|
||||
|
||||
const rows = component.parseFolders(
|
||||
[{ FILEPATH: ' ', FILENAME: ' ', FILE_OR_FOLDER: 'folder', LEVEL: 1 }],
|
||||
false
|
||||
)
|
||||
expect(rows.length).toBe(0)
|
||||
})
|
||||
|
||||
it('browses from the typed path, trimmed', async () => {
|
||||
component.path = ' /tmp '
|
||||
component.toggleBrowse()
|
||||
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].url).toBe('admin/makedata')
|
||||
expect(requests[0].data.fromjs[0].ACTION).toBe('DIRLIST')
|
||||
expect(requests[0].data.fromjs[0].PARENT).toBe('/tmp')
|
||||
})
|
||||
|
||||
it('shows the platform roots when the parent is blank', async () => {
|
||||
response = dirResponse(' ', [folder('/', 0, '/')])
|
||||
component.path = ''
|
||||
component.toggleBrowse()
|
||||
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.listingIsRoot).toBeTrue()
|
||||
expect(component.folders.length).toBe(1)
|
||||
expect(component.folders[0].filepath).toBe('/')
|
||||
})
|
||||
|
||||
it('does not offer the parent itself as a folder to drill into', async () => {
|
||||
response = dirResponse('/tmp', [
|
||||
folder('/tmp', 0, 'tmp'),
|
||||
folder('/tmp/aa', 1, 'aa')
|
||||
])
|
||||
component.path = '/tmp'
|
||||
component.toggleBrowse()
|
||||
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.folders.length).toBe(1)
|
||||
expect(component.folders[0].filepath).toBe('/tmp/aa')
|
||||
expect(component.listingIsRoot).toBeFalse()
|
||||
})
|
||||
|
||||
it('drills into a folder', async () => {
|
||||
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||
component.path = '/tmp'
|
||||
component.toggleBrowse()
|
||||
await fixture.whenStable()
|
||||
|
||||
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
|
||||
component.enter(component.folders[0])
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(requests.length).toBe(2)
|
||||
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp/aa')
|
||||
})
|
||||
|
||||
it('takes a folder as the answer and closes the browser', async () => {
|
||||
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||
const emitted: string[] = []
|
||||
component.pathChange.subscribe((value: string) => emitted.push(value))
|
||||
|
||||
component.path = '/tmp'
|
||||
component.toggleBrowse()
|
||||
await fixture.whenStable()
|
||||
|
||||
component.choose(component.folders[0])
|
||||
|
||||
expect(emitted).toEqual(['/tmp/aa'])
|
||||
expect(component.browsing).toBeFalse()
|
||||
})
|
||||
|
||||
it('goes up one level', async () => {
|
||||
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
|
||||
component.path = '/tmp/aa'
|
||||
component.toggleBrowse()
|
||||
await fixture.whenStable()
|
||||
|
||||
component.up()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp')
|
||||
})
|
||||
|
||||
describe('parentOf', () => {
|
||||
it('walks unix paths up to a root that is not empty', () => {
|
||||
expect(component.parentOf('/tmp/a', '/')).toBe('/tmp')
|
||||
expect(component.parentOf('/tmp', '/')).toBe('/')
|
||||
expect(component.parentOf('/', '/')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a drive root, and goes to the roots from it', () => {
|
||||
expect(component.parentOf('C:\\Users', '\\')).toBe('C:\\')
|
||||
expect(component.parentOf('C:\\', '\\')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a writeable folder', async () => {
|
||||
response = {
|
||||
adapterResponse: {
|
||||
checkwrite: [
|
||||
{
|
||||
PATH: '/tmp ',
|
||||
PATH_EXISTS: 'Y',
|
||||
IS_FOLDER: 'Y',
|
||||
WRITEABLE: 'Y',
|
||||
MSG: ' '
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
component.path = '/tmp'
|
||||
component.validate()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(requests[0].data.fromjs[0].ACTION).toBe('WRITECHECK')
|
||||
expect(requests[0].data.fromjs[0].PATH).toBe('/tmp')
|
||||
expect(component.checkResult?.writeable).toBeTrue()
|
||||
expect(component.checkResult?.path).toBe('/tmp')
|
||||
})
|
||||
|
||||
it('explains a refusal instead of showing a blank message', async () => {
|
||||
response = {
|
||||
adapterResponse: {
|
||||
checkwrite: [
|
||||
{
|
||||
PATH: '/root/private ',
|
||||
PATH_EXISTS: 'Y',
|
||||
IS_FOLDER: 'Y',
|
||||
WRITEABLE: 'N',
|
||||
MSG: ' '
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
component.path = '/root/private'
|
||||
component.validate()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.checkResult?.writeable).toBeFalse()
|
||||
expect((component.checkResult?.msg || '').trim()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the service error rather than a generic failure', async () => {
|
||||
rejection = { adapterResponse: { error: 'Invalid path' } }
|
||||
|
||||
component.path = '%nrstr(&path)'
|
||||
component.validate()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.checkResult?.writeable).toBeFalse()
|
||||
expect(component.checkResult?.msg).toBe('Invalid path')
|
||||
})
|
||||
|
||||
it('does not call the service when there is nothing to validate', async () => {
|
||||
component.path = ' '
|
||||
component.validate()
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
expect(component.checkResult?.msg).toBe('Type or pick a folder first')
|
||||
})
|
||||
|
||||
it('renders the browser only while browsing, and the verdict after a check', async () => {
|
||||
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||
component.path = '/tmp'
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('#DCBrowser')).toBeNull()
|
||||
|
||||
component.toggleBrowse()
|
||||
await fixture.whenStable()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('#DCBrowser')).toBeTruthy()
|
||||
|
||||
response = {
|
||||
adapterResponse: {
|
||||
checkwrite: [
|
||||
{
|
||||
PATH: '/tmp ',
|
||||
PATH_EXISTS: 'Y',
|
||||
IS_FOLDER: 'Y',
|
||||
WRITEABLE: 'Y',
|
||||
MSG: ' '
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
component.validate()
|
||||
await fixture.whenStable()
|
||||
fixture.detectChanges()
|
||||
|
||||
const verdict = fixture.nativeElement.querySelector('#DCValidateResult')
|
||||
expect(verdict).toBeTruthy()
|
||||
expect(verdict.textContent).toContain('is writeable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output
|
||||
} from '@angular/core'
|
||||
import {
|
||||
DcFolderEntry,
|
||||
DcFolderRow,
|
||||
DcWriteCheckResult,
|
||||
DcWriteCheckRow
|
||||
} from 'src/app/models/sas/dc-location.model'
|
||||
import { SasService } from 'src/app/services/sas.service'
|
||||
|
||||
/**
|
||||
* Folder picker for the deploy screens.
|
||||
*
|
||||
* The deploy screen asks where Data Controller should keep its control
|
||||
* library, and that has to be answered before the control library exists - so
|
||||
* the question is served by `makedata` itself (`ACTION=DIRLIST` to browse,
|
||||
* `ACTION=WRITECHECK` to prove the folder is writeable).
|
||||
*
|
||||
* The typed value is always editable: browsing is a convenience over it, never
|
||||
* a replacement, because the picker cannot see a folder the deployment user has
|
||||
* no permission on and a customer may want to type a path that does not exist
|
||||
* yet. The parent is listed one level at a time, so a folder holding tens of
|
||||
* thousands of subfolders does not arrive in one response.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-dc-location',
|
||||
templateUrl: './dc-location.component.html',
|
||||
styleUrls: ['./dc-location.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class DcLocationComponent implements OnDestroy {
|
||||
@Input() path = ''
|
||||
@Input() inputId = 'dcloc'
|
||||
@Output() pathChange = new EventEmitter<string>()
|
||||
|
||||
/** panel state */
|
||||
public browsing = false
|
||||
public loading = false
|
||||
public folders: DcFolderEntry[] = []
|
||||
public listingParent = ''
|
||||
public listingIsRoot = true
|
||||
public truncated = false
|
||||
public listingError = ''
|
||||
|
||||
/** validate state */
|
||||
public validating = false
|
||||
public checkResult: DcWriteCheckResult | null = null
|
||||
|
||||
private separator = '/'
|
||||
|
||||
constructor(private sasService: SasService) {}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.browsing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* SAS has no zero length character value, so a blank field arrives as a single
|
||||
* space. Everything read from the service goes through here before it is
|
||||
* tested, or `' '` reads as a value.
|
||||
*/
|
||||
public trimSas(value: any): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
public onPathChange(value: string) {
|
||||
this.path = value
|
||||
this.checkResult = null
|
||||
this.pathChange.emit(value)
|
||||
}
|
||||
|
||||
public toggleBrowse() {
|
||||
this.browsing = !this.browsing
|
||||
if (this.browsing) this.list(this.trimSas(this.path))
|
||||
}
|
||||
|
||||
/** Lists one level below `parent`. A blank parent is the platform roots. */
|
||||
public list(parent: string) {
|
||||
this.loading = true
|
||||
this.listingError = ''
|
||||
|
||||
const data = {
|
||||
fromjs: [{ ACTION: 'DIRLIST', PARENT: parent, DEPTH: 1, MAXOBS: 500 }]
|
||||
}
|
||||
|
||||
this.sasService
|
||||
.request('admin/makedata', data, undefined, {
|
||||
suppressErrorAbortModal: true,
|
||||
suppressSuccessAbortModal: true
|
||||
})
|
||||
.then((res: any) => {
|
||||
const response = res?.adapterResponse || {}
|
||||
const info = (response.dirinfo || [])[0] || {}
|
||||
const listedParent = this.trimSas(info.PARENT)
|
||||
|
||||
this.separator = this.trimSas(info.SEP) || '/'
|
||||
this.truncated = this.trimSas(info.TRUNCATED) === 'Y'
|
||||
this.listingParent = listedParent
|
||||
this.listingIsRoot = listedParent === ''
|
||||
this.folders = this.parseFolders(response.dirlist, this.listingIsRoot)
|
||||
this.loading = false
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.folders = []
|
||||
this.loading = false
|
||||
this.listingError = this.errorText(err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps folders only. The row at LEVEL 0 is the parent being listed, which is
|
||||
* not something to drill into - except when the parent is blank, where the
|
||||
* level 0 rows *are* the platform roots.
|
||||
*/
|
||||
public parseFolders(rows: any, rootListing: boolean): DcFolderEntry[] {
|
||||
const list: DcFolderRow[] = Array.isArray(rows) ? rows : []
|
||||
|
||||
return list
|
||||
.filter((row) => this.trimSas(row?.FILE_OR_FOLDER) === 'folder')
|
||||
.filter((row) => rootListing || Number(row?.LEVEL) > 0)
|
||||
.map((row) => ({
|
||||
filepath: this.trimSas(row?.FILEPATH),
|
||||
filename: this.trimSas(row?.FILENAME),
|
||||
level: Number(row?.LEVEL) || 0,
|
||||
info: this.trimSas(row?.MSG)
|
||||
}))
|
||||
.filter((folder) => !!folder.filepath)
|
||||
}
|
||||
|
||||
/** Drills into a folder. */
|
||||
public enter(folder: DcFolderEntry) {
|
||||
this.list(folder.filepath)
|
||||
}
|
||||
|
||||
/** Takes a folder as the answer. */
|
||||
public choose(folder: DcFolderEntry) {
|
||||
this.applyPath(folder.filepath)
|
||||
}
|
||||
|
||||
/** Takes the folder being listed - the one the user is standing in. */
|
||||
public useCurrent() {
|
||||
this.applyPath(this.listingParent)
|
||||
}
|
||||
|
||||
/** Goes up one level; from the top of a drive it goes back to the roots. */
|
||||
public up() {
|
||||
this.list(this.parentOf(this.listingParent, this.separator))
|
||||
}
|
||||
|
||||
/** The parent of a folder, without asking the server for it. */
|
||||
public parentOf(folder: string, sep: string): string {
|
||||
let value = this.trimSas(folder)
|
||||
|
||||
while (value.length > 1 && value.endsWith(sep)) {
|
||||
/* a drive root keeps its separator: C:\ is not C: */
|
||||
if (value.length === 3 && value.charAt(1) === ':') break
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
|
||||
const index = value.lastIndexOf(sep)
|
||||
if (index < 0) return ''
|
||||
if (index === 0) return value === sep ? '' : sep
|
||||
|
||||
/* C:\Users -> C:\ , and C:\ -> the roots */
|
||||
if (index === 2 && value.charAt(1) === ':') {
|
||||
return value.length === 3 ? '' : value.slice(0, 3)
|
||||
}
|
||||
|
||||
return value.slice(0, index)
|
||||
}
|
||||
|
||||
private applyPath(value: string) {
|
||||
this.browsing = false
|
||||
this.listingError = ''
|
||||
this.onPathChange(value)
|
||||
}
|
||||
|
||||
/** Asks the service whether this folder exists, is a folder, and is writeable. */
|
||||
public validate() {
|
||||
const target = this.trimSas(this.path)
|
||||
this.checkResult = null
|
||||
|
||||
if (!target) {
|
||||
this.checkResult = {
|
||||
path: '',
|
||||
exists: false,
|
||||
isFolder: false,
|
||||
writeable: false,
|
||||
msg: 'Type or pick a folder first'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.validating = true
|
||||
|
||||
this.sasService
|
||||
.request(
|
||||
'admin/makedata',
|
||||
{ fromjs: [{ ACTION: 'WRITECHECK', PATH: target }] },
|
||||
undefined,
|
||||
{ suppressErrorAbortModal: true, suppressSuccessAbortModal: true }
|
||||
)
|
||||
.then((res: any) => {
|
||||
const response = res?.adapterResponse || {}
|
||||
const row: DcWriteCheckRow = (response.checkwrite || [])[0] || {}
|
||||
this.checkResult = this.parseWriteCheck(row, target)
|
||||
this.validating = false
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.checkResult = {
|
||||
path: target,
|
||||
exists: false,
|
||||
isFolder: false,
|
||||
writeable: false,
|
||||
msg: this.errorText(err)
|
||||
}
|
||||
this.validating = false
|
||||
})
|
||||
}
|
||||
|
||||
public parseWriteCheck(
|
||||
row: DcWriteCheckRow,
|
||||
asked: string
|
||||
): DcWriteCheckResult {
|
||||
const exists = this.trimSas(row?.PATH_EXISTS) === 'Y'
|
||||
const isFolder = this.trimSas(row?.IS_FOLDER) === 'Y'
|
||||
const writeable = this.trimSas(row?.WRITEABLE) === 'Y'
|
||||
const path = this.trimSas(row?.PATH) || asked
|
||||
let msg = this.trimSas(row?.MSG)
|
||||
|
||||
if (!msg && !writeable) {
|
||||
msg = exists
|
||||
? `${path} cannot be written to by the deployment user`
|
||||
: `${path} does not exist`
|
||||
}
|
||||
|
||||
return { path, exists, isFolder, writeable, msg }
|
||||
}
|
||||
|
||||
private errorText(err: any): string {
|
||||
const fromAdapter = err?.adapterResponse?.error
|
||||
if (fromAdapter) return String(fromAdapter)
|
||||
if (err?.message) return String(err.message)
|
||||
return 'The request to makedata failed'
|
||||
}
|
||||
}
|
||||
+4
-16
@@ -20,22 +20,10 @@
|
||||
|
||||
<label class="mt-20 clr-control-label">DC Staging Directory</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input
|
||||
class="clr-input"
|
||||
placeholder="Type in directory"
|
||||
[(ngModel)]="dcDirectory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <button
|
||||
(click)="directoryBrowse.click()"
|
||||
class="mt-15 text-center"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
||||
<app-dc-location
|
||||
[(path)]="dcDirectory"
|
||||
inputId="dcDirectory"
|
||||
></app-dc-location>
|
||||
</div>
|
||||
|
||||
<p class="m-0 mt-10">
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('SasjsConfiguratorComponent (SAS9 / SASjs setup)', () => {
|
||||
)
|
||||
expect(opened[0]).toContain('admin=DCADMINS')
|
||||
expect(opened[0]).toContain('path=%2Ftmp%2FDataController')
|
||||
expect(opened[0]).toContain('action=MAKEDATA')
|
||||
expect(opened[0]).toContain('_debug=131')
|
||||
})
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export class SasjsConfiguratorComponent implements OnInit {
|
||||
|
||||
const url = `${
|
||||
serverUrl ? serverUrl : ''
|
||||
}${executor}?_program=${root}/services/admin/makedata&admin=${
|
||||
}${executor}?_program=${root}/services/admin/makedata&action=MAKEDATA&admin=${
|
||||
this.dcAdminGroup
|
||||
}&path=${dcDirectory}${_debug}`
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shapes returned by the makedata service's filesystem actions.
|
||||
*
|
||||
* The service answers two questions the deploy screens ask before the control
|
||||
* library exists: what is below this folder (`ACTION=DIRLIST`) and can we write
|
||||
* to this folder (`ACTION=WRITECHECK`). Fields arrive with SAS blank padding,
|
||||
* so every consumer trims before it tests.
|
||||
*/
|
||||
export interface DcDirInfo {
|
||||
PARENT: string
|
||||
SEP: string
|
||||
TRUNCATED: string
|
||||
PLATFORM: string
|
||||
DEPTH: number
|
||||
}
|
||||
|
||||
export interface DcFolderRow {
|
||||
FILEPATH: string
|
||||
FILENAME: string
|
||||
FILE_OR_FOLDER: string
|
||||
LEVEL: number
|
||||
MSG: string
|
||||
}
|
||||
|
||||
export interface DcFolderEntry {
|
||||
filepath: string
|
||||
filename: string
|
||||
level: number
|
||||
info: string
|
||||
}
|
||||
|
||||
export interface DcWriteCheckRow {
|
||||
PATH: string
|
||||
PATH_EXISTS: string
|
||||
IS_FOLDER: string
|
||||
WRITEABLE: string
|
||||
MSG: string
|
||||
}
|
||||
|
||||
export interface DcWriteCheckResult {
|
||||
path: string
|
||||
exists: boolean
|
||||
isFolder: boolean
|
||||
writeable: boolean
|
||||
msg: string
|
||||
}
|
||||
Reference in new issue
Block a user