Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Disallow moving relation source or target to another entity in authoring mode if the relation after move already exists on the canvas.
- Disable changing an entity IRI in a form when `MetadataProvider.canModifyEntity()` disallows it.
- Move link vertices together with source and target elements if both are selected in `Selection` component.
- Re-validate entities and links with `ValidationProvider` when added or removed via undo/redo.

#### 💅 Polish
- Remove superfluous "Type" fields from the default "Edit entity" dialog.
- Provide `translation` and current `language` to `ValidationProvider.validate()` method.
- Allow to specify related property IRI when returning a validation item for a relation from `ValidationProvider`.

## [0.32.0] - 2026-03-10
#### 🐛 Fixed
Expand Down
30 changes: 18 additions & 12 deletions examples/resources/exampleMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,16 @@ export class ExampleValidationProvider implements Reactodia.ValidationProvider {
async validate(
event: Reactodia.ValidationEvent
): Promise<Reactodia.ValidationResult> {
const {target, outboundLinks, graph, state, translation, language, signal} = event;
const items: Array<Reactodia.ValidatedElement | Reactodia.ValidatedLink> = [];

if (event.target.types.includes(owl.Class)) {
event.state.links.forEach(e => {
if (e.type === 'relationAdd' && e.data.sourceId === event.target.id) {
if (target.types.includes(owl.Class)) {
state.links.forEach(e => {
if (e.type === 'relationAdd' && e.data.sourceId === target.id) {
const linkType = graph.getLinkType(e.data.linkTypeId);
const linkLabel = translation.formatLabel(
linkType?.data?.label, e.data.linkTypeId, language
);
items.push({
type: 'link',
target: e.data,
Expand All @@ -193,41 +198,42 @@ export class ExampleValidationProvider implements Reactodia.ValidationProvider {
});
items.push({
type: 'element',
target: event.target.id,
target: target.id,
severity: 'warning',
message: `Cannot create <${e.data.linkTypeId}> link from a Class`,
message: `Cannot create "${linkLabel}" relation from a Class`,
});
}
});
}

if (
event.state.elements.has(event.target.id) &&
event.target.types.includes(owl.ObjectProperty)
state.elements.has(target.id) &&
target.types.includes(owl.ObjectProperty)
) {
if (!event.outboundLinks.some(link => link.linkTypeId === rdfs.subPropertyOf)) {
if (!outboundLinks.some(link => link.linkTypeId === rdfs.subPropertyOf)) {
items.push({
type: 'element',
target: event.target.id,
target: target.id,
severity: 'info',
message: 'It might be a good idea to make the property a sub-property of another',
});
}
}

for (const link of event.outboundLinks) {
for (const link of outboundLinks) {
const { [rdfs.comment]: comments } = link.properties;
if (comments && !comments.every(comment => comment.termType === 'Literal' && comment.language)) {
items.push({
type: 'link',
target: link,
severity: 'error',
message: 'rdfs:comment value should have a language',
message: 'value should have a language',
propertyType: rdfs.comment,
});
}
}

await Reactodia.delay(SIMULATED_DELAY, {signal: event.signal});
await Reactodia.delay(SIMULATED_DELAY, {signal});
return {items};
}
}
Expand Down
3 changes: 2 additions & 1 deletion i18n/i18n.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@
"relation_move_source.command": { "$ref": "#/$defs/Value" },
"relation_move_target.command": { "$ref": "#/$defs/Value" },
"remove_items.command": { "$ref": "#/$defs/Value" },
"set_authoring_state.command": { "$ref": "#/$defs/Value" }
"set_authoring_state.command": { "$ref": "#/$defs/Value" },
"validate_entity_failed.message": { "$ref": "#/$defs/Value" }
}
},
"group_paginator": {
Expand Down
3 changes: 2 additions & 1 deletion i18n/translations/en.reactodia-translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@
"relation_move_source.command": "Move relation source",
"relation_move_target.command": "Move relation target",
"remove_items.command": "Remove diagram items",
"set_authoring_state.command": "Create, change or delete entities and relations"
"set_authoring_state.command": "Create, change or delete entities and relations",
"validate_entity_failed.message": "Failed to validate entity"
},
"group_paginator": {
"current_page.label": "{{page}} of {{pageCount}}",
Expand Down
9 changes: 9 additions & 0 deletions src/data/validationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Translation } from '../coreUtils/i18n';

import type { AuthoringState } from '../editor/authoringState';
import type { DataGraphStructure } from '../editor/dataDiagramModel';

Expand All @@ -6,8 +8,12 @@ import type { ElementModel, ElementIri, LinkKey, LinkModel, PropertyTypeIri } fr
export interface ValidationEvent {
readonly target: ElementModel;
readonly outboundLinks: ReadonlyArray<LinkModel>;

readonly graph: DataGraphStructure;
readonly state: AuthoringState;
readonly translation: Translation;
readonly language: string;

readonly signal: AbortSignal | undefined;
}

Expand All @@ -21,13 +27,16 @@ export interface ValidatedElement {
readonly severity: ValidationSeverity;
readonly message: string;
readonly propertyType?: PropertyTypeIri;
readonly errorCause?: unknown;
}

export interface ValidatedLink {
readonly type: 'link';
readonly target: LinkKey;
readonly severity: ValidationSeverity;
readonly message: string;
readonly propertyType?: PropertyTypeIri;
readonly errorCause?: unknown;
}

export type ValidationSeverity = 'info' | 'warning' | 'error';
Expand Down
54 changes: 50 additions & 4 deletions src/editor/editorController.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { Events, EventSource, EventObserver, PropertyChange } from '../coreUtils/events';
import { TranslatedText } from '../coreUtils/i18n';
import { TranslatedText, type Translation } from '../coreUtils/i18n';

import { MetadataProvider } from '../data/metadataProvider';
import { ValidationProvider } from '../data/validationProvider';
import { ElementModel, LinkModel, ElementIri, equalLinks } from '../data/model';
import { TemplateProperties } from '../data/schema';

import { Element, Link } from '../diagram/elements';
import type { CellsChangedEvent } from '../diagram/graph';
import { Command } from '../diagram/history';
import { GraphStructure } from '../diagram/model';

import { AnnotationLink } from './annotationCells';
import {
AuthoringState, AuthoringEvent, TemporaryState,
} from './authoringState';
Expand All @@ -24,6 +23,7 @@ import { ValidationState, changedElementsToValidate, validateElements } from './
/** @hidden */
export interface EditorProps {
readonly model: DataDiagramModel;
readonly translation: Translation;
readonly metadataProvider?: MetadataProvider;
readonly validationProvider?: ValidationProvider;
}
Expand Down Expand Up @@ -67,6 +67,7 @@ export class EditorController {
readonly events: Events<EditorEvents> = this.source;

private readonly model: DataDiagramModel;
private readonly translation: Translation;

private _inAuthoringMode = false;
private _metadataProvider: MetadataProvider | undefined;
Expand All @@ -79,8 +80,9 @@ export class EditorController {

/** @hidden */
constructor(props: EditorProps) {
const {model, metadataProvider, validationProvider} = props;
const {model, translation, metadataProvider, validationProvider} = props;
this.model = model;
this.translation = translation;
this._metadataProvider = metadataProvider;
this._validationProvider = validationProvider;

Expand All @@ -99,6 +101,9 @@ export class EditorController {
this.listener.listen(this.events, 'changeAuthoringState', e => {
this.validateChangedFrom(e.previous);
});
this.listener.listen(model.events, 'changeCells', e => {
this.validateChangedCells(e);
});
}

/** @hidden */
Expand Down Expand Up @@ -214,6 +219,45 @@ export class EditorController {
this.revalidateEntities(changedElements);
}

private validateChangedCells(e: CellsChangedEvent): void {
if (!(this.validationProvider && this.inAuthoringMode)) {
return;
}

if (e.updateAll) {
this.validateChangedFrom(AuthoringState.empty);
return;
}

const state = this.authoringState;
let toRevalidate: Set<ElementIri> | undefined;
if (e.changedElement) {
for (const entity of iterateEntitiesOf(e.changedElement)) {
if (state.elements.has(entity.id)) {
if (!toRevalidate) {
toRevalidate = new Set<ElementIri>();
}
toRevalidate.add(entity.id);
}
}
} else if (e.changedLinks) {
for (const link of e.changedLinks) {
for (const relation of iterateRelationsOf(link)) {
if (state.links.has(relation)) {
if (!toRevalidate) {
toRevalidate = new Set<ElementIri>();
}
toRevalidate.add(relation.sourceId);
}
}
}
}

if (toRevalidate) {
this.revalidateEntities(toRevalidate);
}
}

/**
* Forces re-validation for the specified entities.
*/
Expand All @@ -226,6 +270,8 @@ export class EditorController {
this.validationProvider,
this.model,
this,
this.translation,
this.model.language,
this.cancellation.signal
);
}
Expand Down
14 changes: 11 additions & 3 deletions src/editor/validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HashMap, type ReadonlyHashMap } from '@reactodia/hashmap';

import { mapAbortedToNull } from '../coreUtils/async';
import type { Translation } from '../coreUtils/i18n';

import { ElementIri, LinkKey, LinkModel, hashLink, equalLinks } from '../data/model';
import {
Expand Down Expand Up @@ -150,6 +151,8 @@ export function validateElements(
validationProvider: ValidationProvider,
graph: DataGraphStructure,
editor: EditorController,
translation: Translation,
language: string,
signal: AbortSignal | undefined
): void {
const previousState = editor.validationState;
Expand All @@ -176,6 +179,8 @@ export function validateElements(
outboundLinks,
state: editor.authoringState,
graph,
translation,
language,
signal,
};
const result = mapAbortedToNull(validationProvider.validate(event), signal);
Expand All @@ -185,7 +190,9 @@ export function validateElements(
newState.elements.set(entity.id, loadingElement);
outboundLinks.forEach(link => newState.links.set(link, loadingLink));

void processValidationResult(result, loadingElement, loadingLink, event, editor);
void processValidationResult(
result, loadingElement, loadingLink, event, editor, translation
);
} else {
// use previous state for element and outbound links
newState.elements.set(entity.id, previousState.elements.get(entity.id)!);
Expand All @@ -205,6 +212,7 @@ async function processValidationResult(
previousLink: LinkValidation,
e: ValidationEvent,
editor: EditorController,
translation: Translation
) {
let result: ValidationResult | null;
try {
Expand All @@ -214,12 +222,12 @@ async function processValidationResult(
return;
}
} catch (err) {
console.error('Failed to validate element', e.target, err);
const items: ReadonlyArray<ValidatedElement | ValidatedLink> = [{
type: 'element',
target: e.target.id,
severity: 'error',
message: 'Failed to validate element',
message: translation.text('editor_controller.validate_entity_failed.message'),
errorCause: err,
}];
result = {items};
}
Expand Down
17 changes: 15 additions & 2 deletions src/widgets/visualAuthoring/authoredRelationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,15 +259,28 @@ class LinkStateWidgetInner extends React.Component<AuthoredRelationOverlayInnerP
}

private renderLinkValidations(key: LinkKey): React.ReactElement | null {
const {workspace: {editor}} = this.props;
const {workspace: {model, editor, translation: t}} = this.props;
const {validationState} = editor;

const validation = validationState.links.get(key);
if (!validation) {
return null;
}

const title = validation.items.map(item => item.message).join('\n');
const title = validation.items.map(item => {
if (item.propertyType) {
const propertyType = model.getPropertyType(item.propertyType);
const source = t.formatLabel(
propertyType?.data?.label,
item.propertyType,
model.language
);
return `${source}: ${item.message}`;
} else {
return item.message;
}
}).join('\n');

const severity = getMaxSeverity(validation.items);
return (
<div className={cx(`${CLASS_NAME}__item-validation`, getSeverityClass(severity))}
Expand Down
1 change: 1 addition & 0 deletions src/workspace/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export class Workspace extends React.Component<WorkspaceProps> {

const editor = new EditorController({
model,
translation,
metadataProvider,
validationProvider,
});
Expand Down
Loading