Skip to content

Commit c0af685

Browse files
authored
Merge pull request #33 from PADAS/32-conditional-support
Add support for conditional sections
2 parents 1e548a8 + 2efdda4 commit c0af685

6 files changed

Lines changed: 383 additions & 15 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@earthranger/react-native-jsonforms-formatter",
3-
"version": "2.0.0-beta.14",
3+
"version": "2.0.0-beta.23",
44
"description": "Converts JTD into JSON Schema ",
55
"main": "./dist/bundle.js",
66
"types": "./dist/index.d.ts",
@@ -95,4 +95,4 @@
9595
"webpack": "^5.91.0",
9696
"webpack-cli": "^5.1.4"
9797
}
98-
}
98+
}

src/common/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface V2UIField {
7272
leftColumn?: string[];
7373
rightColumn?: string[];
7474
allowableFileTypes?: string[];
75+
conditionalDependents?: string[];
7576
}
7677

7778
export interface V2Choices {
@@ -90,12 +91,26 @@ export interface V2Header {
9091
size: 'LARGE' | 'MEDIUM' | 'SMALL';
9192
}
9293

94+
export type V2ConditionOperator =
95+
| 'CONTAINS'
96+
| 'DOES_NOT_HAVE_INPUT'
97+
| 'HAS_INPUT'
98+
| 'INPUT_IS_EXACTLY';
99+
100+
export interface V2Condition {
101+
field: string;
102+
id: string;
103+
operator: V2ConditionOperator;
104+
value?: string | number | boolean | null;
105+
}
106+
93107
export interface V2Section {
94108
columns: 1 | 2;
95109
isActive: boolean;
96110
label: string;
97111
leftColumn: V2ColumnItem[];
98112
rightColumn: V2ColumnItem[];
113+
conditions?: V2Condition[];
99114
}
100115

101116
export interface V2ColumnItem {
@@ -125,10 +140,23 @@ export interface JSONFormsControl extends JSONFormsUIElement {
125140
};
126141
}
127142

143+
export type JSONFormsRuleEffect = 'SHOW' | 'HIDE' | 'ENABLE' | 'DISABLE';
144+
145+
export interface JSONFormsSchemaBasedCondition {
146+
scope: string;
147+
schema: Record<string, unknown>;
148+
}
149+
150+
export interface JSONFormsRule {
151+
effect: JSONFormsRuleEffect;
152+
condition: JSONFormsSchemaBasedCondition;
153+
}
154+
128155
export interface JSONFormsLayout extends JSONFormsUIElement {
129156
type: 'VerticalLayout' | 'HorizontalLayout' | 'Group' | 'Categorization';
130157
elements: JSONFormsUIElement[];
131158
label?: string;
159+
rule?: JSONFormsRule;
132160
}
133161

134162
export interface JSONFormsLabel extends JSONFormsUIElement {

src/v2/conditions.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import {
2+
V2Condition,
3+
V2ConditionOperator,
4+
JSONFormsRule,
5+
JSONFormsSchemaBasedCondition,
6+
} from '../common/types';
7+
8+
/**
9+
* Builds a JSON Schema for the CONTAINS operator.
10+
* Matches strings that contain the specified value as a substring.
11+
*/
12+
export const buildContainsSchema = (value: string): Record<string, unknown> => {
13+
return {
14+
pattern: value,
15+
type: 'string',
16+
};
17+
};
18+
19+
/**
20+
* Builds a JSON Schema for the DOES_NOT_HAVE_INPUT operator.
21+
*/
22+
export const buildDoesNotHaveInputSchema = (): Record<string, unknown> => {
23+
return {
24+
not: buildHasInputSchema(),
25+
};
26+
};
27+
28+
/**
29+
* Builds a JSON Schema for the HAS_INPUT operator.
30+
* Matches when the field has meaningful input (non-null, non-empty).
31+
*/
32+
export const buildHasInputSchema = (): Record<string, unknown> => {
33+
return {
34+
allOf: [
35+
{ not: { type: 'null' } },
36+
{
37+
anyOf: [
38+
{ type: 'array', minItems: 1 },
39+
{ type: 'boolean' },
40+
{ type: 'number' },
41+
{ type: 'object', minProperties: 1 },
42+
{ type: 'string', minLength: 1 },
43+
],
44+
},
45+
],
46+
};
47+
};
48+
49+
/**
50+
* Builds a JSON Schema for the INPUT_IS_EXACTLY operator.
51+
* Matches when the field value equals the specified value exactly.
52+
* Uses `enum` for simple matching without type constraints.
53+
*/
54+
export const buildInputIsExactlySchema = (
55+
value: string | number | boolean | null,
56+
): Record<string, unknown> => {
57+
if (value === null) {
58+
return { type: 'null' };
59+
}
60+
if (typeof value === 'boolean') {
61+
return { const: value };
62+
}
63+
if (typeof value === 'number') {
64+
// Allow both number and string representation
65+
return { enum: [value, String(value)] };
66+
}
67+
// String value - also allow numeric match if the string is a valid number
68+
const numValue = Number(value);
69+
if (!isNaN(numValue) && value !== '') {
70+
return { enum: [numValue, value] };
71+
}
72+
return { const: value };
73+
};
74+
75+
const VALID_OPERATORS: V2ConditionOperator[] = [
76+
'CONTAINS',
77+
'DOES_NOT_HAVE_INPUT',
78+
'HAS_INPUT',
79+
'INPUT_IS_EXACTLY',
80+
];
81+
82+
/**
83+
* Gets the operator schema for a condition (without field wrapper).
84+
* Used internally by buildConditionSchema and buildSchemaBasedCondition.
85+
*/
86+
export const getOperatorSchema = (
87+
condition: V2Condition,
88+
): Record<string, unknown> => {
89+
switch (condition.operator) {
90+
case 'CONTAINS':
91+
if (condition.value === undefined || condition.value === null) {
92+
throw new Error(
93+
`CONTAINS operator requires a value for condition '${condition.id}'`,
94+
);
95+
}
96+
return buildContainsSchema(String(condition.value));
97+
98+
case 'DOES_NOT_HAVE_INPUT':
99+
return buildDoesNotHaveInputSchema();
100+
101+
case 'HAS_INPUT':
102+
return buildHasInputSchema();
103+
104+
case 'INPUT_IS_EXACTLY':
105+
if (condition.value === undefined) {
106+
throw new Error(
107+
`INPUT_IS_EXACTLY operator requires a value for condition '${condition.id}'`,
108+
);
109+
}
110+
return buildInputIsExactlySchema(condition.value);
111+
112+
default:
113+
throw new Error(
114+
`Unknown operator '${condition.operator}' in condition '${condition.id}'`,
115+
);
116+
}
117+
};
118+
119+
/**
120+
* Builds a JSON Schema for a single condition based on its operator.
121+
* Wraps the operator schema in a properties structure for root scope validation.
122+
*/
123+
export const buildConditionSchema = (
124+
condition: V2Condition,
125+
): Record<string, unknown> => {
126+
const operatorSchema = getOperatorSchema(condition);
127+
128+
// Wrap in properties structure for the field (used with scope: "#")
129+
return {
130+
properties: {
131+
[condition.field]: operatorSchema,
132+
},
133+
required: [condition.field],
134+
};
135+
};
136+
137+
/**
138+
* Builds a JSONForms schema-based condition from one or more V2 conditions.
139+
*
140+
* For single conditions: Uses field-specific scope (e.g., "#/properties/fieldName")
141+
* which is the standard JSONForms pattern and most reliable across implementations.
142+
*
143+
* For multiple conditions: Uses root scope "#" with allOf combining property schemas.
144+
*/
145+
export const buildSchemaBasedCondition = (
146+
conditions: V2Condition[],
147+
): JSONFormsSchemaBasedCondition => {
148+
if (conditions.length === 0) {
149+
throw new Error('At least one condition is required');
150+
}
151+
152+
if (conditions.length === 1) {
153+
// Single condition: use field-specific scope for better compatibility
154+
const condition = conditions[0];
155+
return {
156+
scope: `#/properties/${condition.field}`,
157+
schema: getOperatorSchema(condition),
158+
};
159+
}
160+
161+
// Multiple conditions: use root scope with allOf
162+
const allOfSchemas = conditions.map((condition) =>
163+
buildConditionSchema(condition),
164+
);
165+
166+
return {
167+
scope: '#',
168+
schema: {
169+
allOf: allOfSchemas,
170+
},
171+
};
172+
};
173+
174+
/**
175+
* Creates a complete JSONForms rule for a section with the given conditions.
176+
* Uses SHOW effect by default.
177+
*/
178+
export const createSectionRule = (conditions: V2Condition[]): JSONFormsRule => {
179+
return {
180+
effect: 'SHOW',
181+
condition: buildSchemaBasedCondition(conditions),
182+
};
183+
};
184+
185+
/**
186+
* Validates conditions for a section.
187+
* Throws an error if any condition is invalid.
188+
*/
189+
export const validateConditions = (
190+
conditions: V2Condition[],
191+
fieldNames: string[],
192+
): void => {
193+
for (const condition of conditions) {
194+
// Validate operator
195+
if (!VALID_OPERATORS.includes(condition.operator)) {
196+
throw new Error(`Invalid operator '${condition.operator}'`);
197+
}
198+
199+
// Validate field exists
200+
if (!fieldNames.includes(condition.field)) {
201+
throw new Error(`Unknown field '${condition.field}'`);
202+
}
203+
204+
// Validate required values
205+
if (
206+
condition.operator === 'CONTAINS' &&
207+
(condition.value === undefined || condition.value === null)
208+
) {
209+
throw new Error(`CONTAINS operator requires a value`);
210+
}
211+
212+
if (
213+
condition.operator === 'INPUT_IS_EXACTLY' &&
214+
condition.value === undefined
215+
) {
216+
throw new Error(`INPUT_IS_EXACTLY operator requires a value`);
217+
}
218+
}
219+
};

src/v2/generateUISchema.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import {
2-
V2Schema,
3-
JSONFormsUISchema,
1+
import {
2+
V2Schema,
3+
JSONFormsUISchema,
44
JSONFormsLayout
55
} from '../common/types';
66

77
import {
88
createControl,
99
createSectionLayout,
10+
extractConditionalProperties,
11+
extractConditionalRequired,
1012
getVisibleFields,
1113
groupFieldsBySection
1214
} from './utils';
1315

16+
import { validateConditions } from './conditions';
17+
1418
/**
1519
* Validates V2 schema structure for known issues
1620
*/
@@ -60,6 +64,23 @@ const validateV2Schema = (schema: V2Schema): void => {
6064
}
6165
});
6266

67+
// Validate section conditions
68+
const conditionalProperties = extractConditionalProperties(schema);
69+
const fieldNames = [
70+
...Object.keys(schema.json.properties),
71+
...Object.keys(conditionalProperties),
72+
];
73+
74+
Object.entries(schema.ui.sections).forEach(([sectionId, section]) => {
75+
if (section.conditions && section.conditions.length > 0) {
76+
try {
77+
validateConditions(section.conditions, fieldNames);
78+
} catch (error) {
79+
invalidFields.push(`Section '${sectionId}': ${(error as Error).message}`);
80+
}
81+
}
82+
});
83+
6384
if (invalidFields.length > 0) {
6485
throw new Error(`Invalid V2 schema structure: ${invalidFields.join(', ')}`);
6586
}
@@ -83,26 +104,29 @@ export const generateUISchema = (schema: V2Schema): JSONFormsUISchema => {
83104

84105
// Get all visible (non-deprecated) fields
85106
const visibleFields = getVisibleFields(schema);
86-
107+
87108
// Group fields by their parent sections
88109
const fieldsBySection = groupFieldsBySection(visibleFields, schema.ui.sections);
89110

111+
// Get conditionally required fields
112+
const conditionallyRequiredFields = extractConditionalRequired(schema);
113+
90114
// Create section layouts in the specified order
91115
const sectionLayouts: JSONFormsLayout[] = [];
92116

93117
schema.ui.order.forEach(sectionId => {
94118
const section = schema.ui.sections[sectionId];
95119
const sectionFields = fieldsBySection[sectionId] || [];
96-
120+
97121
// Check if section has any content (fields or headers)
98122
const hasFields = sectionFields.length > 0;
99123
const hasHeaders = [...section.leftColumn, ...section.rightColumn]
100124
.some(item => item.type === 'header' && schema.ui.headers[item.name]);
101-
125+
102126
// Only create layout if section is active and has content
103127
if (section?.isActive && (hasFields || hasHeaders)) {
104128
const sectionControls = sectionFields.map(({ name, property, uiField }) =>
105-
createControl(name, property, uiField, schema)
129+
createControl(name, property, uiField, schema, conditionallyRequiredFields.has(name))
106130
);
107131

108132
const sectionLayout = createSectionLayout(sectionId, section, sectionControls, schema.ui.headers);

0 commit comments

Comments
 (0)