feat(deploy): pick and validate the DC location from the deploy screens
Build / Build-and-ng-test (pull_request) Successful in 6m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m8s
Build / Build-and-test-development (pull_request) Successful in 35m11s

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:
dc committed 2026-11-02 22:32:27 +00:00
1 parent c5448c7d5b
commit 7d2cc846ff
12 files changed
+849 -22

No files matched your search

+3 -1
View File
@@ -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'
}
}
@@ -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
}