@nakedobjects/cicero
Version:
Single Page Application client for a Naked Objects application.
1,166 lines (1,153 loc) • 108 kB
JavaScript
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) {