diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index d80392c85..15800f1d1 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -57,6 +57,7 @@ import { composeWithUi, convertDateToString, createLabelDescriptionFrom, + encode, findUiControl, getFirstPrimitiveProp, getPropPath, @@ -130,6 +131,38 @@ const isRequired = ( ); }; +/** + * Determines whether the data at the given instance path is required, based on + * the root schema. Walks the data path against the root schema while skipping + * combinator branches (oneOf/anyOf/allOf), so a required parent makes its + * inner combinator sub-schemas required as well. + */ +const isRequiredAtPath = (rootSchema: JsonSchema, path: string): boolean => { + if (!path) { + return false; + } + const segments = path.split('.'); + const lastSegment = segments[segments.length - 1]; + if (/^\d+$/.test(lastSegment)) { + return false; + } + let parentSchemaPath = '#'; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + if (/^\d+$/.test(segment)) { + // We don't try to resolve through array indices to keep the lookup simple. + return false; + } + parentSchemaPath += '/properties/' + encode(segment); + } + const parentSchema = Resolve.schema(rootSchema, parentSchemaPath, rootSchema); + return ( + parentSchema !== undefined && + parentSchema.required !== undefined && + parentSchema.required.indexOf(lastSegment) !== -1 + ); +}; + /** * Adds an asterisk to the given label string based * on the required parameter. @@ -603,7 +636,8 @@ export const mapStateToControlProps = ( const rootSchema = getSchema(state); const required = controlElement.scope !== undefined && - isRequired(ownProps.schema, controlElement.scope, rootSchema); + (isRequired(ownProps.schema, controlElement.scope, rootSchema) || + isRequiredAtPath(rootSchema, path)); const resolvedSchema = Resolve.schema( ownProps.schema || rootSchema, controlElement.scope, diff --git a/packages/core/test/mappers/renderer.test.ts b/packages/core/test/mappers/renderer.test.ts index 9d0dd516c..443a8e4ef 100644 --- a/packages/core/test/mappers/renderer.test.ts +++ b/packages/core/test/mappers/renderer.test.ts @@ -2149,6 +2149,51 @@ test('mapStateToControlProps - required is calculated correctly from encoded JSO t.true(props.required === true); }); +test('mapStateToControlProps - required propagates from required oneOf parent to dispatched primitive sub-schema (issue #2222)', (t) => { + // The OneOf renderer dispatches the string sub-schema with a generated + // uischema whose scope is `#`. The inner control should still be marked + // as required because the oneOf parent is required. + const rootSchema = { + type: 'object', + properties: { + mecanique: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { cardinale: { type: 'string' } }, + required: ['cardinale'], + }, + ], + }, + }, + required: ['mecanique'], + }; + const subSchema = (rootSchema.properties.mecanique as any).oneOf[0]; + const uischema: ControlElement = { + type: 'Control', + scope: '#', + }; + const ownProps = { + visible: true, + uischema, + path: 'mecanique', + schema: subSchema, + }; + const state = { + jsonforms: { + core: { + schema: rootSchema, + data: {}, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToControlProps(state, ownProps); + t.true(props.required === true); +}); + test('mapStateToEnumControlProps - i18n - should not crash without i18n', (t) => { const ownProps = { uischema: coreUISchema, diff --git a/packages/examples/src/examples/required.ts b/packages/examples/src/examples/required.ts new file mode 100644 index 000000000..743b6d5ca --- /dev/null +++ b/packages/examples/src/examples/required.ts @@ -0,0 +1,103 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + requiredString: { + type: 'string', + description: 'Marked as required via the parent object.', + }, + optionalString: { + type: 'string', + description: 'Not in the parent`s required array.', + }, + nested: { + type: 'object', + properties: { + requiredNestedString: { + type: 'string', + description: 'Required via the immediate parent (nested) object.', + }, + optionalNestedString: { + type: 'string', + }, + }, + required: ['requiredNestedString'], + }, + requiredOneOf: { + title: 'Required oneOf', + description: + 'Whole oneOf is required at the parent. Both branches should show as required.', + oneOf: [ + { + title: 'String branch', + type: 'string', + }, + { + title: 'Object branch', + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + ], + }, + optionalOneOf: { + title: 'Optional oneOf', + description: + 'oneOf is NOT required. Only fields with their own required constraint show as required.', + oneOf: [ + { + title: 'String branch', + type: 'string', + }, + { + title: 'Object branch', + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + ], + }, + }, + required: ['requiredString', 'nested', 'requiredOneOf'], +}; + +export const data = {}; + +registerExamples([ + { + name: 'required', + label: 'Required', + data, + schema, + uischema: undefined, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index f8fdc044d..7d983b7c4 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -70,6 +70,7 @@ import * as multiEnum from './examples/enum-multi'; import * as enumI18n from './examples/enumI18n'; import * as enumInArray from './examples/enumInArray'; import * as readonly from './examples/readonly'; +import * as required from './examples/required'; import * as listWithDetailPrimitives from './examples/list-with-detail-primitives'; import * as conditionalSchemaComposition from './examples/conditional-schema-compositions'; import * as additionalErrors from './examples/additional-errors'; @@ -139,6 +140,7 @@ export { enumI18n, enumInArray, readonly, + required, listWithDetailPrimitives, conditionalSchemaComposition, additionalErrors,