Skip to content
Open
7 changes: 7 additions & 0 deletions packages/core/src/configDefault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ export const configDefault = {
* [text] if asterisks in labels for required fields should be hidden
*/
hideRequiredAsterisk: false,

/**
* When false (default), readonly is treated as disabled for backward compatibility.
* When true, readonly and enabled are handled separately and exposed to renderers,
* allowing UI libraries to distinguish between disabled and readonly states.
*/
separateReadonlyFromDisabled: false,
};
27 changes: 24 additions & 3 deletions packages/core/src/mappers/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
JsonFormsCellRendererRegistryEntry,
JsonFormsState,
} from '../store';
import { isInherentlyEnabled } from './util';
import { isInherentlyEnabled, isInherentlyReadonly } from './util';

export interface OwnPropsOfCell extends OwnPropsOfControl {
data?: any;
Expand Down Expand Up @@ -126,10 +126,14 @@ export const mapStateToCellProps = (
* table renderer, determines whether a cell is enabled and should hand
* over the prop themselves. If that prop was given, we prefer it over
* anything else to save evaluation effort (except for the global readonly
* flag). For example it would be quite expensive to evaluate the same ui schema
* flag when separateReadonlyFromDisabled is disabled).
* For example it would be quite expensive to evaluate the same ui schema
* rule again and again for each cell of a table. */
let enabled;
if (state.jsonforms.readonly === true) {
if (
!config?.separateReadonlyFromDisabled &&
state.jsonforms.readonly === true
) {
enabled = false;
} else if (typeof ownProps.enabled === 'boolean') {
enabled = ownProps.enabled;
Expand All @@ -144,6 +148,22 @@ export const mapStateToCellProps = (
);
}

/* Similar to enabled, we take a shortcut for readonly state. The parent
* renderer can pass the readonly prop directly if it has already computed it,
* saving re-evaluation for each cell. */
let readonly;
if (typeof ownProps.readonly === 'boolean') {
readonly = ownProps.readonly;
} else {
readonly = isInherentlyReadonly(
state,
ownProps,
uischema,
schema || rootSchema,
rootData,
config
);
}
const t = getTranslator()(state);
const te = getErrorTranslator()(state);
const errors = getCombinedErrorMessage(
Expand All @@ -160,6 +180,7 @@ export const mapStateToCellProps = (
data: Resolve.data(rootData, path),
visible,
enabled,
readonly,
id,
path,
errors,
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/mappers/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ import {
getUISchemas,
getUiSchema,
} from '../store';
import { isInherentlyEnabled } from './util';
import { isInherentlyEnabled, isInherentlyReadonly } from './util';
import { CombinatorKeyword } from './combinators';
import isEqual from 'lodash/isEqual';

Expand Down Expand Up @@ -375,6 +375,10 @@ export interface OwnPropsOfRenderer {
* Whether the rendered element should be enabled.
*/
enabled?: boolean;
/**
* Whether the rendered element should be readonly.
*/
readonly?: boolean;
/**
* Whether the rendered element should be visible.
*/
Expand Down Expand Up @@ -440,6 +444,12 @@ export interface StatePropsOfRenderer {
* Whether the rendered element should be enabled.
*/
enabled: boolean;

/**
* Whether the rendered element should be readonly.
*/
readonly?: boolean;

/**
* Whether the rendered element should be visible.
*/
Expand Down Expand Up @@ -614,6 +624,14 @@ export const mapStateToControlProps = (
rootData,
config
);
const readonly: boolean = isInherentlyReadonly(
state,
ownProps,
uischema,
resolvedSchema || rootSchema,
rootData,
config
);

const schema = resolvedSchema ?? rootSchema;
const t = getTranslator()(state);
Expand Down Expand Up @@ -646,6 +664,7 @@ export const mapStateToControlProps = (
label: i18nLabel,
visible,
enabled,
readonly,
id,
path,
required,
Expand Down Expand Up @@ -1062,6 +1081,14 @@ export const mapStateToLayoutProps = (
rootData,
config
);
const readonly: boolean = isInherentlyReadonly(
state,
ownProps,
uischema,
undefined, // layouts have no associated schema
rootData,
config
);

// some layouts have labels which might need to be translated
const t = getTranslator()(state);
Expand All @@ -1075,6 +1102,7 @@ export const mapStateToLayoutProps = (
cells: ownProps.cells || getCells(state),
visible,
enabled,
readonly,
path: ownProps.path,
data,
uischema: ownProps.uischema,
Expand Down
73 changes: 62 additions & 11 deletions packages/core/src/mappers/util.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { JsonSchema, UISchemaElement } from '../models';
import { JsonFormsState, getAjv } from '../store';
import { hasEnableRule, isEnabled } from '../util';
import { hasEnableRule, hasReadonlyRule, isEnabled, isReadonly } from '../util';

/**
* Indicates whether the given `uischema` element shall be enabled or disabled.
* Checks the global readonly flag, uischema rule, uischema options (including the config),
* Checks the global readonly flag (unless separateReadonlyFromDisabled is enabled), uischema rule, uischema options (including the config),
* the schema and the enablement indicator of the parent.
*/
export const isInherentlyEnabled = (
Expand All @@ -15,29 +15,80 @@ export const isInherentlyEnabled = (
rootData: any,
config: any
) => {
if (state?.jsonforms?.readonly) {
if (!config?.separateReadonlyFromDisabled && state?.jsonforms?.readonly) {
return false;
}
if (uischema && hasEnableRule(uischema)) {
return isEnabled(uischema, rootData, ownProps?.path, getAjv(state), config);
}
if (!config?.separateReadonlyFromDisabled) {
if (typeof uischema?.options?.readonly === 'boolean') {
return !uischema.options.readonly;
}
if (typeof uischema?.options?.readOnly === 'boolean') {
return !uischema.options.readOnly;
}
if (typeof config?.readonly === 'boolean') {
return !config.readonly;
}
if (typeof config?.readOnly === 'boolean') {
return !config.readOnly;
}
if (schema?.readOnly === true) {
return false;
}
}
if (typeof ownProps?.enabled === 'boolean') {
return ownProps.enabled;
}
return true;
};

/**
* Indicates whether the given `uischema` element shall be readonly or writable.
* Checks the global readonly flag, uischema rule, uischema options (including the config),
* the schema and the readonly indicator of the parent.
*/
export const isInherentlyReadonly = (
state: JsonFormsState,
ownProps: any,
uischema: UISchemaElement,
schema: (JsonSchema & { readOnly?: boolean }) | undefined,
rootData: any,
config: any
) => {
if (state?.jsonforms?.readonly) {
return true;
}

if (uischema && hasReadonlyRule(uischema)) {
return isReadonly(
uischema,
rootData,
ownProps?.path,
getAjv(state),
config
);
}

if (typeof uischema?.options?.readonly === 'boolean') {
return !uischema.options.readonly;
return uischema.options.readonly;
}
if (typeof uischema?.options?.readOnly === 'boolean') {
return !uischema.options.readOnly;
return uischema.options.readOnly;
}
if (typeof config?.readonly === 'boolean') {
return !config.readonly;
return config.readonly;
}
if (typeof config?.readOnly === 'boolean') {
return !config.readOnly;
return config.readOnly;
}
if (schema?.readOnly === true) {
return false;
return true;
}
if (typeof ownProps?.enabled === 'boolean') {
return ownProps.enabled;
if (typeof ownProps?.readonly === 'boolean') {
return ownProps.readonly;
}
return true;

return false;
};
9 changes: 9 additions & 0 deletions packages/core/src/models/uischema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export enum RuleEffect {
* Effect that disables the associated element.
*/
DISABLE = 'DISABLE',
/**
* Effect that makes the associated element read-only
* (interaction allowed, value cannot be changed).
*/
READONLY = 'READONLY',
/**
* Effect that makes the associated element writable.
*/
WRITABLE = 'WRITABLE',
}

/**
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/util/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,26 @@ export const evalEnablement = (
}
};

export const evalReadonly = (
uischema: UISchemaElement,
data: any,
path: string = undefined,
ajv: Ajv,
config: unknown
): boolean => {
const fulfilled = isRuleFulfilled(uischema, data, path, ajv, config);

switch (uischema.rule.effect) {
case RuleEffect.WRITABLE:
return !fulfilled;
case RuleEffect.READONLY:
return fulfilled;
// writable by default
default:
return false;
}
};

export const hasShowRule = (uischema: UISchemaElement): boolean => {
if (
uischema.rule &&
Expand All @@ -180,6 +200,17 @@ export const hasEnableRule = (uischema: UISchemaElement): boolean => {
return false;
};

export const hasReadonlyRule = (uischema: UISchemaElement): boolean => {
if (
uischema.rule &&
(uischema.rule.effect === RuleEffect.READONLY ||
uischema.rule.effect === RuleEffect.WRITABLE)
) {
return true;
}
return false;
};

export const isVisible = (
uischema: UISchemaElement,
data: any,
Expand Down Expand Up @@ -207,3 +238,17 @@ export const isEnabled = (

return true;
};

export const isReadonly = (
uischema: UISchemaElement,
data: any,
path: string = undefined,
ajv: Ajv,
config: unknown
): boolean => {
if (uischema.rule) {
return evalReadonly(uischema, data, path, ajv, config);
}

return false;
};
2 changes: 1 addition & 1 deletion packages/core/test/generators/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ test('default schema generation array types', (t) => {
test.failing('default schema generation tuple array types', (t) => {
const instance: any = { tupleArray: [3.14, 'PI'] };
const schema = generateJsonSchema(instance);
// FIXME: This assumption is the correct one, but we crteate a oneOf in this case
// FIXME: This assumption is the correct one, but we create a oneOf in this case
t.deepEqual(schema, {
type: 'object',
properties: {
Expand Down
Loading