UNPKG

@nakedobjects/cicero

Version:

Single Page Application client for a Naked Objects application.

1,166 lines (1,153 loc) 108 kB
import * as i0 from '@angular/core'; import { Injectable, ViewChild, Component, NgModule } from '@angular/core'; import * as i1 from '@nakedobjects/services'; import { InteractionMode, ErrorWrapper, ErrorCategory, ClientErrorCode, CollectionViewState, fixedDateFormat, supportedDateFormats, ViewType } from '@nakedobjects/services'; import reduce from 'lodash-es/reduce'; import filter from 'lodash-es/filter'; import fromPairs from 'lodash-es/fromPairs'; import last from 'lodash-es/last'; import map from 'lodash-es/map'; import forEach from 'lodash-es/forEach'; import * as Ro from '@nakedobjects/restful-objects'; import each from 'lodash-es/each'; import every from 'lodash-es/every'; import findIndex from 'lodash-es/findIndex'; import keys from 'lodash-es/keys'; import some from 'lodash-es/some'; import mapValues from 'lodash-es/mapValues'; import mapKeys from 'lodash-es/mapKeys'; import { DateTime } from 'luxon'; import zipObject from 'lodash-es/zipObject'; import * as i2 from '@angular/common'; import invert from 'lodash-es/invert'; import * as i5 from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule } from '@angular/router'; function safeUnsubscribe(sub) { if (sub) { sub.unsubscribe(); } } function isFocusable(nativeElement) { return !!(nativeElement && nativeElement instanceof Object && 'focus' in nativeElement); } function safeFocus(nativeElement) { if (isFocusable(nativeElement)) { nativeElement.focus(); } } function focus(element) { setTimeout(() => safeFocus(element?.nativeElement)); return true; } function hasMessage(obj) { return typeof obj === 'object' && obj !== null && 'message' in obj && typeof obj.message == 'string'; } function messageFrom(e) { return hasMessage(e) ? e.message : 'unknown error'; } const mandatory = 'Mandatory'; const optional = 'Optional'; const choices = 'Choices'; const tooLong = 'Too long'; const notANumber = 'Not a number'; const noPatternMatch = 'Invalid entry'; const outOfRange = (_, min, max, filter) => { const minVal = filter ? filter.filter(min) : min; const maxVal = filter ? filter.filter(max) : max; return `Value is outside the range ${minVal || 'unlimited'} to ${maxVal || 'unlimited'}`; }; const welcomeMessage = 'Welcome to Cicero. Type \'help\' and the Enter key for more information.'; const basicHelp = 'Cicero is a user interface purpose-designed to work with an audio screen-reader.\n' + 'The display has only two fields: a read-only output field, and a single input field.\n' + 'The input field always has the focus.\n' + 'Commands are typed into the input field followed by the Enter key.\n' + 'When the output field updates (either instantaneously or after the server has responded)\n' + 'its contents are read out automatically, so \n' + 'the user never has to navigate around the screen.\n' + 'Commands, such as \'action\', \'field\' and \'save\', may be typed in full\n' + 'or abbreviated to the first two or more characters.\n' + 'Commands are not case sensitive.\n' + 'Some commands take one or more arguments.\n' + 'There must be a space between the command word and the first argument,\n' + 'and a comma between arguments.\n' + 'Arguments may contain spaces if needed.\n' + 'The commands available to the user vary according to the context.\n' + 'The command \'help ?\' (note that there is a space between help and \'?\')\n' + 'will list the commands available to the user in the current context.\n' + '‘help’ followed by another command word (in full or abbreviated) will give more details about that command.\n' + 'Some commands will change the context, for example using the Go command to navigate to an associated object, \n' + 'in which case the new context will be read out.\n' + 'Other commands - help being an example - do not change the context, but will read out information to the user.\n' + 'If the user needs a reminder of the current context, the \'Where\' command will read the context out again.\n' + 'Hitting Enter on the empty input field has the same effect.\n' + 'When the user enters a command and the output has been updated, the input field will be cleared, \n' + 'ready for the next command. The user may recall the previous command by hitting the up-arrow key.\n' + 'The user might then edit or extend that previous command and hit Enter to run it again.\n' + 'For advanced users: commands may be chained using a semi-colon between them,\n' + 'however commands that do, or might, result in data updates cannot be chained.'; const actionCommand = 'action'; const actionHelp = 'Open the dialog for action from a menu, or from object actions.\n' + 'A dialog is always opened for an action, even if it has no fields (parameters):\n' + 'This is a safety mechanism, allowing the user to confirm that the action is the one intended.\n' + 'Once the dialog fields have been completed, using the Enter command,\n' + 'the action may then be invoked with the OK command.\n' + 'The action command takes two optional arguments.\n' + 'The first is the name, or partial name, of the action.\n' + 'If the partial name matches more than one action, a list of matches is returned but none opened.\n' + 'If no argument is provided, a full list of available action names is returned.\n' + 'The partial name may have more than one clause, separated by spaces.\n' + 'these may match either parts of the action name or the sub-menu name if one exists.\n' + 'If the action name matches a single action, then a question-mark may be added as a second\n' + 'parameter, which will generate a more detailed description of the Action.'; const backCommand = 'back'; const backHelp = 'Move back to the previous context.'; const cancelCommand = 'cancel'; const cancelHelp = 'Leave the current activity (action dialog, or object edit), incomplete.'; const clipboardCommand = 'clipboard'; const clipboardCopy = 'copy'; const clipboardShow = 'show'; const clipboardGo = 'go'; const clipboardDiscard = 'discard'; const clipboardHelp = 'The clipboard command is used for temporarily\n' + 'holding a reference to an object, so that it may be used later\n' + 'to enter into a field.\n' + 'Clipboard requires one argument, which may take one of four values:\n' + 'copy, show, go, or discard\n' + 'each of which may be abbreviated down to one character.\n' + 'Copy copies a reference to the object being viewed into the clipboard,\n' + 'overwriting any existing reference.\n' + 'Show displays the content of the clipboard without using it.\n' + 'Go takes you directly to the object held in the clipboard.\n' + 'Discard removes any existing reference from the clipboard.\n' + 'The reference held in the clipboard may be used within the Enter command.'; const editCommand = 'edit'; const editHelp = 'Put an object into Edit mode.'; const enterCommand = 'enter'; const enterHelp = 'Enter a value into a field,\n' + 'meaning a parameter in an action dialog,\n' + 'or a property on an object being edited.\n' + 'Enter requires 2 arguments.\n' + 'The first argument is the partial field name, which must match a single field.\n' + 'The second optional argument specifies the value, or selection, to be entered.\n' + 'If a question mark is provided as the second argument, the field will not be\n' + 'updated but further details will be provided about that input field.\n' + 'If the word paste is used as the second argument, then, provided that the field is\n' + 'a reference field, the object reference in the clipboard will be pasted into the field.\n'; const forwardCommand = 'forward'; const forwardHelp = 'Move forward to next context in the history\n' + '(if you have previously moved back).'; const geminiCommand = 'gemini'; const geminiHelp = 'Switch to the Gemini (graphical) user interface\n' + 'preserving the current context.'; const gotoCommand = 'goto'; const gotoHelp = 'Go to the object referenced in a property,\n' + 'or to a collection within an object,\n' + 'or to an object within an open list or collection.\n' + 'Goto takes one argument. In the context of an object\n' + 'that is the name or partial name of the property or collection.\n' + 'In the context of an open list or collection, it is the\n' + 'number of the item within the list or collection (starting at 1). '; const helpCommand = 'help'; const helpHelp = 'If no argument is specified, help provides a basic explanation of how to use Cicero.\n' + 'If help is followed by a question mark as an argument, this lists the commands available\n' + 'in the current context. If help is followed by another command word as an argument\n' + '(or an abbreviation of it), a description of the specified Command is returned.'; const menuCommand = 'menu'; const menuHelp = 'Open a named main menu, from any context.\n' + 'Menu takes one optional argument: the name, or partial name, of the menu.\n' + 'If the partial name matches more than one menu, a list of matches is returned\n' + 'but no menu is opened; if no argument is provided a list of all the menus\n' + 'is returned.'; const okCommand = 'ok'; const okHelp = 'Invoke the action currently open as a dialog.\n' + 'Fields in the dialog should be completed before this.'; const pageCommand = 'page'; const pageFirst = 'first'; const pagePrevious = 'previous'; const pageNext = 'next'; const pageLast = 'last'; const pageHelp = 'Supports paging of returned lists.\n' + 'The page command takes a single argument, which may be one of these four words:\n' + 'first, previous, next, or last, \n' + 'which may be abbreviated down to the first character.\n' + 'Alternative, the argument may be a specific page number.'; const reloadCommand = 'reload'; const reloadHelp = 'Not yet implemented. Reload the data from the server for an object or a list.\n' + 'Note that for a list, which was generated by an action, reload runs the action again, \n' + 'thus ensuring that the list is up to date. However, reloading a list does not reload the\n' + 'individual objects in that list, which may still be cached. Invoking Reload on an\n' + 'individual object, however, will ensure that its fields show the latest server data.'; const rootCommand = 'root'; const rootHelp = 'From within an opend collection context, the root command returns\n' + ' to the root object that owns the collection. Does not take any arguments.\n'; const saveCommand = 'save'; const saveHelp = 'Save the updated fields on an object that is being edited,\n' + 'and return from edit mode to a normal view of that object'; const selectionCommand = 'selection'; const selectionHelp = 'Not fully implemented. Select one or more items from a list,\n' + 'prior to invoking an action on the selection.\n' + 'Selection has one mandatory argument, which must be one of these words,\n' + 'add, remove, all, clear, show.\n' + 'The Add and Remove options must be followed by a second argument specifying\n' + 'the item number, or range, to be added or removed.\n'; const showCommand = 'show'; const showHelp = 'In the context of an object, shows the name and content of\n' + 'one or more of the properties.\n' + 'May take 1 argument: the partial field name.\n' + 'If this matches more than one property, a list of matches is returned.\n' + 'If no argument is provided, the full list of properties is returned.\n' + 'In the context of an opened object collection, or a list,\n' + 'shows one or more items from that collection or list.\n' + 'If no arguments are specified, show will list all of the the items in the collection,\n' + 'or the first page of items if in a list view.\n' + 'Alternatively, the command may be specified with an item number, or a range such as 3- 5.'; const whereCommand = 'where'; const whereHelp = 'Display a reminder of the current context.\n' + 'The same can also be achieved by hitting the Return key on the empty input field.'; // Cicero feedback messages const commandTooShort = 'Command word must have at least 2 characters'; const noCommandMatch = (a) => `No command begins with ${a}`; const commandsAvailable = 'Commands available in current context:\n'; const noArguments = 'No arguments provided'; const tooFewArguments = 'Too few arguments provided'; const tooManyArguments = 'Too many arguments provided'; const commandNotAvailable = (c) => `The command: ${c} is not available in the current context`; const startHigherEnd = 'Starting item number cannot be greater than the ending item number'; const highestItem = (n) => `The highest numbered item is ${n}`; const item = 'item'; const empty = 'empty'; const numberOfItems = (n) => `${n} items`; const on = 'on'; const collection = 'Collection'; const modified = 'modified'; const properties = 'properties'; const modifiedProperties = `Modified ${properties}`; const page = 'Page'; const noVisible = 'No visible properties'; const doesNotMatch = (name) => `${name} does not match any properties`; const cannotPage = 'Cannot page list'; const alreadyOnFirst = 'List is already showing the first page'; const alreadyOnLast = 'List is already showing the last page'; const pageArgumentWrong = 'The argument must match: first, previous, next, last, or a single number'; const pageNumberWrong = (max) => `Specified page number must be between 1 and ${max}`; const mayNotbeChainedMessage = (c, r) => `${c} command may not be chained${r}. Use Where command to see where execution stopped.`; const queryOnlyRider = ' unless the action is query-only'; const noSuchCommand = (c) => `No such command: ${c}`; const missingArgument = (i) => `Required argument number ${i} is missing`; const wrongTypeArgument = (i) => `Argument number ${i} must be a number`; const isNotANumber = (s) => `${s} is not a number`; const tooManyDashes = 'Cannot have more than one dash in argument'; const mustBeGreaterThanZero = 'Item number or range values must be greater than zero'; const pleaseCompleteOrCorrect = 'Please complete or correct these fields:\n'; const required = 'required'; const mustbeQuestionMark = 'Second argument may only be a question mark - to get action details'; const noActionsAvailable = 'No actions available'; const doesNotMatchActions = (a) => `${a} does not match any actions`; const matchingActions = 'Matching actions:\n'; const actionsMessage = 'Actions:\n'; const actionPrefix = 'Action:'; const disabledPrefix = 'disabled:'; const isDisabled = 'is disabled.'; const noDescription = 'No description provided'; const descriptionPrefix = 'Description for action:'; const clipboardError = 'Clipboard command may only be followed by copy, show, go, or discard'; const clipboardContextError = 'Clipboard copy may only be used in the context of viewing an object'; const clipboardContents = (contents) => `Clipboard contains: ${contents}`; const clipboardEmpty = 'Clipboard is empty'; const doesNotMatchProperties = (name) => `${name} does not match any properties`; const matchesMultiple = 'matches multiple fields:\n'; const doesNotMatchDialog = (name) => `${name} does not match any fields in the dialog`; const multipleFieldMatches = 'Multiple fields match'; const isNotModifiable = 'is not modifiable'; const invalidCase = 'Invalid case'; const invalidRefEntry = 'Invalid entry for a reference field. Use clipboard or clip'; const emptyClipboard = 'Cannot use Clipboard as it is empty'; const incompatibleClipboard = 'Contents of Clipboard are not compatible with the field'; const noMatch = (s) => `None of the choices matches ${s}`; const multipleMatches = 'Multiple matches:\n'; const fieldName = (name) => `Field name: ${name}`; const descriptionFieldPrefix = 'Description:'; const typePrefix = 'Type:'; const unModifiablePrefix = (reason) => `Unmodifiable: ${reason}`; const outOfItemRange = (n) => `${n} is out of range for displayed items`; const doesNotMatchMenu = (name) => `${name} does not match any menu`; const matchingMenus = 'Matching menus:'; const menuTitle = (title) => `${title} menu`; const allMenus = 'Menus:'; const noRefFieldMatch = (s) => `${s} does not match any reference fields or collections`; const unsaved = 'Unsaved'; const editing = 'Editing'; class Result { input = null; output = null; static create(input, output) { return { input: input, output: output }; } } // todo move this function getParametersAndCurrentValue(action, context) { if (action instanceof Ro.InvokableActionMember || action instanceof Ro.ActionRepresentation) { const parms = action.parameters(); const cachedValues = context.getDialogCachedValues(action.actionId()); const values = mapValues(parms, p => { const value = cachedValues[p.id()]; return value === undefined ? p.default() : value; }); return values; } return {}; } function getFields(field) { if (field instanceof Ro.Parameter) { const action = field.parent; if (action instanceof Ro.InvokableActionMember || action instanceof Ro.ActionRepresentation) { const parms = action.parameters(); return map(parms, p => p); } } if (field instanceof Ro.PropertyMember) { // todo return []; } return []; } class CommandResult extends Result { stopChain; // eslint-disable-next-line @typescript-eslint/no-empty-function changeState = () => { }; } class Command { urlManager; location; commandFactory; context; mask; error; configService; ciceroContext; ciceroRenderer; constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { this.urlManager = urlManager; this.location = location; this.commandFactory = commandFactory; this.context = context; this.mask = mask; this.error = error; this.configService = configService; this.ciceroContext = ciceroContext; this.ciceroRenderer = ciceroRenderer; } argString = null; chained = false; get keySeparator() { return this.configService.config.keySeparator; } execute() { const result = new CommandResult(); // TODO Create outgoing Vm and copy across values as needed if (!this.isAvailableInCurrentContext()) { return this.returnResult('', commandNotAvailable(this.fullCommand)); } // TODO: This could be moved into a pre-parse method as it does not depend on context if (this.argString == null) { if (this.minArguments > 0) { return this.returnResult('', noArguments); } } else { const args = this.argString.split(','); if (args.length < this.minArguments) { return this.returnResult('', tooFewArguments); } else if (args.length > this.maxArguments) { return this.returnResult('', tooManyArguments); } } return this.doExecute(this.argString, this.chained, result); } returnResult(input, output, changeState, stopChain) { // eslint-disable-next-line @typescript-eslint/no-empty-function changeState = changeState ? changeState : () => { }; return Promise.resolve({ input: input, output: output, changeState: changeState, stopChain: !!stopChain }); } mayNotBeChained(rider = '') { return mayNotbeChainedMessage(this.fullCommand, rider); } checkMatch(matchText) { if (this.fullCommand.indexOf(matchText) !== 0) { throw new Error(noSuchCommand(matchText)); } } // argNo starts from 0. // If argument does not parse correctly, message will be passed to UI and command aborted. argumentAsString(argString, argNo, optional = false, toLower = true) { if (!argString) { return undefined; } if (!optional && argString.split(',').length < argNo + 1) { throw new Error(tooFewArguments); } const args = argString.split(','); if (args.length < argNo + 1) { if (optional) { return undefined; } else { throw new Error(missingArgument(argNo + 1)); } } return toLower ? args[argNo].trim().toLowerCase() : args[argNo].trim(); // which may be "" if argString ends in a ',' } // argNo starts from 0. argumentAsNumber(args, argNo, optional = false) { const arg = this.argumentAsString(args, argNo, optional); if (!arg && optional) { return null; } const number = parseInt(arg, 10); if (isNaN(number)) { throw new Error(wrongTypeArgument(argNo + 1)); } return number; } parseInt(input) { const number = parseInt(input, 10); if (isNaN(number)) { throw new Error(isNotANumber(input)); } return number; } // Parses '17, 3-5, -9, 6-' into two numbers parseRange(arg) { if (!arg) { arg = '1-'; } const clauses = arg.split('-'); const range = { start: null, end: null }; switch (clauses.length) { case 1: { const firstValue = clauses[0]; range.start = firstValue ? this.parseInt(firstValue) : null; range.end = range.start; break; } case 2: { const firstValue = clauses[0]; const secondValue = clauses[1]; range.start = firstValue ? this.parseInt(firstValue) : null; range.end = secondValue ? this.parseInt(secondValue) : null; break; } default: throw new Error(tooManyDashes); } if ((range.start != null && range.start < 1) || (range.end != null && range.end < 1)) { throw new Error(mustBeGreaterThanZero); } return range; } getContextDescription() { // todo return null; } routeData() { return this.urlManager.getRouteData().pane1; } // Helpers delegating to RouteData isHome() { return this.urlManager.isHome(); } isObject() { return this.urlManager.isObject(); } getObject() { const oid = Ro.ObjectIdWrapper.fromObjectId(this.routeData().objectId, this.keySeparator); // TODO: Consider view model & transient modes? return this.context.getObject(1, oid, this.routeData().interactionMode).then((obj) => { if (this.routeData().interactionMode === InteractionMode.Edit) { return this.context.getObjectForEdit(1, obj); } else { return obj; // To wrap a known object as a promise } }); } isList() { return this.urlManager.isList(); } getList() { const routeData = this.routeData(); // TODO: Currently covers only the list-from-menu; need to cover list from object action return this.context.getListFromMenu(routeData, routeData.page, routeData.pageSize); } isMenu() { return !!this.routeData().menuId; } getMenu() { return this.context.getMenu(this.routeData().menuId); } isDialog() { return !!this.routeData().dialogId; } isMultiChoiceField(field) { const entryType = field.entryType(); return entryType === Ro.EntryType.MultipleChoices || entryType === Ro.EntryType.MultipleConditionalChoices; } getActionForCurrentDialog() { const dialogId = this.routeData().dialogId; if (this.isObject()) { return this.getObject().then((obj) => this.context.getInvokableAction(obj.actionMember(dialogId))); } else if (this.isMenu()) { return this.getMenu().then((menu) => this.context.getInvokableAction(menu.actionMember(dialogId))); // i.e. return a promise } return Promise.reject(new ErrorWrapper(ErrorCategory.ClientError, ClientErrorCode.NotImplemented, 'List actions not implemented yet')); } // Tests that at least one collection is open (should only be one). // TODO: assumes that closing collection removes it from routeData NOT sets it to Summary isCollection() { return this.isObject() && some(this.routeData().collections); } closeAnyOpenCollections() { const open = this.ciceroRenderer.openCollectionIds(this.routeData()); forEach(open, id => this.urlManager.setCollectionMemberState(id, CollectionViewState.Summary)); } isTable() { return false; // TODO } isEdit() { return this.routeData().interactionMode === InteractionMode.Edit; } isForm() { return this.routeData().interactionMode === InteractionMode.Form; } isTransient() { return this.routeData().interactionMode === InteractionMode.Transient; } matchingProperties(obj, match) { let props = map(obj.propertyMembers(), prop => prop); if (match) { props = this.matchFriendlyNameAndOrMenuPath(props, match); } return props; } matchingCollections(obj, match) { const allColls = map(obj.collectionMembers(), action => action); if (match) { return this.matchFriendlyNameAndOrMenuPath(allColls, match); } else { return allColls; } } matchingParameters(action, match) { let params = map(action.parameters(), p => p); if (match) { params = this.matchFriendlyNameAndOrMenuPath(params, match); } return params; } matchFriendlyNameAndOrMenuPath(reps, match) { const clauses = match ? match.split(' ') : []; // An exact match has preference over any partial match const exactMatches = filter(reps, (rep) => { const path = rep.extensions().menuPath(); const name = rep.extensions().friendlyName().toLowerCase(); return match === name || (!!path && match === path.toLowerCase() + ' ' + name) || every(clauses, clause => name === clause || (!!path && path.toLowerCase() === clause)); }); if (exactMatches.length > 0) { return exactMatches; } return filter(reps, rep => { const path = rep.extensions().menuPath(); const name = rep.extensions().friendlyName().toLowerCase(); return every(clauses, clause => name.indexOf(clause) >= 0 || (!!path && path.toLowerCase().indexOf(clause) >= 0)); }); } findMatchingChoicesForRef(choices, titleMatch) { return choices ? filter(choices, v => v.toString().toLowerCase().indexOf(titleMatch.toLowerCase()) >= 0) : []; } findMatchingChoicesForScalar(choices, titleMatch) { if (choices == null) { return []; } const labels = keys(choices); const matchingLabels = filter(labels, l => l.toString().toLowerCase().indexOf(titleMatch.toLowerCase()) >= 0); const result = new Array(); switch (matchingLabels.length) { case 0: break; // leave result empty case 1: // Push the VALUE for the key // For simple scalars they are the same, but not for Enums result.push(choices[matchingLabels[0]]); break; default: // Push the matching KEYs, wrapped as (pseudo) Values for display in message to user // For simple scalars the values would also be OK, but not for Enums forEach(matchingLabels, label => result.push(new Ro.Value(label))); break; } return result; } handleErrorResponse(err, getFriendlyName) { if (err.invalidReason()) { return this.returnResult('', err.invalidReason()); } let msg = pleaseCompleteOrCorrect; each(err.valuesMap(), (errorValue, fieldId) => { msg += this.fieldValidationMessage(errorValue, () => getFriendlyName(fieldId)); }); return this.returnResult('', msg); } handleErrorResponseNew(err, getFriendlyName) { if (err.invalidReason()) { return this.returnResult('', err.invalidReason()); } let msg = pleaseCompleteOrCorrect; each(err.valuesMap(), (errorValue, fieldId) => { msg += this.fieldValidationMessage(errorValue, () => getFriendlyName(fieldId)); }); return this.returnResult('', msg); } validationMessage(reason, value, fieldFriendlyName) { if (reason) { const prefix = `${fieldFriendlyName}: `; const suffix = reason === mandatory ? required : `${value} ${reason}`; return `${prefix}${suffix}\n`; } return ''; } fieldValidationMessage(errorValue, fieldFriendlyName) { const reason = errorValue.invalidReason; return this.validationMessage(reason, errorValue.value, fieldFriendlyName()); } valueForUrl(val, field) { if (val.isNull()) { return val; } const fieldEntryType = field.entryType(); if (fieldEntryType !== Ro.EntryType.FreeForm || field.isCollectionContributed()) { if (this.isMultiChoiceField(field) || field.isCollectionContributed()) { let valuesFromRouteData = new Array(); if (field instanceof Ro.Parameter) { const rd = getParametersAndCurrentValue(field.parent, this.context)[field.id()]; if (rd) { valuesFromRouteData = rd.list(); } // TODO: what if only one? } else if (field instanceof Ro.PropertyMember) { const obj = field.parent; const props = this.context.getObjectCachedValues(obj.id()); const rd = props[field.id()]; if (rd) { valuesFromRouteData = rd.list(); } // TODO: what if only one? } let vals = []; if (val.isReference() || val.isScalar()) { vals = new Array(val); } else if (val.isList()) { // Should be! vals = val.list(); } valuesFromRouteData = valuesFromRouteData || []; forEach(vals, v => this.addOrRemoveValue(valuesFromRouteData, v)); if (vals[0].isScalar()) { // then all must be scalar const scalars = map(valuesFromRouteData, v => v.scalar()); return new Ro.Value(scalars); } else { // assumed to be links const links = map(valuesFromRouteData, v => ({ href: v.link().href(), title: Ro.withUndefined(v.link().title()) })); return new Ro.Value(links.length > 0 ? links : null); } } if (val.isScalar()) { return val; } // reference return this.leanLink(val); } if (val.isScalar()) { if (val.isNull()) { return new Ro.Value(''); } return val; // TODO: consider these options: // if (from.value instanceof Date) { // return new Value((from.value as Date).toISOString()); // } // return new Value(from.value as number | string | boolean); } if (val.isReference()) { return this.leanLink(val); } return null; } leanLink(val) { return new Ro.Value({ href: val.link().href(), title: val.link().title() }); } addOrRemoveValue(valuesFromRouteData, val) { let index; let valToAdd; if (val.isScalar()) { valToAdd = val; index = findIndex(valuesFromRouteData, v => v.scalar() === val.scalar()); } else { // Must be reference valToAdd = this.leanLink(val); index = findIndex(valuesFromRouteData, v => v.link().href() === valToAdd.link().href()); } if (index > -1) { valuesFromRouteData.splice(index, 1); } else { valuesFromRouteData.push(valToAdd); } } setFieldValueInContext(field, val) { this.context.cacheFieldValue(this.routeData().dialogId, field.id(), val); } setPropertyValueinContext(obj, property, urlVal) { this.context.cachePropertyValue(obj, property, urlVal); } } class Action extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'ac'; fullCommand = actionCommand; helpText = actionHelp; minArguments = 0; maxArguments = 2; isAvailableInCurrentContext() { return (this.isMenu() || this.isObject() || this.isForm()) && !this.isDialog() && !this.isEdit(); // TODO add list } doExecute(args, _chained, _result) { const match = this.argumentAsString(args, 0); const details = this.argumentAsString(args, 1, true); if (details && details !== '?') { return this.returnResult('', mustbeQuestionMark); } if (this.isObject()) { return this.getObject().then(obj => this.processActions(match, obj.actionMembers(), details)); } else if (this.isMenu()) { return this.getMenu().then(menu => this.processActions(match, menu.actionMembers(), details)); } // TODO: handle list - CCAs return Promise.reject('TODO: handle list - CCAs'); } processActions(match, actionsMap, details) { let actions = map(actionsMap, action => action); if (actions.length === 0) { return this.returnResult('', noActionsAvailable); } if (match) { actions = this.matchFriendlyNameAndOrMenuPath(actions, match); } switch (actions.length) { case 0: return this.returnResult('', doesNotMatchActions(match)); case 1: { const action = actions[0]; if (details) { return this.returnResult('', this.renderActionDetails(action)); } else if (action.disabledReason()) { return this.returnResult('', this.disabledAction(action)); } else { return this.openActionDialog(action); } } default: { let output = match ? matchingActions : actionsMessage; output += this.listActions(actions); return this.returnResult('', output); } } } disabledAction(action) { return `${actionPrefix} ${action.extensions().friendlyName()} ${isDisabled} ${action.disabledReason()}`; } listActions(actions) { return reduce(actions, (s, t) => { const menupath = t.extensions().menuPath() ? `${t.extensions().menuPath()} - ` : ''; const disabled = t.disabledReason() ? ` (${disabledPrefix} ${t.disabledReason()})` : ''; return s + menupath + t.extensions().friendlyName() + disabled + '\n'; }, ''); } openActionDialog(action) { return this.context.getInvokableAction(action). then(invokable => { this.context.clearDialogCachedValues(); this.urlManager.setDialog(action.actionId()); forEach(invokable.parameters(), p => this.setFieldValueInContext(p, p.default())); return this.returnResult('', ''); }); } renderActionDetails(action) { return `${descriptionPrefix} ${action.extensions().friendlyName()}\n${action.extensions().description() || noDescription}`; } } class Back extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'ba'; fullCommand = backCommand; helpText = backHelp; minArguments = 0; maxArguments = 0; isAvailableInCurrentContext() { return true; } doExecute(_args, _chained) { return this.returnResult('', '', () => this.location.back()); } } class Cancel extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'ca'; fullCommand = cancelCommand; helpText = cancelHelp; minArguments = 0; maxArguments = 0; isAvailableInCurrentContext() { return this.isDialog() || this.isEdit(); } doExecute(_args, _chained) { if (this.isEdit()) { return this.returnResult('', '', () => this.urlManager.setInteractionMode(InteractionMode.View)); } if (this.isDialog()) { return this.returnResult('', '', () => this.urlManager.closeDialogReplaceHistory(this.routeData().dialogId)); } return this.returnResult('', 'some sort of error'); // todo } } class Clipboard extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'cl'; fullCommand = clipboardCommand; helpText = clipboardHelp; minArguments = 1; maxArguments = 1; isAvailableInCurrentContext() { return true; } doExecute(args, _chained) { const sub = this.argumentAsString(args, 0); if (sub === undefined) { return this.returnResult('', clipboardError); } if (clipboardCopy.indexOf(sub) === 0) { return this.copy(); } else if (clipboardShow.indexOf(sub) === 0) { return this.show(); } else if (clipboardGo.indexOf(sub) === 0) { return this.go(); } else if (clipboardDiscard.indexOf(sub) === 0) { return this.discard(); } else { return this.returnResult('', clipboardError); } } copy() { if (!this.isObject()) { return this.returnResult('', clipboardContextError); } return this.getObject().then(obj => { this.ciceroContext.ciceroClipboard = obj; const label = Ro.typePlusTitle(obj); return this.returnResult('', clipboardContents(label)); }); } show() { if (this.ciceroContext.ciceroClipboard) { const label = Ro.typePlusTitle(this.ciceroContext.ciceroClipboard); return this.returnResult('', clipboardContents(label)); } else { return this.returnResult('', clipboardEmpty); } } go() { const link = this.ciceroContext.ciceroClipboard && this.ciceroContext.ciceroClipboard.selfLink(); if (link) { return this.returnResult('', '', () => this.urlManager.setItem(link)); } else { return this.show(); } } discard() { this.ciceroContext.ciceroClipboard = null; return this.show(); } } class Edit extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'ed'; fullCommand = editCommand; helpText = editHelp; minArguments = 0; maxArguments = 0; isAvailableInCurrentContext() { return this.isObject() && !this.isEdit(); } doExecute(args, chained) { if (chained) { // eslint-disable-next-line @typescript-eslint/no-empty-function return this.returnResult('', this.mayNotBeChained(), () => { }, true); } const newState = () => { this.context.clearObjectCachedValues(); this.urlManager.setInteractionMode(InteractionMode.Edit); }; return this.returnResult('', '', newState); } } function isInteger(value) { return typeof value === 'number' && isFinite(value) && Math.floor(value) === value; } function getDate(val) { const dt1 = DateTime.fromFormat(val, fixedDateFormat); return dt1.isValid ? dt1.toJSDate() : null; } function validateNumber(model, newValue, filter) { const format = model.extensions().format(); switch (format) { case ('int'): if (!isInteger(newValue)) { return 'Not an integer'; } } const range = model.extensions().range(); if (range) { const min = range.min; const max = range.max; if (typeof min === 'number' && newValue < min) { return outOfRange(newValue, min, max, filter); } if (typeof max === 'number' && newValue > max) { return outOfRange(newValue, min, max, filter); } } return ''; } function validateStringFormat(model, newValue) { const maxLength = model.extensions().maxLength(); const pattern = model.extensions().pattern(); const len = newValue ? newValue.length : 0; if (maxLength && len > maxLength) { return tooLong; } if (pattern) { const regex = new RegExp(pattern); return regex.test(newValue) ? '' : noPatternMatch; } return ''; } function validateDateTimeFormat(_model, _newValue) { return ''; } function validateDateFormat(model, newValue, filter) { const range = model.extensions().range(); const newDate = (newValue instanceof Date) ? newValue : getDate(newValue); if (range && newDate) { const min = range.min ? getDate(range.min) : null; const max = range.max ? getDate(range.max) : null; if (min && newDate < min) { return outOfRange(Ro.toDateString(newDate), min.toISOString(), max?.toISOString(), filter); } if (max && newDate > max) { return outOfRange(Ro.toDateString(newDate), min?.toISOString(), max.toISOString(), filter); } } return ''; } function validateTimeFormat(_model, _newValue) { return ''; } function validateString(model, newValue, filter) { const format = model.extensions().format(); switch (format) { case ('string'): return validateStringFormat(model, newValue); case ('date-time'): return validateDateTimeFormat(model, newValue); case ('date'): return validateDateFormat(model, newValue, filter); case ('time'): return validateTimeFormat(model, newValue); default: return ''; } } function validateMandatory(model, viewValue) { // first check const isMandatory = !model.extensions().optional(); if (isMandatory && (viewValue === '' || viewValue == null)) { return mandatory; } return ''; } function validateMandatoryAgainstType(model, viewValue, filter) { // check type const returnType = model.extensions().returnType(); switch (returnType) { case ('number'): { const valueAsNumber = parseFloat(viewValue); if (Number.isFinite(valueAsNumber)) { return validateNumber(model, valueAsNumber, filter); } return notANumber; } case ('string'): return validateString(model, viewValue, filter); case ('boolean'): return ''; default: return ''; } } function validateDate(newValue, validInputFormats) { for (const f of validInputFormats) { const dt = DateTime.fromFormat(newValue, f); if (dt.isValid) { return dt; } } return null; } class Enter extends Command { constructor(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer) { super(urlManager, location, commandFactory, context, mask, error, configService, ciceroContext, ciceroRenderer); } shortCommand = 'en'; fullCommand = enterCommand; helpText = enterHelp; minArguments = 2; maxArguments = 2; isAvailableInCurrentContext() { return this.isDialog() || this.isEdit() || this.isTransient() || this.isForm(); } doExecute(args, _chained) { const fieldName = this.argumentAsString(args, 0); const fieldEntry = this.argumentAsString(args, 1, false, false); if (fieldName === undefined) { return this.returnResult('', doesNotMatchDialog(fieldName)); } if (fieldEntry === undefined) { return this.returnResult('', tooFewArguments); } if (this.isDialog()) { return this.fieldEntryForDialog(fieldName, fieldEntry); } else { return this.fieldEntryForEdit(fieldName, fieldEntry); } } fieldEntryForEdit(fieldName, fieldEntry) { return this.getObject().then(obj => { const fields = this.matchingProperties(obj, fieldName); switch (fields.length) { case 0: { const s = doesNotMatchProperties(fieldName); return this.returnResult('', s); } case 1: { const field = fields[0]; if (fieldEntry === '?') { // TODO: does this work in edit mode i.e. show entered value const details = this.renderFieldDetails(field, field.value()); return this.returnResult('', details); } else { this.findAndClearAnyDependentFields(field.id(), obj.propertyMembers()); return this.setField(field, fieldEntry); } } default: { const ss = reduce(fields, (str, prop) => str + prop.extensions().friendlyName() + '\n', `${fieldName} ${matchesMultiple}`); return this.returnResult('', ss); } } }); } isDependentField(fieldName, possibleDependent) { const promptLink = possibleDependent.promptLink(); if (promptLink) { const pArgs = promptLink.arguments(); const argNames = keys(pArgs); return (argNames.indexOf(fieldName.toLowerCase()) >= 0); } return false; } findAndClearAnyDependentFields(changingField, allFields) { forEach(allFields, field => { if (this.isDependentField(changingField, field)) { if (!this.isMultiChoiceField(field)) { this.clearField(field); } } }); } fieldEntryForDialog(fieldName, fieldEntry) { return this.getActionForCurrentDialog().then(action => { // let params = map(action.parameters(), param => param); params = this.matchFriendlyNameAndOrMenuPath(params, fieldName); switch (params.length) { case 0: return this.returnResult('', doesNotMatchDialog(fieldName)); case 1: if (fieldEntry === '?') { const p = params[0]; const value = getParametersAndCurrentValue(p.parent, this.context)[p.id()]; const s = this.renderFieldDetails(p, value); return this.returnResult('', s); } else { this.findAndClearAnyDependentFields(fieldName, action.parameters()); return this.setField(params[0], fieldEntry); } default: return this.returnResult('', `${multipleFieldMatches} ${fieldName}`); // TODO: list them } }); } clearField(field) { this.context.cacheFieldValue(this.routeData().dialogId, field.id(), new Ro.Value(null)); if (field instanceof Ro.Parameter) { this.context.cacheFieldValue(this.routeData().dialogId, field.id(), new Ro.Value(null)); } else if (field instanceof Ro.PropertyMember) { const parent = field.parent; this.context.cachePropertyValue(parent, field, new Ro.Value(null)); } } setField(field, fieldEntry) { if (field instanceof Ro.PropertyMember && field.disabledReason()) { return this.returnResult('', `${field.extensions().friendlyName()} ${isNotModifiable}`); } const entryType = field.entryType(); switch (entryType) {