Skip to content

Commit 7e3cb75

Browse files
authored
Merge pull request #2936 from github/koesie10/method-modeling-panel-validation
Add model validation in method modeling panel
2 parents ac4ccf4 + bcceae4 commit 7e3cb75

8 files changed

Lines changed: 683 additions & 3 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ModeledMethod } from "../modeled-method";
2+
import { MethodSignature } from "../method";
3+
import { assertNever } from "../../common/helpers-pure";
4+
5+
export type ModeledMethodValidationError = {
6+
title: string;
7+
message: string;
8+
actionText: string;
9+
index: number;
10+
};
11+
12+
/**
13+
* This method will reset any properties which are not used for the specific type of modeled method.
14+
*
15+
* It will also set the `provenance` to `manual` since multiple modelings of the same method with a
16+
* different provenance are not actually different.
17+
*
18+
* The returned canonical modeled method should only be used for comparisons. It should not be used
19+
* for display purposes, saving the model, or any other purpose which requires the original modeled
20+
* method to be preserved.
21+
*
22+
* @param modeledMethod The modeled method to canonicalize
23+
*/
24+
function canonicalizeModeledMethod(
25+
modeledMethod: ModeledMethod,
26+
): ModeledMethod {
27+
const methodSignature: MethodSignature = {
28+
signature: modeledMethod.signature,
29+
packageName: modeledMethod.packageName,
30+
typeName: modeledMethod.typeName,
31+
methodName: modeledMethod.methodName,
32+
methodParameters: modeledMethod.methodParameters,
33+
};
34+
35+
switch (modeledMethod.type) {
36+
case "none":
37+
return {
38+
...methodSignature,
39+
type: "none",
40+
input: "",
41+
output: "",
42+
kind: "",
43+
provenance: "manual",
44+
};
45+
case "source":
46+
return {
47+
...methodSignature,
48+
type: "source",
49+
input: "",
50+
output: modeledMethod.output,
51+
kind: modeledMethod.kind,
52+
provenance: "manual",
53+
};
54+
case "sink":
55+
return {
56+
...methodSignature,
57+
type: "sink",
58+
input: modeledMethod.input,
59+
output: "",
60+
kind: modeledMethod.kind,
61+
provenance: "manual",
62+
};
63+
case "summary":
64+
return {
65+
...methodSignature,
66+
type: "summary",
67+
input: modeledMethod.input,
68+
output: modeledMethod.output,
69+
kind: modeledMethod.kind,
70+
provenance: "manual",
71+
};
72+
case "neutral":
73+
return {
74+
...methodSignature,
75+
type: "neutral",
76+
input: "",
77+
output: "",
78+
kind: "",
79+
provenance: "manual",
80+
};
81+
default:
82+
assertNever(modeledMethod.type);
83+
}
84+
}
85+
86+
export function validateModeledMethods(
87+
modeledMethods: ModeledMethod[],
88+
): ModeledMethodValidationError[] {
89+
// Anything that is not modeled will not be saved, so we don't need to validate it
90+
const consideredModeledMethods = modeledMethods.filter(
91+
(modeledMethod) => modeledMethod.type !== "none",
92+
);
93+
94+
const result: ModeledMethodValidationError[] = [];
95+
96+
// If the same model is present multiple times, only the first one makes sense, so we should give
97+
// an error for any duplicates.
98+
const seenModeledMethods = new Set<string>();
99+
for (const modeledMethod of consideredModeledMethods) {
100+
const canonicalModeledMethod = canonicalizeModeledMethod(modeledMethod);
101+
const key = JSON.stringify(
102+
canonicalModeledMethod,
103+
// This ensures the keys are always in the same order
104+
Object.keys(canonicalModeledMethod).sort(),
105+
);
106+
107+
if (seenModeledMethods.has(key)) {
108+
result.push({
109+
title: "Duplicated classification",
110+
message:
111+
"This method has two identical or conflicting classifications.",
112+
actionText: "Modify or remove the duplicated classification.",
113+
index: modeledMethods.indexOf(modeledMethod),
114+
});
115+
} else {
116+
seenModeledMethods.add(key);
117+
}
118+
}
119+
120+
const neutralModeledMethod = consideredModeledMethods.find(
121+
(modeledMethod) => modeledMethod.type === "neutral",
122+
);
123+
const hasNonNeutralModeledMethod = consideredModeledMethods.some(
124+
(modeledMethod) => modeledMethod.type !== "neutral",
125+
);
126+
127+
// If there is a neutral model and any other model, that is an error
128+
if (neutralModeledMethod && hasNonNeutralModeledMethod) {
129+
// Another validation will validate that only one neutral method is present, so we only need
130+
// to return an error for the first one
131+
132+
result.push({
133+
title: "Conflicting classification",
134+
message:
135+
"This method has a neutral classification, which conflicts with other classifications.",
136+
actionText: "Modify or remove the neutral classification.",
137+
index: modeledMethods.indexOf(neutralModeledMethod),
138+
});
139+
}
140+
141+
// Sort by index so that the errors are always in the same order
142+
result.sort((a, b) => a.index - b.index);
143+
144+
return result;
145+
}

extensions/ql-vscode/src/stories/method-modeling/MethodModeling.stories.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,35 @@ MultipleModelingsModeledMultiple.args = {
6969
showMultipleModels: true,
7070
modelingStatus: "saved",
7171
};
72+
73+
export const MultipleModelingsValidationFailedNeutral = Template.bind({});
74+
MultipleModelingsValidationFailedNeutral.args = {
75+
method,
76+
modeledMethods: [
77+
createModeledMethod(method),
78+
createModeledMethod({
79+
...method,
80+
type: "neutral",
81+
}),
82+
],
83+
showMultipleModels: true,
84+
modelingStatus: "unsaved",
85+
};
86+
87+
export const MultipleModelingsValidationFailedDuplicate = Template.bind({});
88+
MultipleModelingsValidationFailedDuplicate.args = {
89+
method,
90+
modeledMethods: [
91+
createModeledMethod(method),
92+
createModeledMethod({
93+
...method,
94+
type: "source",
95+
input: "",
96+
output: "ReturnValue",
97+
kind: "remote",
98+
}),
99+
createModeledMethod(method),
100+
],
101+
showMultipleModels: true,
102+
modelingStatus: "unsaved",
103+
};

extensions/ql-vscode/src/view/common/Alert.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,30 @@ type Props = {
8585

8686
// Inverse the color scheme
8787
inverse?: boolean;
88+
89+
/**
90+
* Role is used as the ARIA role. "alert" should only be set if the alert requires
91+
* the user's immediate attention. "status" should be set if the alert is not
92+
* important enough to require the user's immediate attention.
93+
*
94+
* Can be left out if the alert is not important enough to require the user's
95+
* immediate attention. In this case, no ARIA role will be set and the alert
96+
* will be read as normal text. The user will not be notified about any changes
97+
* to the alert.
98+
*/
99+
role?: "alert" | "status";
88100
};
89101

90-
export const Alert = ({ type, title, message, actions, inverse }: Props) => {
102+
export const Alert = ({
103+
type,
104+
title,
105+
message,
106+
actions,
107+
inverse,
108+
role,
109+
}: Props) => {
91110
return (
92-
<Container type={type} inverse={inverse}>
111+
<Container type={type} inverse={inverse} role={role}>
93112
<Title>
94113
{getTypeText(type)}: {title}
95114
</Title>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ModeledMethodValidationError } from "../../model-editor/shared/validation";
2+
import TextButton from "../common/TextButton";
3+
import { Alert } from "../common";
4+
import * as React from "react";
5+
import { useCallback } from "react";
6+
7+
type Props = {
8+
error: ModeledMethodValidationError;
9+
setSelectedIndex: (index: number) => void;
10+
};
11+
12+
export const ModeledMethodAlert = ({ error, setSelectedIndex }: Props) => {
13+
const handleClick = useCallback(() => {
14+
setSelectedIndex(error.index);
15+
}, [error.index, setSelectedIndex]);
16+
17+
return (
18+
<Alert
19+
role="alert"
20+
type="error"
21+
title={error.title}
22+
message={
23+
<>
24+
{error.message}{" "}
25+
<TextButton onClick={handleClick}>{error.actionText}</TextButton>
26+
</>
27+
}
28+
/>
29+
);
30+
};

extensions/ql-vscode/src/view/method-modeling/MultipleModeledMethodsPanel.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { styled } from "styled-components";
66
import { MethodModelingInputs } from "./MethodModelingInputs";
77
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
88
import { Codicon } from "../common";
9+
import { validateModeledMethods } from "../../model-editor/shared/validation";
10+
import { ModeledMethodAlert } from "./ModeledMethodAlert";
911

1012
export type MultipleModeledMethodsPanelProps = {
1113
method: Method;
@@ -19,9 +21,14 @@ const Container = styled.div`
1921
gap: 0.25rem;
2022
2123
padding-bottom: 0.5rem;
24+
border-top: 0.05rem solid var(--vscode-panelSection-border);
2225
border-bottom: 0.05rem solid var(--vscode-panelSection-border);
2326
`;
2427

28+
const AlertContainer = styled.div`
29+
margin-top: 0.5rem;
30+
`;
31+
2532
const Footer = styled.div`
2633
display: flex;
2734
flex-direction: row;
@@ -54,6 +61,11 @@ export const MultipleModeledMethodsPanel = ({
5461
setSelectedIndex((previousIndex) => previousIndex + 1);
5562
}, []);
5663

64+
const validationErrors = useMemo(
65+
() => validateModeledMethods(modeledMethods),
66+
[modeledMethods],
67+
);
68+
5769
const handleAddClick = useCallback(() => {
5870
const newModeledMethod: ModeledMethod = {
5971
type: "none",
@@ -110,6 +122,17 @@ export const MultipleModeledMethodsPanel = ({
110122

111123
return (
112124
<Container>
125+
{validationErrors.length > 0 && (
126+
<AlertContainer>
127+
{validationErrors.map((error, index) => (
128+
<ModeledMethodAlert
129+
key={index}
130+
error={error}
131+
setSelectedIndex={setSelectedIndex}
132+
/>
133+
))}
134+
</AlertContainer>
135+
)}
113136
{modeledMethods.length > 0 ? (
114137
<MethodModelingInputs
115138
method={method}

0 commit comments

Comments
 (0)