Skip to content

Commit 3175107

Browse files
authored
show current selected element as highlighted in GUI (#973)
* show current selected element as highlighted in GUI * fix bugs and add more tests * apply formatting changes --------- Co-authored-by: Logende <Logende@users.noreply.github.com>
1 parent f6219c3 commit 3175107

7 files changed

Lines changed: 270 additions & 13 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {test, expect} from '@playwright/test';
2+
import {forceEditorMode, openApp, selectInitialSchemaFromExamples} from '../../tests/shared/utils';
3+
import {tpForceCurrentPath} from '../../tests/shared/utilsTestPanel';
4+
import {SessionMode} from '../src/store/sessionMode';
5+
6+
test('autonomous vehicle schema shows normal subschema option for properties entries', async ({
7+
page,
8+
}) => {
9+
await openApp(page, 'settings_testpanel.json');
10+
await selectInitialSchemaFromExamples(page, 'Autonomous Vehicle Schema');
11+
await forceEditorMode(page, SessionMode.SchemaEditor);
12+
await tpForceCurrentPath(page, SessionMode.SchemaEditor, ['properties']);
13+
14+
const simulationNameRow = page.getByTestId('property-data-properties.SimulationName');
15+
await expect(simulationNameRow).toBeVisible();
16+
17+
const combo = simulationNameRow.getByRole('combobox');
18+
await combo.click();
19+
20+
await expect(page.getByRole('option', {name: /0: Always valid/})).toBeVisible();
21+
await expect(page.getByRole('option', {name: /1: Always invalid/})).toBeVisible();
22+
await expect(page.getByRole('option', {name: /2: Subschema/})).toBeVisible();
23+
});

meta_configurator/src/components/panels/gui-editor/PropertiesPanel.vue

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,12 @@ watch(
8686
const pathToCutOff = session.currentPath.value;
8787
const relativePath = absolutePath.slice(pathToCutOff.length);
8888
if (relativePath.length > 0) {
89-
// cut off last element, because we want to expand until last element, but not expand children of last element
90-
const relativePathToExpand = relativePath.slice(0, relativePath.length - 1);
89+
const selectedSchema = props.currentSchema.subSchemaAt(relativePath);
90+
const selectedNodeIsExpandable =
91+
selectedSchema?.hasType('object') || selectedSchema?.hasType('array');
92+
const relativePathToExpand = selectedNodeIsExpandable
93+
? relativePath
94+
: relativePath.slice(0, relativePath.length - 1);
9195
expandElementsByPath(relativePathToExpand);
9296
}
9397
scrollToPath(absolutePath);
@@ -553,6 +557,10 @@ function zoomIntoPath(path: Path) {
553557
overlayShowScheduled.value = false;
554558
emit('zoom_into_path', path);
555559
}
560+
561+
function isNodeHighlighted(node: GuiEditorTreeNode) {
562+
return node.type !== TreeNodeType.ADVANCED_PROPERTY && session.isNodeHighlighted(node);
563+
}
556564
</script>
557565

558566
<template>
@@ -578,14 +586,15 @@ function zoomIntoPath(path: Path) {
578586
v-if="displayAsRegularProperty(slotProps.node)"
579587
style="width: 50%; min-width: 50%"
580588
:style="addNegativeMarginForTableStyle(slotProps.node.data.depth)"
589+
:class="{'bg-yellow-50 rounded-sm': isNodeHighlighted(slotProps.node)}"
581590
@mouseenter="event => showInfoOverlayPanel(slotProps.node.data, event)"
582591
@mouseleave="closeInfoOverlayPanel">
583592
<PropertyMetadata
584593
:sessionMode="props.sessionMode"
585594
:validationResults="getValidationResults(slotProps.node.data.absolutePath)"
586595
:node="slotProps.node"
587596
:type="slotProps.node.type"
588-
:highlighted="session.isNodeHighlighted(slotProps.node)"
597+
:highlighted="isNodeHighlighted(slotProps.node)"
589598
@zoom_into_path="zoomIntoPath"
590599
@update_property_name="
591600
(oldName, newName) =>
@@ -597,7 +606,11 @@ function zoomIntoPath(path: Path) {
597606
</span>
598607

599608
<!-- data nodes, actual edit fields for the data -->
600-
<span v-if="displayAsRegularProperty(slotProps.node)" style="max-width: 47%" class="w-full">
609+
<span
610+
v-if="displayAsRegularProperty(slotProps.node)"
611+
style="max-width: 47%"
612+
class="w-full"
613+
:class="{'bg-yellow-50 rounded-sm': isNodeHighlighted(slotProps.node)}">
601614
<PropertyData
602615
class="w-full"
603616
:nodeData="slotProps.node.data"

meta_configurator/src/components/toolbar/clearFile.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,8 @@ function newEmptyFile({
5252

5353
function clearFile(dataLink: ManagedData) {
5454
dataLink.setData({});
55-
let mode = useSessionStore().currentMode;
56-
getSessionForMode(mode).updateCurrentPath([]); // todo introduce reset method
57-
getSessionForMode(mode).updateCurrentSelectedElement([]);
55+
getSessionForMode(dataLink.mode).updateCurrentPath([]); // todo introduce reset method
56+
getSessionForMode(dataLink.mode).updateCurrentSelectedElement([]);
5857
}
5958

6059
/**
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {describe, expect, it, vi} from 'vitest';
2+
import {ManagedSession} from '../managedSession';
3+
import {SessionMode} from '../../store/sessionMode';
4+
5+
vi.mock('@/data/useDataLink', () => ({
6+
getDataForMode: vi.fn(),
7+
getSchemaForMode: vi.fn(),
8+
}));
9+
10+
describe('ManagedSession.isNodeHighlighted', () => {
11+
it('highlights the exactly selected node', () => {
12+
const session = new ManagedSession(SessionMode.DataEditor);
13+
session.updateCurrentSelectedElement(['parent', 'child']);
14+
15+
expect(
16+
session.isNodeHighlighted({
17+
key: 'parent.child',
18+
data: {} as any,
19+
type: 'data' as any,
20+
})
21+
).toBe(true);
22+
});
23+
24+
it('highlights search result ancestors', () => {
25+
const session = new ManagedSession(SessionMode.DataEditor);
26+
session.currentSearchResults.value = [
27+
{
28+
path: ['parent', 'child'],
29+
description: 'match',
30+
textSnippet: 'match',
31+
} as any,
32+
];
33+
34+
expect(
35+
session.isNodeHighlighted({
36+
key: 'parent',
37+
data: {} as any,
38+
type: 'data' as any,
39+
})
40+
).toBe(true);
41+
});
42+
43+
it('does not highlight unrelated nodes', () => {
44+
const session = new ManagedSession(SessionMode.DataEditor);
45+
session.updateCurrentSelectedElement(['parent', 'child']);
46+
47+
expect(
48+
session.isNodeHighlighted({
49+
key: 'parent.other',
50+
data: {} as any,
51+
type: 'data' as any,
52+
})
53+
).toBe(false);
54+
});
55+
});

meta_configurator/src/data/managedSession.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,18 @@ export class ManagedSession {
7979
}
8080

8181
/**
82-
* Returns true if the node or any of its children is highlighted.
82+
* Returns true if the node is selected, or if the node or any of its children matches a search result.
8383
*/
8484
public isNodeHighlighted(node: ConfigDataTreeNode) {
85+
const selectedPath = pathToString(this.currentSelectedElement.value);
86+
if (node.key && node.key === selectedPath) {
87+
return true;
88+
}
89+
8590
return this.currentSearchResults.value
8691
.map(searchResult => searchResult.path)
8792
.map(path => pathToString(path))
8893
.some(path => node.key && path.startsWith(node.key));
89-
// TODO: also highlight, when node is currentSelectedElement
9094
}
9195

9296
public effectiveSchemaAtCurrentPath: Ref<EffectiveSchema> = computed(() =>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {describe, expect, it} from 'vitest';
2+
import {areSchemasCompatible, mergeSchemas, safeMergeSchemas} from '@/schema/mergeAllOfs';
3+
4+
describe('mergeAllOfs', () => {
5+
it('treats schemas with only annotation conflicts as compatible', () => {
6+
const result = safeMergeSchemas(
7+
{
8+
title: 'Json schema',
9+
$comment: 'wrapper comment',
10+
},
11+
{
12+
title: 'Subschema',
13+
type: 'object',
14+
properties: {
15+
foo: {
16+
title: 'Foo',
17+
type: 'string',
18+
},
19+
},
20+
}
21+
);
22+
23+
expect(result).not.toBe(false);
24+
});
25+
26+
it('treats empty schema plus constrained schema as compatible', () => {
27+
expect(
28+
areSchemasCompatible(
29+
{},
30+
{
31+
type: 'object',
32+
conditions: [
33+
{
34+
if: {
35+
anyOf: [{properties: {type: {const: 'string'}}}],
36+
},
37+
then: {
38+
properties: {
39+
pattern: {
40+
type: 'string',
41+
pattern: '^[A-Za-z_][-A-Za-z0-9._]*$',
42+
},
43+
},
44+
},
45+
},
46+
],
47+
}
48+
)
49+
).toBe(true);
50+
});
51+
52+
it('treats a single schema with an unsatisfiable internal allOf as incompatible', () => {
53+
// After convertConstToEnum normalizes `const: 'null'` into `enum: ['null']`,
54+
// the merger can no longer reconcile the two `contains` keywords. This is a
55+
// real incompatibility and must propagate from areSchemasCompatible so the
56+
// caller drops the option before it reaches handleAllOfs.
57+
expect(
58+
areSchemasCompatible(
59+
{},
60+
{
61+
type: 'array',
62+
allOf: [
63+
{contains: {enum: ['null']}},
64+
{contains: {enum: ['array', 'boolean', 'integer', 'number', 'object', 'string']}},
65+
],
66+
}
67+
)
68+
).toBe(false);
69+
});
70+
71+
it('falls back to a stripped merge only when necessary', () => {
72+
const result = mergeSchemas(
73+
{
74+
title: 'Json schema',
75+
$comment: 'wrapper comment',
76+
},
77+
{
78+
title: 'Subschema',
79+
type: 'object',
80+
properties: {
81+
foo: {
82+
title: 'Foo',
83+
type: 'string',
84+
},
85+
},
86+
}
87+
) as any;
88+
89+
expect(result.type).toBe('object');
90+
expect(result.properties.foo.type).toBe('string');
91+
expect(result.title).toBe('Json schema');
92+
});
93+
});

meta_configurator/src/schema/mergeAllOfs.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ export function mergeAllOfs(schema: JsonSchemaType, deep: boolean = true): JsonS
77
return schema;
88
}
99

10+
try {
11+
return mergeAllOfWithResolvers(schema, deep);
12+
} catch (error) {
13+
return mergeAllOfWithResolvers(stripNonValidationFields(schema), deep);
14+
}
15+
}
16+
17+
function mergeAllOfWithResolvers(schema: JsonSchemaType, deep: boolean): JsonSchemaType {
1018
return mergeAllOf(
1119
schema as any,
1220
{
@@ -36,17 +44,45 @@ export function mergeAllOfs(schema: JsonSchemaType, deep: boolean = true): JsonS
3644
} as any
3745
);
3846
}
39-
export function safeMergeAllOfs(schema: JsonSchemaType): JsonSchemaType {
47+
export function safeMergeAllOfs(schema: JsonSchemaType, deep: boolean = true): JsonSchemaType {
4048
try {
41-
return mergeAllOfs(schema);
49+
return mergeAllOfs(schema, deep);
4250
} catch (e) {
4351
return false;
4452
}
4553
}
4654

4755
export function areSchemasCompatible(...schemas: JsonSchemaType[]): boolean {
48-
const mergeResult = safeMergeSchemas(...schemas);
49-
return mergeResult != false;
56+
const strippedSchemas = schemas.map(schema => stripNonValidationFields(schema));
57+
58+
if (strippedSchemas.some(schema => schema === false)) {
59+
return false;
60+
}
61+
62+
// empty-object schemas are neutral: they impose no constraints, so they're
63+
// always compatible with anything. Drop them before delegating to the merger.
64+
const relevantSchemas = strippedSchemas.filter(
65+
schema =>
66+
!(
67+
typeof schema === 'object' &&
68+
schema !== null &&
69+
!Array.isArray(schema) &&
70+
Object.keys(schema).length === 0
71+
)
72+
);
73+
74+
if (relevantSchemas.length === 0) {
75+
return true;
76+
}
77+
78+
// Even a single remaining schema must be checked: a top-level allOf that the
79+
// merger can't reconcile (e.g. multiple `contains` with disjoint enums after
80+
// const -> enum normalization) is a genuine incompatibility that must propagate
81+
// up so the caller can drop the option. Use a shallow merge so unrelated
82+
// nested allOfs (which will be resolved when their own subtree is processed)
83+
// don't cause false negatives here.
84+
const combined = {allOf: relevantSchemas};
85+
return safeMergeAllOfs(combined, false) !== false;
5086
}
5187

5288
export function safeMergeSchemas(...schemas: JsonSchemaType[]): JsonSchemaType | false {
@@ -62,3 +98,37 @@ export function mergeSchemas(...schemas: JsonSchemaType[]): JsonSchemaType {
6298
};
6399
return mergeAllOfs(combinedSchema);
64100
}
101+
102+
function stripNonValidationFields(schema: JsonSchemaType): JsonSchemaType {
103+
if (typeof schema !== 'object' || schema === null) {
104+
return schema;
105+
}
106+
107+
if (Array.isArray(schema)) {
108+
return schema.map(item => stripNonValidationFields(item));
109+
}
110+
111+
const stripped: Record<string, any> = {};
112+
for (const [key, value] of Object.entries(schema)) {
113+
if (NON_VALIDATION_FIELDS.has(key)) {
114+
continue;
115+
}
116+
stripped[key] = stripNonValidationFields(value as JsonSchemaType);
117+
}
118+
return stripped;
119+
}
120+
121+
const NON_VALIDATION_FIELDS = new Set([
122+
'title',
123+
'description',
124+
'$comment',
125+
'default',
126+
'examples',
127+
'deprecated',
128+
'readOnly',
129+
'writeOnly',
130+
'metaConfigurator',
131+
'$id',
132+
'$schema',
133+
'id',
134+
]);

0 commit comments

Comments
 (0)