From 9cd745b69ec4c3f319f7f384336d96de0822c29c Mon Sep 17 00:00:00 2001 From: Dariy Miseldzhani Date: Thu, 11 Jun 2026 20:23:42 +0200 Subject: [PATCH 01/18] fix(schemas): format conditions when switching from schema editor UI to JSON tab Signed-off-by: Dariy Miseldzhani --- .../schema-configuration.component.ts | 21 +++++++++++++------ interfaces/src/helpers/schema-json.ts | 2 +- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.ts b/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.ts index 4437fbfa70..3541cbf5c7 100644 --- a/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.ts +++ b/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.ts @@ -948,6 +948,19 @@ export class SchemaConfigurationComponent implements OnInit { } } + const fieldsBySchemaName = new Map(fields.map(f => [f.name, f])); + + const getPickedName = (r: any): string | undefined => { + if (Array.isArray(r?.field?.fieldPath) && r.field.fieldPath.length > 0) { + return r.field.fieldPath[0]; + } + if (Array.isArray(r?.fieldPath) && r.fieldPath.length > 0) { + return r.fieldPath[0]; + } + return r?.field?.key || r?.field?.controlKey?.value || + (typeof r?.field === 'string' ? r.field : undefined); + }; + const conditions: SchemaCondition[] = []; for (const element of this.conditions) { const conditionValue = value.conditions[element.name]; @@ -977,14 +990,10 @@ export class SchemaConfigurationComponent implements OnInit { continue; } - const getPickedName = (r: any): string | undefined => { - return r?.field?.name || r?.field?.key || r?.field?.controlKey?.value || r?.field; - }; - if (op === 'SINGLE') { const row = rows[0]; const name = getPickedName(row); - const sf = name ? allFieldsByName.get(name) : undefined; + const sf = name ? fieldsBySchemaName.get(name) : undefined; if (!sf) { continue; } @@ -1001,7 +1010,7 @@ export class SchemaConfigurationComponent implements OnInit { const arr = rows .map(r => { const name = getPickedName(r); - const sf = name ? allFieldsByName.get(name) : undefined; + const sf = name ? fieldsBySchemaName.get(name) : undefined; if (!sf) { return null; } diff --git a/interfaces/src/helpers/schema-json.ts b/interfaces/src/helpers/schema-json.ts index b0d8ce34bd..edc2fe2ced 100644 --- a/interfaces/src/helpers/schema-json.ts +++ b/interfaces/src/helpers/schema-json.ts @@ -378,7 +378,7 @@ export class SchemaToJson { } json.if.AND = ic.AND .filter((p: any) => p?.field?.name !== undefined) - .map((p: any) => ({ field: p.field.name, value: p.fieldValue })); + .map((p: any) => ({ field: p.field.name, fieldValue: p.fieldValue })); return json; } From 97a05bce2f019948812ab1d803551d9ce34dd652 Mon Sep 17 00:00:00 2001 From: Dariy Miseldzhani Date: Fri, 12 Jun 2026 22:48:10 +0200 Subject: [PATCH 02/18] feat(interfaces): add fieldPath and cross-schema targets to condition types Signed-off-by: Dariy Miseldzhani --- interfaces/src/helpers/schema-json.ts | 137 +++++++++++++----- .../interface/schema-condition.interface.ts | 18 ++- 2 files changed, 117 insertions(+), 38 deletions(-) diff --git a/interfaces/src/helpers/schema-json.ts b/interfaces/src/helpers/schema-json.ts index edc2fe2ced..b7aa92eea8 100644 --- a/interfaces/src/helpers/schema-json.ts +++ b/interfaces/src/helpers/schema-json.ts @@ -60,17 +60,25 @@ export interface IFieldJson { export interface IIfRuleJson { field: string; fieldValue: any; + fieldPath?: string[]; +} + +export interface IConditionTargetJson { + fieldPath: string[]; } export interface IConditionJson { if: { field?: string; fieldValue?: any; + fieldPath?: string[]; AND?: IIfRuleJson[]; OR?: IIfRuleJson[]; }, then: IFieldJson[], - else: IFieldJson[] + else: IFieldJson[], + thenTargets?: IConditionTargetJson[], + elseTargets?: IConditionTargetJson[], } export interface ISchemaJson { @@ -368,17 +376,31 @@ export class SchemaToJson { } } + if (condition.thenTargets?.length) { + json.thenTargets = condition.thenTargets.map(t => ({ fieldPath: t.fieldPath })); + } + if (condition.elseTargets?.length) { + json.elseTargets = condition.elseTargets.map(t => ({ fieldPath: t.fieldPath })); + } + const ic: any = condition.ifCondition; + const serializePredicate = (p: any): IIfRuleJson => { + const r: IIfRuleJson = { field: p.field.name, fieldValue: p.fieldValue }; + if (p.fieldPath?.length > 1) { r.fieldPath = p.fieldPath; } + return r; + }; + if (ic?.AND && Array.isArray(ic.AND)) { if (ic.AND.length === 1) { json.if.field = ic.AND[0]?.field?.name; json.if.fieldValue = ic.AND[0]?.fieldValue; + if (ic.AND[0]?.fieldPath?.length > 1) { json.if.fieldPath = ic.AND[0].fieldPath; } return json; } json.if.AND = ic.AND .filter((p: any) => p?.field?.name !== undefined) - .map((p: any) => ({ field: p.field.name, fieldValue: p.fieldValue })); + .map(serializePredicate); return json; } @@ -386,31 +408,34 @@ export class SchemaToJson { if (ic.OR.length === 1) { json.if.field = ic.OR[0]?.field?.name; json.if.fieldValue = ic.OR[0]?.fieldValue; + if (ic.OR[0]?.fieldPath?.length > 1) { json.if.fieldPath = ic.OR[0].fieldPath; } return json; } json.if.OR = ic.OR .filter((p: any) => p?.field?.name !== undefined) - .map((p: any) => ({ field: p.field.name, fieldValue: p.fieldValue })); + .map(serializePredicate); return json; } if (ic?.field?.name !== undefined) { json.if.field = ic.field.name; json.if.fieldValue = ic.fieldValue; + if (ic.fieldPath?.length > 1) { json.if.fieldPath = ic.fieldPath; } return json; } if (Array.isArray(ic?.predicates) && ic.predicates.length) { if (ic.predicates.length === 1) { json.if.field = ic.predicates[0].field?.name; json.if.fieldValue = ic.predicates[0].fieldValue; + if (ic.predicates[0]?.fieldPath?.length > 1) { json.if.fieldPath = ic.predicates[0].fieldPath; } } else if (ic.op === 'ANY_OF') { json.if.OR = ic.predicates .filter((p: any) => p?.field?.name !== undefined) - .map((p: any) => ({ field: p.field.name, fieldValue: p.fieldValue })); + .map(serializePredicate); } else { json.if.AND = ic.predicates .filter((p: any) => p?.field?.name !== undefined) - .map((p: any) => ({ field: p.field.name, fieldValue: p.fieldValue })); + .map(serializePredicate); } return json; } @@ -1137,6 +1162,28 @@ export class JsonToSchema { return target; } + private static resolveFieldByPath( + path: string[], + fields: SchemaField[], + context: ErrorContext + ): SchemaField { + let current = fields; + let field: SchemaField | undefined; + for (let i = 0; i < path.length; i++) { + field = current.find(f => f.name === path[i]); + if (!field) { + throw JsonToSchema.createErrorWithValue( + context.setMessage(JsonError.INVALID_FORMAT, JsonErrorMessage.REF), + path[i] + ); + } + if (i < path.length - 1) { + current = field.fields || []; + } + } + return field!; + } + private static fromCondIf( value: IConditionJson, fields: SchemaField[], @@ -1144,6 +1191,23 @@ export class JsonToSchema { ): SchemaCondition['ifCondition'] { const ifCtx = context.add('if'); + const resolvePredicateField = ( + fieldName: string | undefined, + fp: string[] | undefined, + ctx: ErrorContext + ): SchemaField => fp?.length > 1 + ? JsonToSchema.resolveFieldByPath(fp, fields, ctx) + : JsonToSchema.resolveFieldByName(fieldName, fields, ctx); + + const buildPredicate = (p: IIfRuleJson, ctx: ErrorContext): any => { + const pred: any = { + field: resolvePredicateField(p.field, p.fieldPath, ctx), + fieldValue: p.fieldValue, + }; + if (p.fieldPath?.length > 1) { pred.fieldPath = p.fieldPath; } + return pred; + }; + if (Array.isArray(value?.if?.AND)) { const andArr = value.if.AND; if (andArr.length === 0) { @@ -1152,18 +1216,10 @@ export class JsonToSchema { ); } if (andArr.length === 1) { - const p = andArr[0]; - const field = JsonToSchema.resolveFieldByName(p?.field, fields, ifCtx.add('AND').add('[0]').add('field')); - return { - field, - fieldValue: p.fieldValue - } as any; + return buildPredicate(andArr[0], ifCtx.add('AND').add('[0]')) as any; } return { - AND: andArr.map((p, idx) => ({ - field: JsonToSchema.resolveFieldByName(p?.field, fields, ifCtx.add('AND').add(`[${idx}]`).add('field')), - fieldValue: p?.fieldValue - })) + AND: andArr.map((p, idx) => buildPredicate(p, ifCtx.add('AND').add(`[${idx}]`))) } as any; } @@ -1175,28 +1231,20 @@ export class JsonToSchema { ); } if (orArr.length === 1) { - const p = orArr[0]; - const field = JsonToSchema.resolveFieldByName(p?.field, fields, ifCtx.add('OR').add('[0]').add('field')); - return { - field, - fieldValue: p.fieldValue - } as any; + return buildPredicate(orArr[0], ifCtx.add('OR').add('[0]')) as any; } return { - OR: orArr.map((p, idx) => ({ - field: JsonToSchema.resolveFieldByName(p?.field, fields, ifCtx.add('OR').add(`[${idx}]`).add('field')), - fieldValue: p?.fieldValue - })) + OR: orArr.map((p, idx) => buildPredicate(p, ifCtx.add('OR').add(`[${idx}]`))) } as any; } + const fp = value?.if?.fieldPath; const fieldName = value?.if?.field; const val = value?.if?.fieldValue; - const target = JsonToSchema.resolveFieldByName(fieldName, fields, ifCtx.add('field')); - return { - field: target, - fieldValue: val - } as any; + const target = resolvePredicateField(fieldName, fp, ifCtx.add('field')); + const single: any = { field: target, fieldValue: val }; + if (fp?.length > 1) { single.fieldPath = fp; } + return single as any; } private static fromCondFields( @@ -1239,11 +1287,6 @@ export class JsonToSchema { } else { elseFields = []; } - if (thenFields.length === 0 && elseFields.length === 0) { - throw JsonToSchema.createError( - context.setMessage(JsonError.THEN_ELSE) - ); - } return { then: thenFields, else: elseFields @@ -1261,10 +1304,32 @@ export class JsonToSchema { context = context.add(`[${index}]`); const ifCondition = JsonToSchema.fromCondIf(value, fields, context); const { then, else: _else } = JsonToSchema.fromCondFields(value, all, entity, new Set(), context); + + const thenTargets = (value.thenTargets || []) + .filter(t => Array.isArray(t.fieldPath) && t.fieldPath.length >= 2) + .map(t => ({ + fieldPath: t.fieldPath, + field: JsonToSchema.resolveFieldByPath(t.fieldPath, fields, context.add('thenTargets')), + })); + const elseTargets = (value.elseTargets || []) + .filter(t => Array.isArray(t.fieldPath) && t.fieldPath.length >= 2) + .map(t => ({ + fieldPath: t.fieldPath, + field: JsonToSchema.resolveFieldByPath(t.fieldPath, fields, context.add('elseTargets')), + })); + + if (then.length === 0 && _else.length === 0 && thenTargets.length === 0 && elseTargets.length === 0) { + throw JsonToSchema.createError( + context.setMessage(JsonError.THEN_ELSE) + ); + } + const condition: SchemaCondition = { ifCondition, thenFields: then, - elseFields: _else + elseFields: _else, + thenTargets: thenTargets.length ? thenTargets : undefined, + elseTargets: elseTargets.length ? elseTargets : undefined, } as any; return condition; } diff --git a/interfaces/src/interface/schema-condition.interface.ts b/interfaces/src/interface/schema-condition.interface.ts index 1c937f6fbd..dc99784e56 100644 --- a/interfaces/src/interface/schema-condition.interface.ts +++ b/interfaces/src/interface/schema-condition.interface.ts @@ -1,20 +1,34 @@ import { SchemaField } from './schema-field.interface.js'; +/** + * A cross-schema condition target: a field that lives inside a nested sub-schema + * and is controlled by a parent schema's condition (not defined at the parent level). + * fieldPath is the full path from the parent schema root to the leaf field, + * e.g. ['fieldC_ref', 'NumberC']. + */ +export interface SchemaConditionTarget { + field: SchemaField; + fieldPath: string[]; +} + /** * Schema condition */ export interface SchemaCondition { ifCondition: ( - { field: SchemaField; fieldValue: any } | + SchemaFieldPredicate | { AND: SchemaFieldPredicate[] } | { OR: SchemaFieldPredicate[] } ); thenFields: SchemaField[]; elseFields: SchemaField[]; + thenTargets?: SchemaConditionTarget[]; + elseTargets?: SchemaConditionTarget[]; errors?: any[]; } export interface SchemaFieldPredicate { field: SchemaField; fieldValue: any; -} \ No newline at end of file + fieldPath?: string[]; +} From c3dc94fd875fcdc0e20079c87241dfd55ceef5ea Mon Sep 17 00:00:00 2001 From: Dariy Miseldzhani Date: Fri, 12 Jun 2026 22:50:12 +0200 Subject: [PATCH 03/18] feat(schema): support nested fieldPath and cross-schema condition targets Signed-off-by: Dariy Miseldzhani --- interfaces/src/helpers/schema-helper.ts | 279 ++++++++++++++++++++++-- 1 file changed, 264 insertions(+), 15 deletions(-) diff --git a/interfaces/src/helpers/schema-helper.ts b/interfaces/src/helpers/schema-helper.ts index 88ad2b4e54..5459950c17 100644 --- a/interfaces/src/helpers/schema-helper.ts +++ b/interfaces/src/helpers/schema-helper.ts @@ -276,14 +276,33 @@ export class SchemaHelper { const buildFields = (node: any) => SchemaHelper.parseFields(node, context, schemaCache, document.$defs || defs) as SchemaField[]; - const predicatesFromProperties = (props: any): SchemaFieldPredicate[] => { + const predicatesFromProperties = ( + props: any, + currentFields: SchemaField[] = fields, + pathSoFar: string[] = [] + ): SchemaFieldPredicate[] => { const preds: SchemaFieldPredicate[] = []; for (const key of Object.keys(props || {})) { const rule = props[key]; - if (rule && Object.prototype.hasOwnProperty.call(rule, 'const')) { - const f = fields.find(x => x.name === key); + if (!rule) { continue; } + if (Object.prototype.hasOwnProperty.call(rule, 'const')) { + const f = currentFields.find(x => x.name === key); if (f) { - preds.push({ field: f, fieldValue: rule.const }); + const fullPath = [...pathSoFar, key]; + preds.push({ + field: f, + fieldValue: rule.const, + fieldPath: fullPath.length > 1 ? fullPath : undefined, + }); + } + } else if (rule.properties) { + const refField = currentFields.find(x => x.name === key && x.isRef); + if (refField && (refField as any).fields?.length) { + preds.push(...predicatesFromProperties( + rule.properties, + (refField as any).fields, + [...pathSoFar, key] + )); } } } @@ -335,6 +354,102 @@ export class SchemaHelper { return null; }; + const extractCrossFromLevel = ( + node: any, + refFieldsAtLevel: SchemaField[], + pathPrefix: string[], + ): { + thenTargets: Array<{ field: SchemaField; fieldPath: string[] }>; + elseTargets: Array<{ field: SchemaField; fieldPath: string[] }>; + cleanProps: any; + hasCrossKeys: boolean; + } => { + const thenTargets: Array<{ field: SchemaField; fieldPath: string[] }> = []; + const elseTargets: Array<{ field: SchemaField; fieldPath: string[] }> = []; + const cleanProps: any = {}; + let hasCrossKeys = false; + + if (!node?.properties) { + return { thenTargets, elseTargets, cleanProps: node?.properties, hasCrossKeys }; + } + + for (const key of Object.keys(node.properties)) { + const val = node.properties[key]; + const refField = refFieldsAtLevel.find(f => f.name === key && f.isRef); + const isCrossConstraint = refField && val && !val.$comment && !val.type && !val.$ref; + if (isCrossConstraint) { + hasCrossKeys = true; + const childPath = [...pathPrefix, key]; + const childFields: SchemaField[] = (refField as any).fields || []; + // Direct required targets (leaf one level inside this ref) + if (Array.isArray(val.required)) { + for (const fieldName of val.required) { + const childField = childFields.find(f => f.name === fieldName); + if (childField) { + thenTargets.push({ field: childField, fieldPath: [...childPath, fieldName] }); + } + } + } + // Properties: either forbidden (=== false) or deeper nesting (recurse) + if (val.properties) { + for (const fieldName of Object.keys(val.properties)) { + const subVal = val.properties[fieldName]; + if (subVal === false) { + const childField = childFields.find(f => f.name === fieldName); + if (childField) { + elseTargets.push({ field: childField, fieldPath: [...childPath, fieldName] }); + } + } else { + // Recurse if this is another ref field at the child level + const subRefField = childFields.find(f => f.name === fieldName && f.isRef); + if (subRefField) { + const subResult = extractCrossFromLevel( + { properties: { [fieldName]: subVal } }, + childFields, + childPath, + ); + thenTargets.push(...subResult.thenTargets); + elseTargets.push(...subResult.elseTargets); + } + } + } + } + } else { + cleanProps[key] = val; + } + } + return { thenTargets, elseTargets, cleanProps, hasCrossKeys }; + }; + + const extractCrossTargets = (node: any): { + thenTargets: Array<{ field: SchemaField; fieldPath: string[] }>; + elseTargets: Array<{ field: SchemaField; fieldPath: string[] }>; + cleanNode: any; + } => { + if (!node?.properties) { + return { thenTargets: [], elseTargets: [], cleanNode: node }; + } + const result = extractCrossFromLevel(node, fields, []); + if (!result.hasCrossKeys) { + return { thenTargets: [], elseTargets: [], cleanNode: node }; + } + const cleanNode: any = { ...node }; + if (result.cleanProps && Object.keys(result.cleanProps).length) { + cleanNode.properties = result.cleanProps; + if (Array.isArray(node.required)) { + cleanNode.required = node.required.filter((n: string) => n in result.cleanProps); + } + } else { + delete cleanNode.properties; + delete cleanNode.required; + } + return { + thenTargets: result.thenTargets, + elseTargets: result.elseTargets, + cleanNode: Object.keys(cleanNode).length ? cleanNode : null, + }; + }; + const parseArray = (arr: any[]): SchemaCondition[] => { const out: SchemaCondition[] = []; for (const n of arr || []) { @@ -342,9 +457,14 @@ export class SchemaHelper { continue; } const ifCondition = toIfCondition(n.if); - const thenFields = buildFields(n.then); - const elseFields = buildFields(n.else); - out.push({ ifCondition, thenFields, elseFields }); + const { thenTargets, elseTargets, cleanNode: cleanThen } = extractCrossTargets(n.then); + const { cleanNode: cleanElse } = extractCrossTargets(n.else); + const thenFields = buildFields(cleanThen); + const elseFields = buildFields(cleanElse); + const condition: any = { ifCondition, thenFields, elseFields }; + if (thenTargets.length) { condition.thenTargets = thenTargets; } + if (elseTargets.length) { condition.elseTargets = elseTargets; } + out.push(condition as SchemaCondition); } return out; }; @@ -520,9 +640,18 @@ export class SchemaHelper { } const single = (p: SchemaFieldPredicate | { field: SchemaField; fieldValue: any }) => { - return { - properties: { [p.field.name]: { const: p.fieldValue } } + const path = ('fieldPath' in p && p.fieldPath && p.fieldPath.length > 1) + ? p.fieldPath + : [p.field.name]; + let node: any = { const: p.fieldValue }; + for (let i = path.length - 1; i >= 0; i--) { + const wrapper: any = { properties: { [path[i]]: node } }; + if (path.length > 1) { + wrapper.required = [path[i]]; + } + node = wrapper; } + return node; }; if ('field' in ic && 'fieldValue' in ic) { @@ -556,6 +685,67 @@ export class SchemaHelper { return null; }; + const deepMergeSchemaObj = (a: any, b: any): any => { + if (!a) { return b; } + if (!b) { return a; } + const result: any = { ...a }; + for (const key of Object.keys(b)) { + if (key === 'properties') { + result.properties = { ...(a.properties || {}) }; + for (const pk of Object.keys(b.properties)) { + result.properties[pk] = (a.properties?.[pk] !== undefined) + ? deepMergeSchemaObj(a.properties[pk], b.properties[pk]) + : b.properties[pk]; + } + } else if (key === 'required') { + result.required = [...new Set([...(a.required || []), ...(b.required || [])])]; + } else { + result[key] = b[key]; + } + } + return result; + }; + + // Build a nested JSON Schema object that requires the leaf field at each target's path. + const buildCrossRequired = (targets?: { fieldPath: string[] }[]): any | undefined => { + if (!targets?.length) { return undefined; } + const root: any = {}; + for (const t of targets) { + const path = t.fieldPath; + if (!path || path.length < 2) { continue; } + let node = root; + for (let i = 0; i < path.length - 1; i++) { + if (!node.properties) { node.properties = {}; } + if (!node.properties[path[i]]) { node.properties[path[i]] = {}; } + node = node.properties[path[i]]; + } + const fieldName = path[path.length - 1]; + if (!node.required) { node.required = []; } + if (!node.required.includes(fieldName)) { node.required.push(fieldName); } + } + return Object.keys(root).length ? root : undefined; + }; + + // Build a nested JSON Schema object that forbids the leaf field (property: false). + const buildCrossForbidden = (targets?: { fieldPath: string[] }[]): any | undefined => { + if (!targets?.length) { return undefined; } + const root: any = {}; + for (const t of targets) { + const path = t.fieldPath; + if (!path || path.length < 2) { continue; } + let node = root; + for (let i = 0; i < path.length - 1; i++) { + if (!node.properties) { node.properties = {}; } + if (!node.properties[path[i]]) { node.properties[path[i]] = {}; } + node = node.properties[path[i]]; + } + const fieldName = path[path.length - 1]; + if (!node.properties) { node.properties = {}; } + node.properties[fieldName] = false; + } + return Object.keys(root).length ? root : undefined; + }; + const serializeCondition = (cond: SchemaCondition) => { const ifNode = serializeIf(cond); if (!ifNode) { @@ -569,8 +759,14 @@ export class SchemaHelper { return Object.keys(props).length ? { properties: props, required: req } : undefined; }; - const thenObj = buildSub(cond.thenFields); - const elseObj = buildSub(cond.elseFields); + const thenObj = deepMergeSchemaObj( + deepMergeSchemaObj(buildSub(cond.thenFields), buildCrossRequired(cond.thenTargets)), + buildCrossForbidden(cond.elseTargets) + ); + const elseObj = deepMergeSchemaObj( + deepMergeSchemaObj(buildSub(cond.elseFields), buildCrossRequired(cond.elseTargets)), + buildCrossForbidden(cond.thenTargets) + ); const obj: any = { if: ifNode }; if (thenObj) { @@ -594,6 +790,15 @@ export class SchemaHelper { SchemaHelper.getFieldsFromObject(fields, document.required, document.properties, schema.contextURL); + const conditionFieldNames = new Set(); + for (const cond of (conditions || [])) { + for (const f of (cond.thenFields || [])) { conditionFieldNames.add(f.name); } + for (const f of (cond.elseFields || [])) { conditionFieldNames.add(f.name); } + } + if (conditionFieldNames.size && Array.isArray(document.required)) { + document.required = document.required.filter((name: string) => !conditionFieldNames.has(name)); + } + return document; } @@ -857,7 +1062,7 @@ export class SchemaHelper { */ public static findRefs(target: Schema, schemas: Schema[]) { const map = {}; - const schemaMap = { + const schemaMap: Record = { '#GeoJSON': geoJson, '#SentinelHUB': SentinelHubSchema }; @@ -869,25 +1074,69 @@ export class SchemaHelper { map[field.type] = schemaMap[field.type]; } } - return SchemaHelper.uniqueRefs(map, {}); + + const patchMap = SchemaHelper.buildCrossTargetPatchMap(target, schemaMap); + return SchemaHelper.uniqueRefs(map, {}, patchMap); + } + + /** + * Build a map from sub-schema IRI → set of field names to strip from required, + * derived from cross-schema condition targets on the given schema. + */ + private static buildCrossTargetPatchMap( + target: Schema, + schemaMap: Record + ): Map> { + const patchMap = new Map>(); + const fieldNameToIRI = new Map(); + for (const field of target.fields) { + if (field.isRef && field.type) { + fieldNameToIRI.set(field.name, field.type); + } + } + for (const condition of (target.conditions || [])) { + const allTargets = [ + ...(condition.thenTargets || []), + ...(condition.elseTargets || []), + ]; + for (const t of allTargets) { + const path = t.fieldPath; + if (!path || path.length < 2) { continue; } + // Walk the path to find the IRI of the schema that owns the leaf field. + let iri: string | undefined = fieldNameToIRI.get(path[0]); + for (let i = 1; i < path.length - 1 && iri; i++) { + iri = schemaMap[iri]?.properties?.[path[i]]?.['$ref']; + } + if (!iri) { continue; } + const leaf = path[path.length - 1]; + if (!patchMap.has(iri)) { patchMap.set(iri, new Set()); } + patchMap.get(iri)!.add(leaf); + } + } + return patchMap; } /** * Get unique refs * @param map * @param newMap + * @param patchMap fields to strip from required per schema IRI * @private */ - private static uniqueRefs(map: any, newMap: any) { + private static uniqueRefs(map: any, newMap: any, patchMap?: Map>) { const keys = Object.keys(map); for (const iri of keys) { if (!newMap[iri]) { const oldSchema = map[iri]; const newSchema = { ...oldSchema }; delete newSchema.$defs; + const toRemove = patchMap?.get(iri); + if (toRemove?.size && Array.isArray(newSchema.required)) { + newSchema.required = newSchema.required.filter((r: string) => !toRemove.has(r)); + } newMap[iri] = newSchema; if (oldSchema.$defs) { - SchemaHelper.uniqueRefs(oldSchema.$defs, newMap); + SchemaHelper.uniqueRefs(oldSchema.$defs, newMap, patchMap); } } } From ae53a5395935d20b669c83719198ca6ad400805d Mon Sep 17 00:00:00 2001 From: Dariy Miseldzhani Date: Fri, 12 Jun 2026 23:03:44 +0200 Subject: [PATCH 04/18] feat(ui): add cross-schema condition editor to schema configuration Signed-off-by: Dariy Miseldzhani --- .../schema-engine/condition-control.ts | 83 +++- .../schema-configuration.component.html | 93 ++++- .../schema-configuration.component.scss | 74 +++- .../schema-configuration.component.ts | 366 +++++++++++++++--- .../schema-form-model/field-form.ts | 222 +++++++++-- .../schema-form-view.component.ts | 45 ++- 6 files changed, 755 insertions(+), 128 deletions(-) diff --git a/frontend/src/app/modules/schema-engine/condition-control.ts b/frontend/src/app/modules/schema-engine/condition-control.ts index e7dbcf6f6c..7f901fd262 100644 --- a/frontend/src/app/modules/schema-engine/condition-control.ts +++ b/frontend/src/app/modules/schema-engine/condition-control.ts @@ -1,4 +1,3 @@ -// condition-control.ts import { UntypedFormArray, UntypedFormControl, @@ -12,6 +11,21 @@ import { FieldControl } from './field-control'; export type IfOperator = 'SINGLE' | 'AND' | 'OR'; +export interface ConditionFieldOption { + key: string; + label: string; + shortLabel?: string; + fieldPath: string[]; + typeKey: string; + required: boolean; + fieldControl?: FieldControl; +} + +export interface ConditionFieldGroup { + label: string; + items: ConditionFieldOption[]; +} + export class ConditionControl { public readonly name: string; @@ -20,6 +34,11 @@ export class ConditionControl { public readonly thenFieldControls: UntypedFormGroup; public readonly elseFieldControls: UntypedFormGroup; + public crossThenTargets: ConditionFieldOption[] = []; + public crossElseTargets: ConditionFieldOption[] = []; + public readonly crossThenCount: UntypedFormControl; + public readonly crossElseCount: UntypedFormControl; + public readonly conditions: UntypedFormArray; public readonly operator: UntypedFormControl; @@ -27,13 +46,15 @@ export class ConditionControl { public changeEvents: any[] | null = null; public fieldChange: Subscription | null = null; - constructor(field?: FieldControl, fieldValue: string = '', operator: IfOperator = 'SINGLE') { + constructor(field?: ConditionFieldOption, fieldValue: string = '', operator: IfOperator = 'SINGLE') { this.name = `condition${Date.now()}${Math.floor(Math.random() * 1000000)}`; this.thenControls = []; this.elseControls = []; this.thenFieldControls = new UntypedFormGroup({}); this.elseFieldControls = new UntypedFormGroup({}); + this.crossThenCount = new UntypedFormControl(0); + this.crossElseCount = new UntypedFormControl(0); this.operator = new UntypedFormControl(operator, Validators.required); this.conditions = new UntypedFormArray([], this.dynamicConditionsValidator()); @@ -45,7 +66,35 @@ export class ConditionControl { } } - public get fieldControl(): FieldControl | undefined { + public addCrossThenTarget(option: ConditionFieldOption): void { + const key = option.fieldPath.join('.'); + if (!this.crossThenTargets.find(t => t.fieldPath.join('.') === key)) { + this.crossThenTargets = [...this.crossThenTargets, option]; + this.crossThenCount.setValue(this.crossThenTargets.length); + } + } + + public removeCrossThenTarget(option: ConditionFieldOption): void { + const key = option.fieldPath.join('.'); + this.crossThenTargets = this.crossThenTargets.filter(t => t.fieldPath.join('.') !== key); + this.crossThenCount.setValue(this.crossThenTargets.length); + } + + public addCrossElseTarget(option: ConditionFieldOption): void { + const key = option.fieldPath.join('.'); + if (!this.crossElseTargets.find(t => t.fieldPath.join('.') === key)) { + this.crossElseTargets = [...this.crossElseTargets, option]; + this.crossElseCount.setValue(this.crossElseTargets.length); + } + } + + public removeCrossElseTarget(option: ConditionFieldOption): void { + const key = option.fieldPath.join('.'); + this.crossElseTargets = this.crossElseTargets.filter(t => t.fieldPath.join('.') !== key); + this.crossElseCount.setValue(this.crossElseTargets.length); + } + + public get fieldControl(): ConditionFieldOption | undefined { const g = this.conditions.at(0) as UntypedFormGroup; return g ? (g.get('field') as UntypedFormControl)?.value : undefined; } @@ -65,7 +114,9 @@ export class ConditionControl { conditions: this.conditions }), thenFieldControls: this.thenFieldControls, - elseFieldControls: this.elseFieldControls + elseFieldControls: this.elseFieldControls, + crossThenCount: this.crossThenCount, + crossElseCount: this.crossElseCount, }, this.countThenElseFieldsValidator()); } @@ -92,10 +143,11 @@ export class ConditionControl { type === 'then' ? this.removeThenControl(control) : this.removeElseControl(control); } - public addCondition(field?: FieldControl, fieldValue: string = '') { + public addCondition(option?: ConditionFieldOption, fieldValue: string = '') { const group = new UntypedFormGroup({ - field: new UntypedFormControl(field, Validators.required), - fieldValue: new UntypedFormControl(fieldValue, Validators.required) + field: new UntypedFormControl(option, Validators.required), + fieldValue: new UntypedFormControl(fieldValue, Validators.required), + fieldPath: new UntypedFormControl(option?.fieldPath ?? null), }); this.conditions.push(group); this.conditions.updateValueAndValidity(); @@ -122,16 +174,25 @@ export class ConditionControl { public normalizeByOperator() { const op = this.operator.value as IfOperator; if (op === 'SINGLE' && this.conditions.length > 1) { - while (this.conditions.length > 1) this.conditions.removeAt(this.conditions.length - 1); + while (this.conditions.length > 1) { + this.conditions.removeAt(this.conditions.length - 1); + } } this.conditions.updateValueAndValidity(); } private countThenElseFieldsValidator(): ValidatorFn { return (group: any): ValidationErrors | null => { - const thenFieldControls = group.controls['thenFieldControls'] as UntypedFormGroup; - const elseFieldControls = group.controls['elseFieldControls'] as UntypedFormGroup; - if (Object.keys(thenFieldControls.controls).length > 0 || Object.keys(elseFieldControls.controls).length > 0) { + const thenFieldControls = group.controls.thenFieldControls as UntypedFormGroup; + const elseFieldControls = group.controls.elseFieldControls as UntypedFormGroup; + const crossThen: number = (group.controls.crossThenCount as UntypedFormControl)?.value || 0; + const crossElse: number = (group.controls.crossElseCount as UntypedFormControl)?.value || 0; + if ( + Object.keys(thenFieldControls.controls).length > 0 || + Object.keys(elseFieldControls.controls).length > 0 || + crossThen > 0 || + crossElse > 0 + ) { return null; } return { noConditionFields: { valid: false } }; diff --git a/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.html b/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.html index f3a9692376..d8838539d2 100644 --- a/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.html +++ b/frontend/src/app/modules/schema-engine/schema-configuration/schema-configuration.component.html @@ -218,19 +218,28 @@ + + {{ group.label }} + @if (getRowField(row)) {
=
} @switch (true) { - @case (isFieldType1(getRowField(row))) { + @case (isFieldType1(getRowOption(row))) {
} - @case (isFieldType2(getRowField(row))) { + @case (isFieldType2(getRowOption(row))) {
} - @case (isFieldType3(getRowField(row))) { + @case (isFieldType3(getRowOption(row))) {
@@ -271,7 +280,7 @@ appendTo="body">
} - @case (isFieldType4(getRowField(row))) { + @case (isFieldType4(getRowOption(row))) {
@@ -282,7 +291,7 @@ appendTo="body">
} - @case (isFieldType5(getRowField(row))) { + @case (isFieldType5(getRowOption(row))) {
@@ -375,6 +384,41 @@
Add THEN Field
+ @if (condition.crossThenTargets.length) { +
+ Sub-schema THEN: + @for (target of condition.crossThenTargets; track target.fieldPath.join('>')) { + + {{ target.label }} + + + } +
+ } +
+ + + {{ group.label }} + + + {{ option.shortLabel || option.label }} + + +

ELSE

@if (condition.elseControls) {
Add ELSE Field
+ @if (condition.crossElseTargets.length) { +
+ Sub-schema ELSE: + @for (target of condition.crossElseTargets; track target.fieldPath.join('>')) { + + {{ target.label }} + + + } +
+ } +
+ + + {{ group.label }} + + + {{ option.shortLabel || option.label }} + + +