diff --git a/packages/storybook/src/stories/3-ConcertoForm.stories.js b/packages/storybook/src/stories/3-ConcertoForm.stories.js index 36431b3..1044b65 100644 --- a/packages/storybook/src/stories/3-ConcertoForm.stories.js +++ b/packages/storybook/src/stories/3-ConcertoForm.stories.js @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { action } from '@storybook/addon-actions'; import { text, boolean, object } from '@storybook/addon-knobs'; -import { ConcertoForm } from '@accordproject/ui-concerto'; +import { ConcertoForm, ModelBuilderVisitor } from '@accordproject/ui-concerto'; +import { ConcertoMetamodel, TestModel } from './concerto.models'; export default { title: 'Concerto Form', @@ -14,13 +15,14 @@ export default { } }; -export const Demo = () => { +export const SimpleExample = () => { const readOnly = boolean('Read-only', false); const type = text('Type', 'test.Person'); const options = object('Options', { includeOptionalFields: true, includeSampleData: 'sample', updateExternalModels: true, + checkboxStyle: 'toggle', hiddenFields: [ 'org.accordproject.base.Transaction.transactionId', 'org.accordproject.cicero.contract.AccordContract.contractId', @@ -28,37 +30,15 @@ export const Demo = () => { 'org.accordproject.cicero.contract.AccordContractState.stateId', ], }); - const model = text('Model', `namespace test - - enum Country { - o USA - o UK - o France - o Sweden - } - - participant Person identified by name { - o String name - o Address address - --> Person[] children optional - } - - concept Address { - o String street - o String city - @FormEditor( "hide", true) - o String zipCode - o Country country - } - `); + const model = text('Model', TestModel); const handleValueChange = (json) => { return action("value changed")(json); }; options.relationshipProvider = { - getOptions : (field) => { - if(field.getFullyQualifiedTypeName() === 'test.Person') { + getOptions: (field) => { + if (field.getFullyQualifiedTypeName() === 'test.Person') { return [{ key: '001', value: 'test.Person#Marissa', @@ -85,19 +65,55 @@ export const Demo = () => { value: 'test.Person#Rosalind', text: 'Rosalind Picard' } - ] + ] } else { return null; - }} + } + } + }; + + return ( +
+ +
+ ) +}; + + +export const ModelBuilder = () => { + const readOnly = boolean('Read-only', false); + const type = text('Type', 'concerto.metamodel.ModelFile'); + const options = object('Options', { + includeOptionalFields: false, + updateExternalModels: false, + visitor: new ModelBuilderVisitor(), + customSelectors: { + types: [ + { text: 'Contract', value: 'org.accordproject.cicero.contract.AccordContract' }, + { text: 'Party', value: 'org.accordproject.cicero.contract.AccordParty' } + ] + } + }); + const model = text('Model', ConcertoMetamodel); + + const handleValueChange = (json) => { + return action("value changed")(json); }; - + return (
Person[] children optional +} + +concept Address { + o String street + o String city + @FormEditor( "hide", true) + o String zipCode + o Country country +} +`; + +export const ConcertoMetamodel = `/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + namespace concerto.metamodel + + /** + * The metadmodel for Concerto files + */ + + abstract concept DecoratorLiteral { + } + + concept DecoratorString extends DecoratorLiteral { + o String value + } + + concept DecoratorNumber extends DecoratorLiteral { + o Double value + } + + concept DecoratorBoolean extends DecoratorLiteral { + o Boolean value + } + + concept TypeIdentifier { + @FormEditor("selectOptions", "types") + o String fullyQualifiedName + } + + concept DecoratorIdentifier extends DecoratorLiteral { + o TypeIdentifier identifier + o Boolean isArray default=false + } + + concept Decorator { + o String name + o DecoratorLiteral[] arguments optional + } + + @FormEditor("defaultSubclass","concerto.metamodel.ConceptDeclaration") + abstract concept ClassDeclaration { + @FormEditor("hide", true) + o Decorator[] decorators optional + o Boolean isAbstract default=false + // TODO use regex /^(?!null|true|false)(\\p{Lu}|\\p{Ll}|\\p{Lt}|\\p{Lm}|\\p{Lo}|\\p{Nl}|\\$|_|\\\\u[0-9A-Fa-f]{4})(?:\\p{Lu}|\\p{Ll}|\\p{Lt}|\\p{Lm}|\\p{Lo}|\\p{Nl}|\\$|_|\\\\u[0-9A-Fa-f]{4}|\\p{Mn}|\\p{Mc}|\\p{Nd}|\\p{Pc}|\\u200C|\\u200D)*/u + @FormEditor("title", "name") + o String identifier default="className" regex=/^(?!null|true|false)(\\w|\\d|\\$|_|\\\\u[0-9A-Fa-f]{4})(?:\\w|\\d|\\$|_|\\\\u[0-9A-Fa-f]{4}|\\S|\\u200C|\\u200D)*$/ + o String identifiedByField optional + @FormEditor("title", "parentType") + o TypeIdentifier superType optional + o FieldDeclaration[] fields + } + + concept AssetDeclaration extends ClassDeclaration { + } + + concept ParticipantDeclaration extends ClassDeclaration { + } + + concept TransactionDeclaration extends ClassDeclaration { + } + + concept EventDeclaration extends ClassDeclaration { + } + + concept ConceptDeclaration extends ClassDeclaration { + } + + // TODO - enums do not support abstract or super types + concept EnumDeclaration extends ClassDeclaration { + } + + concept StringDefault { + o String value + } + + concept BooleanDefault { + o Boolean value + } + + concept IntegerDefault { + o Integer value + } + + concept RealDefault { + o Double value + } + + @FormEditor("defaultSubclass","concerto.metamodel.StringFieldDeclaration") + abstract concept FieldDeclaration { + // TODO Allow regex modifiers e.g. //ui + // regex /^(?!null|true|false)(\\p{Lu}|\\p{Ll}|\\p{Lt}|\\pLm}|\\p{Lo}|\\p{Nl}|\\$|_|\\\\u[0-9A-Fa-f]{4})(?:\\p{Lu}|\\p{Ll}|\\p{Lt}|\\p{Lm}|\\p{Lo}|\\p{Nl}|\\$|_|\\\\u[0-9A-Fa-f]{4}|\\p{Mn}|\\p{Mc}|\\p{Nd}|\\p{Pc}|\\u200C|\\u200D)*/u + // This regex is an approximation of what the parser accepts without using unicode character classes + o String name default="fieldName" regex=/^(?!null|true|false)(\\w|\\d|\\$|_|\\\\u[0-9A-Fa-f]{4})(?:\\w|\\d|\\$|_|\\\\u[0-9A-Fa-f]{4}|\\S|\\u200C|\\u200D)*$/ + @FormEditor("title", "isArray?") + o Boolean isArray optional + @FormEditor("title", "isOptional?") + o Boolean isOptional optional + @FormEditor("hide", true) + o Decorator[] decorators optional + } + + concept ObjectFieldDeclaration extends FieldDeclaration { + @FormEditor("hide", true) + o StringDefault defaultValue optional + @FormEditor("title", "typeIdentifier", "selectOptions", "types") + o TypeIdentifier type + } + + concept BooleanFieldDeclaration extends FieldDeclaration { + @FormEditor("hide", true) + o BooleanDefault defaultValue optional + } + + concept DateTimeFieldDeclaration extends FieldDeclaration { + } + + concept StringFieldDeclaration extends FieldDeclaration { + @FormEditor("hide", true) + o StringDefault defaultValue optional + @FormEditor("hide", true) + o StringRegexValidator validator optional + } + + concept StringRegexValidator { + o String regex + } + + concept RealDomainValidator { + o Double lower optional + o Double upper optional + } + + concept IntegerDomainValidator { + o Integer lower optional + o Integer upper optional + } + + concept RealFieldDeclaration extends FieldDeclaration { + o RealDefault defaultValue optional + o RealDomainValidator validator optional + } + + concept IntegerFieldDeclaration extends FieldDeclaration { + @FormEditor("hide", true) + o IntegerDefault defaultValue optional + @FormEditor("hide", true) + o IntegerDomainValidator validator optional + } + + concept RelationshipDeclaration extends FieldDeclaration { + @FormEditor("title", "typeIdentifier", "selectOptions", "types") + o TypeIdentifier type + } + + abstract concept Import { + o String uri optional + } + + concept NamespaceImport extends Import { + o String namespace + } + + concept TypeImport extends Import { + o TypeIdentifier identifier + } + + concept ModelFile { + o String namespace + @FormEditor("hide", true) + o Import[] imports optional + @FormEditor("title", "classes") + o ClassDeclaration[] declarations optional + } +`; \ No newline at end of file diff --git a/packages/ui-concerto/src/components/concertoForm.css b/packages/ui-concerto/src/components/concertoForm.css index 19573e0..af863b0 100644 --- a/packages/ui-concerto/src/components/concertoForm.css +++ b/packages/ui-concerto/src/components/concertoForm.css @@ -12,33 +12,72 @@ * limitations under the License. */ -.arrayElement, .classElement { - border-left: 1px solid rgba(34,36,38,.15); - padding: 5px 0px 5px 10px +.arrayElement, +.classElement { + border: 1px dashed rgba(34, 36, 38, 0.15); + padding: 5px 0px 5px 10px; + margin-bottom: 5px; +} + +.field > .field { + border-left: 1px solid rgba(34, 36, 38, 0.15); + padding: 5px 0px 5px 10px; } .grid { - display: grid; - grid-template-columns: auto 36px; - grid-column-gap: 5px; + display: grid; + grid-template-columns: auto 42px; + column-gap: 5px; } .fullHeight { - height:100% + height: 100%; } .monetaryAmount { - display: grid; - grid-template-columns: auto 80px; - grid-column-gap: 5px; + display: grid; + grid-template-columns: auto 80px; + column-gap: 5px; } .duration { - display: grid; - grid-template-columns: auto 100px; - grid-column-gap: 5px; + display: grid; + grid-template-columns: auto 100px; + column-gap: 5px; } .arrayButton { - box-shadow: none !important; + box-shadow: none !important; + background-color: rgb(240, 240, 240) !important; +} + +.ui.form .field > label { + margin-top: 2px; +} + +.ui.form .field { + margin-bottom: 0; +} + +/** CSS Classes for use by the ModelBuilderVisitor */ +.mbFieldDeclaration { + display: grid; + grid-template-columns: auto 150px 80px 80px; + column-gap: 5px; +} + +.mbObjectDeclaration { + display: grid; + grid-template-columns: auto 150px 150px 80px 80px; + column-gap: 5px; +} + +.mbIdentifierDeclaration { + display: grid; + grid-template-columns: auto 150px; + column-gap: 5px; +} + +.mbFieldDeclarations { + padding-top: 5px; } diff --git a/packages/ui-concerto/src/components/concertoForm.js b/packages/ui-concerto/src/components/concertoForm.js index 2201587..191906a 100644 --- a/packages/ui-concerto/src/components/concertoForm.js +++ b/packages/ui-concerto/src/components/concertoForm.js @@ -17,6 +17,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import get from 'lodash.get'; import set from 'lodash.set'; +import toPath from 'lodash.topath'; import { Form, Dimmer, Loader, Message } from 'semantic-ui-react'; import { ModelManager } from '@accordproject/concerto-core'; @@ -53,11 +54,11 @@ const ConcertoForm = (props) => { }, [onValueChange, value]); const addElement = useCallback((e, key, elementValue) => { - const array = get(value, key) || []; - const path = typeof key === 'string' ? [key] : key; + const pathKey = toPath(key); + const array = get(value, pathKey) || []; const valueClone = set( { ...value }, - [...path, array.length], + [...pathKey, array.length], elementValue ); setValue(valueClone); @@ -144,9 +145,9 @@ const ConcertoForm = (props) => { if (loading) { return ( - - Loading - + + Loading + ); } @@ -154,42 +155,42 @@ const ConcertoForm = (props) => { try { const form = generator.generateHTML(props.type, value); return ( -
- {form} -
+
+ {form} +
); } catch (err) { console.error(err); return ( - - - An error occured while generating this form - -
{err.message}
-
+ + + An error occured while generating this form + +
{err.message}
+
); } } if (!props.type) { return ( - - No model type specified -

- Please specify a model type to display the form. -

-
- ); - } - - return ( - Invalid JSON instance provided + No model type specified

- The JSON value does not match the model type associated with this - form. + Please specify a model type to display the form.

+ ); + } + + return ( + + Invalid JSON instance provided +

+ The JSON value does not match the model type associated with this + form. +

+
); }; diff --git a/packages/ui-concerto/src/components/fields.js b/packages/ui-concerto/src/components/fields.js index 506c905..0b7541e 100644 --- a/packages/ui-concerto/src/components/fields.js +++ b/packages/ui-concerto/src/components/fields.js @@ -16,7 +16,7 @@ import React from 'react'; import { Relationship } from '@accordproject/concerto-core'; import { Checkbox, Input, Form, Button, Select, Popup, Label, Icon } from 'semantic-ui-react'; import { DateTimeInput } from 'semantic-ui-calendar-react'; -import { parseValue, normalizeLabel } from '../utilities'; +import { parseValue, normalizeLabel, applyDecoratorTitle } from '../utilities'; export const ConcertoLabel = ({ skip, name, htmlFor }) => !skip ? : null; @@ -29,11 +29,13 @@ export const ConcertoCheckbox = ({ value, onFieldValueChange, skipLabel, + toggle }) => ( - + ( - - +}) => { + let error; + const validator = field.getValidator(); + if (validator) { + try { + validator.validate(id, value); + } catch (validationError) { + error = true; + console.warn(validationError.message); + } + } + return + - -); + ; +}; export const ConcertoRelationship = ({ id, @@ -105,32 +117,31 @@ export const ConcertoRelationship = ({ ? relationshipProvider.getOptions(field) : null; const relationshipEditor = relationshipOptions ? + id={id} + value={value} + readOnly={readOnly} + onFieldValueChange={onFieldValueChange} + options={relationshipOptions} + key={id} + /> : {normalizeLabel(relationship.getType())}} - labelPosition='right' - readOnly={readOnly} - value={relationship.getIdentifier()} - onChange={(e, data) => { - relationship.setIdentifier(data.value || 'resource1'); - return onFieldValueChange( - { ...data, value: relationship.toURI() }, - id - ); - }} - key={id} -/>; + type={type} + label={} + labelPosition='right' + readOnly={readOnly} + value={relationship.getIdentifier()} + onChange={(e, data) => { + relationship.setIdentifier(data.value || 'resource1'); + return onFieldValueChange( + { ...data, value: relationship.toURI() }, + id + ); + }} + key={id} + />; return - + {relationshipEditor} ; }; @@ -145,7 +156,7 @@ export const ConcertoDateTime = ({ skipLabel, }) => ( - + ( - + {children}
-
-
+
); @@ -203,24 +210,22 @@ export const ConcertoArrayElement = ({ }) => (
{children}
-
); @@ -231,16 +236,17 @@ export const ConcertoDropdown = ({ onFieldValueChange, options, }) => !readOnly ? ( - onFieldValueChange(data, id)} + key={`select-${id}`} + options={options} + /> ) : ( - -); + + ); const BinaryField = ({ className, children }) => (
diff --git a/packages/ui-concerto/src/index.js b/packages/ui-concerto/src/index.js index 3565d0a..f2082e3 100644 --- a/packages/ui-concerto/src/index.js +++ b/packages/ui-concerto/src/index.js @@ -13,6 +13,7 @@ */ import ConcertoFormWrapper from './components/concertoFormWrapper'; import ReactFormVisitor from './reactformvisitor'; +import ModelBuilderVisitor from './modelBuilderVisitor'; import * as Utilities from './utilities'; -export { ConcertoFormWrapper as ConcertoForm, ReactFormVisitor, Utilities }; +export { ConcertoFormWrapper as ConcertoForm, ReactFormVisitor, ModelBuilderVisitor, Utilities }; diff --git a/packages/ui-concerto/src/modelBuilderVisitor.js b/packages/ui-concerto/src/modelBuilderVisitor.js new file mode 100644 index 0000000..7e405ad --- /dev/null +++ b/packages/ui-concerto/src/modelBuilderVisitor.js @@ -0,0 +1,185 @@ +/* eslint-disable class-methods-use-this */ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import get from 'lodash.get'; +import { Form } from 'semantic-ui-react'; +import ReactFormVisitor from './reactformvisitor'; +import { + ConcertoDropdown, + ConcertoLabel, +} from './components/fields'; +import { + pathToString +} from './utilities'; + +const declarationTypes = [ + { value: 'concerto.metamodel.StringFieldDeclaration', text: 'Text' }, + { value: 'concerto.metamodel.IntegerFieldDeclaration', text: 'Whole Number' }, + { value: 'concerto.metamodel.BooleanFieldDeclaration', text: 'Boolean' }, + { value: 'concerto.metamodel.DateTimeFieldDeclaration', text: 'Date' }, + { value: 'concerto.metamodel.RealFieldDeclaration', text: 'Decimal' }, + { value: 'concerto.metamodel.ObjectFieldDeclaration', text: 'Object' }, + { value: 'concerto.metamodel.RelationshipDeclaration', text: 'Relationship' }, +]; + +/** + * Convert the contents of a ModelManager to React compnents. + * @class + */ +class ModelBuilderVisitor extends ReactFormVisitor { + /** + * Visitor design pattern + * @param {ClassDeclaration} classDeclaration - the object being visited + * @param {Object} parameters - the parameter + * @return {Object} the result of visiting or null + * @private + */ + visitClassDeclaration(classDeclaration, parameters) { + const fqn = classDeclaration.getFullyQualifiedName(); + if (fqn === 'concerto.metamodel.ConceptDeclaration') { + return this.visitMetaConceptDeclaration(classDeclaration, parameters); + } + + if (fqn === 'concerto.metamodel.TypeIdentifier') { + parameters.skipLabel = true; + const component = super.visitClassDeclaration(classDeclaration, parameters); + parameters.skipLabel = false; + return component; + } + + const declarationTypeNames = declarationTypes.map(({ value }) => value); + if (declarationTypeNames.includes(fqn)) { + return this.visitMetaFieldDeclaration(classDeclaration, parameters); + } + + return super.visitClassDeclaration(classDeclaration, parameters); + } + + visitMetaConceptDeclaration(declaration, parameters) { + const props = declaration.getProperties(); + const identifier = props.find(({ name }) => name === 'identifier'); + const superType = props.find(({ name }) => name === 'superType'); + const fields = props.find(({ name }) => name === 'fields'); + + return
+
+
{identifier.accept(this, parameters)}
+
{superType.accept(this, parameters)}
+
+
+ {fields.accept(this, parameters)} +
+
; + } + + visitMetaFieldDeclaration(declaration, parameters) { + const props = declaration.getProperties(); + + const name = props.find(({ name }) => name === 'name'); + const isOptional = props.find(({ name }) => name === 'isOptional'); + const isArray = props.find(({ name }) => name === 'isArray'); + let type; + + const key = pathToString(parameters.stack); + const value = get(parameters.json, key); + + const hasTypeProperty = name => [ + 'concerto.metamodel.ObjectFieldDeclaration', + 'concerto.metamodel.RelationshipDeclaration' + ].includes(name); + + // Create a new concept + if (value.$class) { + const tempParts = value.$class.split('.'); + const tempName = tempParts.pop(); + const concept = parameters.modelManager + .getFactory() + .newConcept(tempParts.join('.'), tempName, undefined, { + parameters: parameters.includeOptionalFields, + generate: parameters.includeSampleData, + }); + type = concept.getClassDeclaration().getProperties().find(({ name }) => name === 'type'); + } + + // We need a special version of `onFieldValueChange` because changing the $class value + // requires us to regenerate the instance + const onFieldValueChange = data => { + const { value: newClassName } = data; + + // Add a new type property to the data + if (hasTypeProperty(newClassName) && !hasTypeProperty(value.$class)) { + const parts = newClassName.split('.'); + const name = parts.pop(); + + // Create a new concept + const concept = parameters.modelManager + .getFactory() + .newConcept(parts.join('.'), name, undefined, { + parameters: parameters.includeOptionalFields, + generate: parameters.includeSampleData, + }); + + // Keep any existing values + const json = { + ...parameters.modelManager.getSerializer().toJSON(concept), + name: value.name, + isOptional: value.isOptional, + isArray: value.isArray, + }; + value.type = json; + + parameters.onFieldValueChange({ + ...data, + value: json + }, key); + return; + } + + // Remove any old type properties + value.type = undefined; + value.$class = newClassName; + parameters.onFieldValueChange({ + ...data, + value, + }, key); + }; + + return ( +
+
{name.accept(this, parameters)}
+ + + ({ + key: `option-${value}`, + value, + text, + }))} + /> + +
{type && type.accept(this, parameters)}
+
{isArray.accept(this, parameters)}
+
{isOptional.accept(this, parameters)}
+
+ ); + } +} +export default ModelBuilderVisitor; diff --git a/packages/ui-concerto/src/reactformvisitor.js b/packages/ui-concerto/src/reactformvisitor.js index dbf9d95..f2c74a2 100644 --- a/packages/ui-concerto/src/reactformvisitor.js +++ b/packages/ui-concerto/src/reactformvisitor.js @@ -42,28 +42,11 @@ import { findConcreteSubclass, getDefaultValue, toFieldType, + pathToString, + getCustomSelectDecoratorKey, + isHidden } from './utilities'; -const toPath = (paramsArr) => paramsArr.join('.'); - -/** - * Returns true if the field has the @FormEditor( "hide", true ) - * decorator - * @param {object} field the Concerto field - */ -function isHidden(field) { - const decorator = field.getDecorator('FormEditor'); - if (decorator) { - const args = decorator.getArguments(); - if (args.find((d, index) => d === 'hide' - && index < args[args.length - 1] && args[index + 1] === true)) { - return true; - } - } - - return false; -} - /** * Convert the contents of a ModelManager to React compnents. * @class @@ -205,7 +188,7 @@ class ReactFormVisitor { * @private */ visitEnumDeclaration(enumDeclaration, parameters) { - const key = toPath(parameters.stack); + const key = pathToString(parameters.stack); const value = get(parameters.json, key); return ( @@ -247,7 +230,7 @@ class ReactFormVisitor { } stack.push(field.getName()); - const key = toPath(stack); + const key = pathToString(stack); const value = get(parameters.json, key); let component = null; @@ -302,24 +285,62 @@ class ReactFormVisitor { * @private */ visitSingletonField(field, parameters, props) { - const key = toPath(parameters.stack); + const key = pathToString(parameters.stack); const value = get(parameters.json, key); if (field.isPrimitive()) { if (field.getType() === 'Boolean') { - return ; + const { checkboxStyle } = parameters; + return ; } if (toFieldType(field.getType()) === 'datetime-local') { return ; } + + // Allow the client application to override the definition of a basic input + // field with a Dropdown using their values + const customSelectorKey = getCustomSelectDecoratorKey(field); + if (customSelectorKey) { + const { customSelectors = {} } = parameters; + const options = customSelectors[customSelectorKey]; + if (!options) { + throw new Error(`Custom selector key '${customSelectorKey}' not found`); + } + return ({ + key: `option-${value}`, + value, + text, + }))} + />; + } return ; } let type = parameters.modelManager.getType( field.getFullyQualifiedTypeName() ); type = findConcreteSubclass(type); + + const decorator = field.getDecorator('FormEditor'); + let name = field.getName(); + if (decorator) { + const args = decorator.getArguments(); + name = args[1]; + } + return ( - - + + {type.accept(this, parameters)} ); @@ -348,7 +369,7 @@ class ReactFormVisitor { } stack.push(relationship.getName()); - const key = toPath(stack); + const key = pathToString(stack); const value = get(parameters.json, key); const commonProps = { @@ -384,7 +405,7 @@ class ReactFormVisitor { {value && value.map((_element, index) => { stack.push(index); - const key = toPath(stack); + const key = pathToString(stack); const value = get(parameters.json, key); const arrayComponent = ( @@ -397,7 +418,7 @@ class ReactFormVisitor { ); } else { - component = ; + component = ; } stack.pop(); diff --git a/packages/ui-concerto/src/utilities.js b/packages/ui-concerto/src/utilities.js index 91162d8..3414beb 100644 --- a/packages/ui-concerto/src/utilities.js +++ b/packages/ui-concerto/src/utilities.js @@ -116,6 +116,24 @@ export const findConcreteSubclass = declaration => { throw new Error('No concrete subclasses found'); } + // Allow the model to specify an explicit default sub-class, + // e.g. @FormEditor("defaultSubclass", "concerto.metadata.ConceptDeclaration") + const decorator = declaration.getDecorator('FormEditor'); + let explicitSubclassName; + if (decorator) { + const args = decorator.getArguments(); + const index = args.findIndex(d => d === 'defaultSubclass'); + if (index >= 0 && index < args.length - 1) { + explicitSubclassName = args[index + 1]; + } + } + + const explicitSubclass = concreteSubclasses + .find(c => c.getFullyQualifiedName() === explicitSubclassName); + if (explicitSubclass) { + return explicitSubclass; + } + return concreteSubclasses[0]; }; @@ -153,6 +171,7 @@ export const hideProperty = (property, parameters) => { * @private */ export const getDefaultValue = (field, parameters) => { + const { includeOptionalFields, includeSampleData } = parameters; if (field.isPrimitive()) { return convertToJSON(field); } @@ -161,8 +180,71 @@ export const getDefaultValue = (field, parameters) => { const resource = parameters.modelManager .getFactory() .newResource(type.getNamespace(), type.getName(), type.isIdentified() ? 'resource1' : null, { - includeOptionalFields: true, - generate: 'sample', + includeOptionalFields, + generate: includeSampleData, }); return parameters.modelManager.getSerializer().toJSON(resource); }; + +/** + * Convert an array path, e.g. ['a', 1, 'b'] to the path string e.g. 'a[1].b' + * @param {Array} array - the source array path + * @return {String} - A string representation of the path. + * @private + */ +export const pathToString = (array) => array.reduce((string, item) => { + const prefix = string === '' ? '' : '.'; + return string + (Number.isNaN(Number(item)) ? prefix + item : `[${item}]`); +}, ''); + +/** + * Substitutes the field name for a value in a decorator, @FormEditor( "title", "My Name" ) + * @param {object} field the Concerto field + * @private + */ +export const applyDecoratorTitle = field => { + const decorator = field.getDecorator('FormEditor'); + let name = field.getName(); + if (decorator) { + const args = decorator.getArguments(); + const index = args.findIndex(d => d === 'title'); + if (index >= 0 && index < args.length - 1) { + name = args[index + 1]; + } + } + return name; +}; + +/** + * Returns true if the field has the decorator @FormEditor( "hide", true ) + * @param {object} field the Concerto field + * @private + */ +export const isHidden = field => { + const decorator = field.getDecorator('FormEditor'); + if (decorator) { + const args = decorator.getArguments(); + if (args.find((d, index) => d === 'hide' + && index < args[args.length - 1] && args[index + 1] === true)) { + return true; + } + } + return false; +}; + +/** + * Returns the value of the decorator @FormEditor( "selectOptions", "key" ) + * @param {object} field the Concerto field + * @private + */ +export const getCustomSelectDecoratorKey = field => { + const decorator = field.getDecorator('FormEditor'); + if (decorator) { + const args = decorator.getArguments(); + const index = args.findIndex(d => d === 'selectOptions'); + if (index >= 0 && index < args.length - 1) { + return args[index + 1]; + } + } + return undefined; +};