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.
253 lines
7.4 KiB
TypeScript
253 lines
7.4 KiB
TypeScript
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'
|
|
}
|
|
}
|