blob: 2cb5e5dafd7b93f3c66ed3c1c1b75f1fb6fb77da [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as __path from "path";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import * as DmnEditor from "@kie-tools/dmn-editor/dist/DmnEditor";
import { normalize, Normalized } from "@kie-tools/dmn-editor/dist/normalization/normalize";
import { DMN_LATEST_VERSION, DmnLatestModel, DmnMarshaller, getMarshaller } from "@kie-tools/dmn-marshaller";
import { generateUuid } from "@kie-tools/boxed-expression-component/dist/api";
import {
ContentType,
ResourceContent,
SearchType,
WorkspaceChannelApi,
WorkspaceEdit,
} from "@kie-tools-core/workspace/dist/api";
import { DMN15_SPEC } from "@kie-tools/dmn-marshaller/dist/schemas/dmn-1_5/Dmn15Spec";
import { domParser } from "@kie-tools/xml-parser-ts";
import { ns as dmn15ns } from "@kie-tools/dmn-marshaller/dist/schemas/dmn-1_5/ts-gen/meta";
import { XML2PMML } from "@kie-tools/pmml-editor-marshaller";
import { getPmmlNamespace } from "@kie-tools/dmn-editor/dist/pmml/pmml";
import { getNamespaceOfDmnImport } from "@kie-tools/dmn-editor/dist/includedModels/importNamespaces";
import {
imperativePromiseHandle,
PromiseImperativeHandle,
} from "@kie-tools-core/react-hooks/dist/useImperativePromiseHandler";
import { KeyboardShortcutsService } from "@kie-tools-core/keyboard-shortcuts/dist/envelope/KeyboardShortcutsService";
import { Flex } from "@patternfly/react-core/dist/js/layouts/Flex";
import { EmptyState, EmptyStateBody, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState";
import { Title } from "@patternfly/react-core/dist/js/components/Title";
export const EXTERNAL_MODELS_SEARCH_GLOB_PATTERN = "**/*.{dmn,pmml}";
export const EMPTY_DMN = () => `<?xml version="1.0" encoding="UTF-8"?>
<definitions
xmlns="${dmn15ns.get("")}"
expressionLanguage="${DMN15_SPEC.expressionLanguage.default}"
namespace="https://kie.org/dmn/${generateUuid()}"
id="${generateUuid()}"
name="DMN${generateUuid()}">
</definitions>`;
export type DmnEditorRootProps = {
exposing: (s: DmnEditorRoot) => void;
onNewEdit: (edit: WorkspaceEdit) => void;
onRequestWorkspaceFilesList: WorkspaceChannelApi["kogitoWorkspace_resourceListRequest"];
onRequestWorkspaceFileContent: WorkspaceChannelApi["kogitoWorkspace_resourceContentRequest"];
onOpenFileFromNormalizedPosixPathRelativeToTheWorkspaceRoot: WorkspaceChannelApi["kogitoWorkspace_openFile"];
workspaceRootAbsolutePosixPath: string;
keyboardShortcutsService: KeyboardShortcutsService | undefined;
};
export type DmnEditorRootState = {
marshaller: DmnMarshaller<typeof DMN_LATEST_VERSION> | undefined;
stack: Normalized<DmnLatestModel>[];
pointer: number;
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot: string | undefined;
externalModelsByNamespace: DmnEditor.ExternalModelsIndex;
readonly: boolean;
externalModelsManagerDoneBootstraping: boolean;
keyboardShortcutsRegisterIds: number[];
keyboardShortcutsRegistred: boolean;
error: Error | undefined;
};
export class DmnEditorRoot extends React.Component<DmnEditorRootProps, DmnEditorRootState> {
private readonly externalModelsManagerDoneBootstraping = imperativePromiseHandle<void>();
private readonly dmnEditorRef: React.RefObject<DmnEditor.DmnEditorRef>;
constructor(props: DmnEditorRootProps) {
super(props);
props.exposing(this);
this.dmnEditorRef = React.createRef();
this.state = {
externalModelsByNamespace: {},
marshaller: undefined,
stack: [],
pointer: -1,
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot: undefined,
readonly: true,
externalModelsManagerDoneBootstraping: false,
keyboardShortcutsRegisterIds: [],
keyboardShortcutsRegistred: false,
error: undefined,
};
}
// Exposed API
public async undo(): Promise<void> {
this.setState((prev) => ({ ...prev, pointer: Math.max(0, prev.pointer - 1) }));
}
public async redo(): Promise<void> {
this.setState((prev) => ({ ...prev, pointer: Math.min(prev.stack.length - 1, prev.pointer + 1) }));
}
public async getDiagramSvg(): Promise<string | undefined> {
return this.dmnEditorRef.current?.getDiagramSvg();
}
public async getContent(): Promise<string> {
if (!this.state.marshaller || !this.model) {
throw new Error(
`DMN EDITOR ROOT: Content has not been set yet. Throwing an error to prevent returning a "default" content.`
);
}
return this.state.marshaller.builder.build(this.model);
}
public async setContent(
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot: string,
content: string
): Promise<void> {
const marshaller = this.getMarshaller(content);
// Save stack
let savedStackPointer: Normalized<DmnLatestModel>[] = [];
// Set the model and path for external models manager.
this.setState((prev) => {
savedStackPointer = [...prev.stack];
return {
stack: [normalize(marshaller.parser.parse())],
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot,
pointer: 0,
};
});
// Wait the external manager models to load.
await this.externalModelsManagerDoneBootstraping.promise;
// Set the valeus to render the DMN Editor.
this.setState((prev) => {
// External change to the same file.
if (
prev.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot ===
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot
) {
const newStack = savedStackPointer.slice(0, prev.pointer + 1);
return {
marshaller,
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot,
stack: [...newStack, normalize(marshaller.parser.parse())],
readonly: false,
pointer: newStack.length,
externalModelsManagerDoneBootstraping: true,
};
}
// Different file opened. Need to reset everything.
else {
return {
marshaller,
openFilenormalizedPosixPathRelativeToTheWorkspaceRoot,
stack: [normalize(marshaller.parser.parse())],
readonly: false,
pointer: 0,
externalModelsManagerDoneBootstraping: true,
};
}
});
}
public get model(): Normalized<DmnLatestModel> | undefined {
return this.state.stack[this.state.pointer];
}
// Internal methods
private getMarshaller(content: string) {
try {
return getMarshaller(content || EMPTY_DMN(), { upgradeTo: "latest" });
} catch (e) {
this.setState((s) => ({
...s,
error: e,
}));
throw e;
}
}
private setExternalModelsByNamespace = (externalModelsByNamespace: DmnEditor.ExternalModelsIndex) => {
this.setState((prev) => ({ ...prev, externalModelsByNamespace }));
};
private onModelChange: DmnEditor.OnDmnModelChange = (model) => {
this.setState(
(prev) => {
const newStack = prev.stack.slice(0, prev.pointer + 1);
return {
...prev,
stack: [...newStack, model],
pointer: newStack.length,
};
},
() =>
this.props.onNewEdit({
id: `${this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot}__${generateUuid()}`,
})
);
};
private onRequestExternalModelsAvailableToInclude: DmnEditor.OnRequestExternalModelsAvailableToInclude = async () => {
if (!this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot) {
return [];
}
const list = await this.props.onRequestWorkspaceFilesList({
pattern: EXTERNAL_MODELS_SEARCH_GLOB_PATTERN,
opts: { type: SearchType.TRAVERSAL },
});
return list.normalizedPosixPathsRelativeToTheWorkspaceRoot.flatMap((p) =>
// Do not show this DMN on the list
p === this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot
? []
: __path.relative(__path.dirname(this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot!), p)
);
};
private onRequestToResolvePathRelativeToTheOpenFile: DmnEditor.OnRequestToResolvePath = (
normalizedPosixPathRelativeToTheOpenFile
) => {
const normalizedPosixPathRelativeToTheWorkspaceRoot = __path
.resolve(
__path.dirname(this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot!),
normalizedPosixPathRelativeToTheOpenFile
)
.substring(1); // Remove leading slash.
return normalizedPosixPathRelativeToTheWorkspaceRoot;
// Example:
// this.state.openFileAbsolutePath = /Users/ljmotta/packages/dmns/Dmn.dmn
// normalizedPosixPathRelativeToTheOpenFile = ../../tmp/Tmp.dmn
// workspaceRootAbsolutePosixPath = /Users/ljmotta
// resolvedAbsolutePath = /Users/ljmotta/tmp/Tmp.dmn
// return (which is the normalizedPosixPathRelativeToTheWorkspaceRoot) = tmp/Tmp.dmn
};
private onRequestExternalModelByPathsRelativeToTheOpenFile: DmnEditor.OnRequestExternalModelByPath = async (
normalizedPosixPathRelativeToTheOpenFile
) => {
const normalizedPosixPathRelativeToTheWorkspaceRoot = this.onRequestToResolvePathRelativeToTheOpenFile(
normalizedPosixPathRelativeToTheOpenFile
);
const resource = await this.props.onRequestWorkspaceFileContent({
normalizedPosixPathRelativeToTheWorkspaceRoot,
opts: { type: ContentType.TEXT },
});
const ext = __path.extname(normalizedPosixPathRelativeToTheOpenFile);
if (ext === ".dmn") {
return {
normalizedPosixPathRelativeToTheOpenFile,
type: "dmn",
model: normalize(getMarshaller(resource?.content ?? "", { upgradeTo: "latest" }).parser.parse()),
svg: "",
};
} else if (ext === ".pmml") {
return {
normalizedPosixPathRelativeToTheOpenFile,
type: "pmml",
model: XML2PMML(resource?.content ?? ""),
};
} else {
throw new Error(`Unknown extension '${ext}'.`);
}
};
private onOpenFileFromPathRelativeToTheOpenFile = (normalizedPosixPathRelativeToTheOpenFile: string) => {
if (!this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot) {
return;
}
this.props.onOpenFileFromNormalizedPosixPathRelativeToTheWorkspaceRoot(
this.onRequestToResolvePathRelativeToTheOpenFile(normalizedPosixPathRelativeToTheOpenFile)
);
};
public componentDidUpdate(
prevProps: Readonly<DmnEditorRootProps>,
prevState: Readonly<DmnEditorRootState>,
snapshot?: any
): void {
if (this.props.keyboardShortcutsService === undefined || this.state.keyboardShortcutsRegistred === true) {
return;
}
const commands = this.dmnEditorRef.current?.getCommands();
if (commands === undefined) {
return;
}
const cancelAction = this.props.keyboardShortcutsService.registerKeyPress("Escape", "Edit | Unselect", async () =>
commands.cancelAction()
);
const deleteSelectionBackspace = this.props.keyboardShortcutsService.registerKeyPress(
"Backspace",
"Edit | Delete selection",
async () => {}
);
const deleteSelectionDelete = this.props.keyboardShortcutsService.registerKeyPress(
"Delete",
"Edit | Delete selection",
async () => {}
);
const selectAll = this.props.keyboardShortcutsService?.registerKeyPress(
"A",
"Edit | Select/Deselect all",
async () => commands.selectAll()
);
const createGroup = this.props.keyboardShortcutsService?.registerKeyPress(
"G",
"Edit | Create group wrapping selection",
async () => {
console.log(" KEY GROUP PRESSED, ", commands);
return commands.createGroup();
}
);
const hideFromDrd = this.props.keyboardShortcutsService?.registerKeyPress("X", "Edit | Hide from DRD", async () =>
commands.hideFromDrd()
);
const copy = this.props.keyboardShortcutsService?.registerKeyPress("Ctrl+C", "Edit | Copy nodes", async () =>
commands.copy()
);
const cut = this.props.keyboardShortcutsService?.registerKeyPress("Ctrl+X", "Edit | Cut nodes", async () =>
commands.cut()
);
const paste = this.props.keyboardShortcutsService?.registerKeyPress("Ctrl+V", "Edit | Paste nodes", async () =>
commands.paste()
);
const togglePropertiesPanel = this.props.keyboardShortcutsService?.registerKeyPress(
"I",
"Misc | Open/Close properties panel",
async () => commands.togglePropertiesPanel()
);
const toggleHierarchyHighlight = this.props.keyboardShortcutsService?.registerKeyPress(
"H",
"Misc | Toggle hierarchy highlights",
async () => commands.toggleHierarchyHighlight()
);
const moveUp = this.props.keyboardShortcutsService.registerKeyPress(
"Up",
"Move | Move selection up",
async () => {}
);
const moveDown = this.props.keyboardShortcutsService.registerKeyPress(
"Down",
"Move | Move selection down",
async () => {}
);
const moveLeft = this.props.keyboardShortcutsService.registerKeyPress(
"Left",
"Move | Move selection left",
async () => {}
);
const moveRight = this.props.keyboardShortcutsService.registerKeyPress(
"Right",
"Move | Move selection right",
async () => {}
);
const bigMoveUp = this.props.keyboardShortcutsService.registerKeyPress(
"Shift + Up",
"Move | Move selection up a big distance",
async () => {}
);
const bigMoveDown = this.props.keyboardShortcutsService.registerKeyPress(
"Shift + Down",
"Move | Move selection down a big distance",
async () => {}
);
const bigMoveLeft = this.props.keyboardShortcutsService.registerKeyPress(
"Shift + Left",
"Move | Move selection left a big distance",
async () => {}
);
const bigMoveRight = this.props.keyboardShortcutsService.registerKeyPress(
"Shift + Right",
"Move | Move selection right a big distance",
async () => {}
);
const focusOnBounds = this.props.keyboardShortcutsService?.registerKeyPress(
"B",
"Navigate | Focus on selection",
async () => commands.focusOnSelection()
);
const resetPosition = this.props.keyboardShortcutsService?.registerKeyPress(
"Space",
"Navigate | Reset position to origin",
async () => commands.resetPosition()
);
const pan = this.props.keyboardShortcutsService?.registerKeyPress(
"Right Mouse Button",
"Navigate | Hold and drag to Pan",
async () => {}
);
const zoom = this.props.keyboardShortcutsService?.registerKeyPress(
"Ctrl",
"Navigate | Hold and scroll to zoom in/out",
async () => {}
);
const navigateHorizontally = this.props.keyboardShortcutsService?.registerKeyPress(
"Shift",
"Navigate | Hold and scroll to navigate horizontally",
async () => {}
);
this.setState((prev) => ({
...prev,
keyboardShortcutsRegistred: true,
keyboardShortcutsRegisterIds: [
bigMoveDown,
bigMoveLeft,
bigMoveRight,
bigMoveUp,
cancelAction,
copy,
createGroup,
cut,
deleteSelectionBackspace,
deleteSelectionDelete,
focusOnBounds,
hideFromDrd,
moveDown,
moveLeft,
moveRight,
moveUp,
navigateHorizontally,
pan,
paste,
resetPosition,
selectAll,
toggleHierarchyHighlight,
togglePropertiesPanel,
zoom,
],
}));
}
public componentWillUnmount() {
const keyboardShortcuts = this.dmnEditorRef.current?.getCommands();
if (keyboardShortcuts === undefined) {
return;
}
this.state.keyboardShortcutsRegisterIds.forEach((id) => {
this.props.keyboardShortcutsService?.deregister(id);
});
}
public render() {
return (
<>
{this.state.error && <DmnMarshallerFallbackError error={this.state.error} />}
{this.model && (
<>
<DmnEditor.DmnEditor
ref={this.dmnEditorRef}
originalVersion={this.state.marshaller?.originalVersion}
model={this.model}
externalModelsByNamespace={this.state.externalModelsByNamespace}
evaluationResults={[]}
validationMessages={[]}
externalContextName={""}
externalContextDescription={""}
issueTrackerHref={""}
onModelChange={this.onModelChange}
onRequestExternalModelsAvailableToInclude={this.onRequestExternalModelsAvailableToInclude}
// (begin) All paths coming from inside the DmnEditor component are paths relative to the open file.
onRequestExternalModelByPath={this.onRequestExternalModelByPathsRelativeToTheOpenFile}
onRequestToJumpToPath={this.onOpenFileFromPathRelativeToTheOpenFile}
onRequestToResolvePath={this.onRequestToResolvePathRelativeToTheOpenFile}
// (end)
/>
<ExternalModelsManager
workspaceRootAbsolutePosixPath={this.props.workspaceRootAbsolutePosixPath}
thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot={
this.state.openFilenormalizedPosixPathRelativeToTheWorkspaceRoot
}
model={this.model}
onChange={this.setExternalModelsByNamespace}
onRequestWorkspaceFilesList={this.props.onRequestWorkspaceFilesList}
onRequestWorkspaceFileContent={this.props.onRequestWorkspaceFileContent}
externalModelsManagerDoneBootstraping={this.externalModelsManagerDoneBootstraping}
/>
</>
)}
</>
);
}
}
const NAMESPACES_EFFECT_SEPARATOR = " , ";
function ExternalModelsManager({
workspaceRootAbsolutePosixPath,
thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot,
model,
onChange,
onRequestWorkspaceFileContent,
onRequestWorkspaceFilesList,
externalModelsManagerDoneBootstraping,
}: {
workspaceRootAbsolutePosixPath: string;
thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot: string | undefined;
model: Normalized<DmnLatestModel>;
onChange: (externalModelsByNamespace: DmnEditor.ExternalModelsIndex) => void;
onRequestWorkspaceFileContent: WorkspaceChannelApi["kogitoWorkspace_resourceContentRequest"];
onRequestWorkspaceFilesList: WorkspaceChannelApi["kogitoWorkspace_resourceListRequest"];
externalModelsManagerDoneBootstraping: PromiseImperativeHandle<void>;
}) {
const namespaces = useMemo(
() =>
(model.definitions.import ?? [])
.map((i) => getNamespaceOfDmnImport({ dmnImport: i }))
.join(NAMESPACES_EFFECT_SEPARATOR),
[model.definitions.import]
);
const [externalUpdatesCount, setExternalUpdatesCount] = useState(0);
// This is a hack. Every time a file is updates in KIE Sandbox, the Shared Worker emits an event to this BroadcastChannel.
// By listening to it, we can reload the `externalModelsByNamespace` object. This makes the DMN Editor react to external changes,
// Which is very important for multi-file editing.
//
// Now, this mechanism is not ideal. We would ideally only be notified on changes to relevant files, but this sub-system does not exist yet.
// The consequence of this "hack" is some extra reloads.
useEffect(() => {
const bc = new BroadcastChannel("workspaces_files");
bc.onmessage = ({ data }) => {
// Changes to `thisDmn` shouldn't update its references to external models.
// Here, `data?.relativePath` is relative to the workspace root.
if (data?.relativePath === thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot) {
return;
}
setExternalUpdatesCount((prev) => prev + 1);
};
return () => {
bc.close();
};
}, [thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot]);
// This effect actually populates `externalModelsByNamespace` through the `onChange` call.
useEffect(() => {
let canceled = false;
if (!thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot) {
return;
}
onRequestWorkspaceFilesList({ pattern: EXTERNAL_MODELS_SEARCH_GLOB_PATTERN, opts: { type: SearchType.TRAVERSAL } })
.then((list) => {
const resources: Array<Promise<ResourceContent | undefined>> = [];
for (let i = 0; i < list.normalizedPosixPathsRelativeToTheWorkspaceRoot.length; i++) {
const normalizedPosixPathRelativeToTheWorkspaceRoot = list.normalizedPosixPathsRelativeToTheWorkspaceRoot[i];
if (normalizedPosixPathRelativeToTheWorkspaceRoot === thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot) {
continue;
}
resources.push(
onRequestWorkspaceFileContent({
normalizedPosixPathRelativeToTheWorkspaceRoot,
opts: { type: ContentType.TEXT },
})
);
}
return Promise.all(resources);
})
.then((resources) => {
const externalModelsIndex: DmnEditor.ExternalModelsIndex = {};
const namespacesSet = new Set(namespaces.split(NAMESPACES_EFFECT_SEPARATOR));
for (let i = 0; i < resources.length; i++) {
const resource = resources[i];
if (!resource) {
continue;
}
const content = resource.content ?? "";
const normalizedPosixPathRelativeToTheOpenFile = __path.relative(
__path.dirname(thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot),
resource.normalizedPosixPathRelativeToTheWorkspaceRoot
);
const ext = __path.extname(resource.normalizedPosixPathRelativeToTheWorkspaceRoot);
if (ext === ".dmn") {
const namespace = domParser.getDomDocument(content).documentElement.getAttribute("namespace");
if (namespace && namespacesSet.has(namespace)) {
// Check for multiplicity of namespaces on DMN models
if (externalModelsIndex[namespace]) {
console.warn(
`DMN EDITOR ROOT: Multiple DMN models encountered with the same namespace '${namespace}': '${
resource.normalizedPosixPathRelativeToTheWorkspaceRoot
}' and '${
externalModelsIndex[namespace]!.normalizedPosixPathRelativeToTheOpenFile
}'. The latter will be considered.`
);
}
externalModelsIndex[namespace] = {
normalizedPosixPathRelativeToTheOpenFile,
model: normalize(getMarshaller(content, { upgradeTo: "latest" }).parser.parse()),
type: "dmn",
svg: "",
};
}
} else if (ext === ".pmml") {
const namespace = getPmmlNamespace({ normalizedPosixPathRelativeToTheOpenFile });
if (namespace && namespacesSet.has(namespace)) {
// No need to check for namespaces being equal becuase there can't be two files with the same relativePath.
externalModelsIndex[namespace] = {
normalizedPosixPathRelativeToTheOpenFile,
model: XML2PMML(content),
type: "pmml",
};
}
} else {
throw new Error(`Unknown extension '${ext}'.`);
}
}
if (!canceled) {
onChange(externalModelsIndex);
}
externalModelsManagerDoneBootstraping.resolve();
});
return () => {
canceled = true;
};
}, [
namespaces,
onChange,
onRequestWorkspaceFileContent,
onRequestWorkspaceFilesList,
thisDmnsNormalizedPosixPathRelativeToTheWorkspaceRoot,
externalUpdatesCount,
workspaceRootAbsolutePosixPath,
externalModelsManagerDoneBootstraping,
]);
return <></>;
}
function DmnMarshallerFallbackError({ error }: { error: Error }) {
return (
<Flex justifyContent={{ default: "justifyContentCenter" }} style={{ marginTop: "100px" }}>
<EmptyState style={{ maxWidth: "1280px" }}>
<EmptyStateIcon icon={() => <div style={{ fontSize: "3em" }}>😕</div>} />
<Title size={"lg"} headingLevel={"h4"}>
Unable to open file.
</Title>
<br />
<EmptyStateBody>Error details: {error.message}</EmptyStateBody>
</EmptyState>
</Flex>
);
}