Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add model validation in method modeling panel #2936

Merged
merged 6 commits into from
Oct 12, 2023
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
145 changes: 145 additions & 0 deletions extensions/ql-vscode/src/model-editor/shared/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { ModeledMethod } from "../modeled-method";
import { MethodSignature } from "../method";
import { assertNever } from "../../common/helpers-pure";

export type ModeledMethodValidationError = {
title: string;
message: string;
actionText: string;
index: number;
};

/**
* This method will reset any properties which are not used for the specific type of modeled method.
*
* It will also set the `provenance` to `manual` since multiple modelings of the same method with a
* different provenance are not actually different.
*
* The returned canonical modeled method should only be used for comparisons. It should not be used
* for display purposes, saving the model, or any other purpose which requires the original modeled
* method to be preserved.
*
* @param modeledMethod The modeled method to canonicalize
*/
function canonicalizeModeledMethod(
modeledMethod: ModeledMethod,
): ModeledMethod {
const methodSignature: MethodSignature = {
signature: modeledMethod.signature,
packageName: modeledMethod.packageName,
typeName: modeledMethod.typeName,
methodName: modeledMethod.methodName,
methodParameters: modeledMethod.methodParameters,
};

switch (modeledMethod.type) {
case "none":
return {
...methodSignature,
type: "none",
input: "",
output: "",
kind: "",
provenance: "manual",
};
case "source":
return {
...methodSignature,
type: "source",
input: "",
output: modeledMethod.output,
kind: modeledMethod.kind,
provenance: "manual",
};
case "sink":
return {
...methodSignature,
type: "sink",
input: modeledMethod.input,
output: "",
kind: modeledMethod.kind,
provenance: "manual",
};
case "summary":
return {
...methodSignature,
type: "summary",
input: modeledMethod.input,
output: modeledMethod.output,
kind: modeledMethod.kind,
provenance: "manual",
};
case "neutral":
return {
...methodSignature,
type: "neutral",
input: "",
output: "",
kind: "",
provenance: "manual",
};
default:
assertNever(modeledMethod.type);
}
}

export function validateModeledMethods(
modeledMethods: ModeledMethod[],
): ModeledMethodValidationError[] {
// Anything that is not modeled will not be saved, so we don't need to validate it
const consideredModeledMethods = modeledMethods.filter(
(modeledMethod) => modeledMethod.type !== "none",
);

const result: ModeledMethodValidationError[] = [];

// If the same model is present multiple times, only the first one makes sense, so we should give
// an error for any duplicates.
const seenModeledMethods = new Set<string>();
for (const modeledMethod of consideredModeledMethods) {
const canonicalModeledMethod = canonicalizeModeledMethod(modeledMethod);
const key = JSON.stringify(
canonicalModeledMethod,
// This ensures the keys are always in the same order
Object.keys(canonicalModeledMethod).sort(),
);

if (seenModeledMethods.has(key)) {
result.push({
title: "Duplicated classification",
message:
"This method has two identical or conflicting classifications.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the extreme case of more than two duplicate modelings, do we want to avoid adding multiple errors? I'm unsure. I'd say it's a very unlikely case, so perhaps not worth putting in the implementation effort to give a nice user experience in this case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure either, but I don't think it's worth handling this case for a very rare case.

actionText: "Modify or remove the duplicated classification.",
index: modeledMethods.indexOf(modeledMethod),
});
} else {
seenModeledMethods.add(key);
}
}

const neutralModeledMethod = consideredModeledMethods.find(
(modeledMethod) => modeledMethod.type === "neutral",
);
const hasNonNeutralModeledMethod = consideredModeledMethods.some(
(modeledMethod) => modeledMethod.type !== "neutral",
);

// If there is a neutral model and any other model, that is an error
if (neutralModeledMethod && hasNonNeutralModeledMethod) {
// Another validation will validate that only one neutral method is present, so we only need
// to return an error for the first one

result.push({
title: "Conflicting classification",
message:
"This method has a neutral classification, which conflicts with other classifications.",
actionText: "Modify or remove the neutral classification.",
index: modeledMethods.indexOf(neutralModeledMethod),
});
}

// Sort by index so that the errors are always in the same order
result.sort((a, b) => a.index - b.index);

return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,35 @@ MultipleModelingsModeledMultiple.args = {
showMultipleModels: true,
modelingStatus: "saved",
};

export const MultipleModelingsValidationFailedNeutral = Template.bind({});
MultipleModelingsValidationFailedNeutral.args = {
method,
modeledMethods: [
createModeledMethod(method),
createModeledMethod({
...method,
type: "neutral",
}),
],
showMultipleModels: true,
modelingStatus: "unsaved",
};

export const MultipleModelingsValidationFailedDuplicate = Template.bind({});
MultipleModelingsValidationFailedDuplicate.args = {
method,
modeledMethods: [
createModeledMethod(method),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
createModeledMethod(method),
],
showMultipleModels: true,
modelingStatus: "unsaved",
};
23 changes: 21 additions & 2 deletions extensions/ql-vscode/src/view/common/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,30 @@ type Props = {

// Inverse the color scheme
inverse?: boolean;

/**
* Role is used as the ARIA role. "alert" should only be set if the alert requires
* the user's immediate attention. "status" should be set if the alert is not
* important enough to require the user's immediate attention.
*
* Can be left out if the alert is not important enough to require the user's
koesie10 marked this conversation as resolved.
Show resolved Hide resolved
* immediate attention. In this case, no ARIA role will be set and the alert
* will be read as normal text. The user will not be notified about any changes
* to the alert.
*/
role?: "alert" | "status";
};

export const Alert = ({ type, title, message, actions, inverse }: Props) => {
export const Alert = ({
type,
title,
message,
actions,
inverse,
role,
}: Props) => {
return (
<Container type={type} inverse={inverse}>
<Container type={type} inverse={inverse} role={role}>
<Title>
{getTypeText(type)}: {title}
</Title>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ModeledMethodValidationError } from "../../model-editor/shared/validation";
import TextButton from "../common/TextButton";
import { Alert } from "../common";
import * as React from "react";
import { useCallback } from "react";

type Props = {
error: ModeledMethodValidationError;
setSelectedIndex: (index: number) => void;
};

export const ModeledMethodAlert = ({ error, setSelectedIndex }: Props) => {
const handleClick = useCallback(() => {
setSelectedIndex(error.index);
}, [error.index, setSelectedIndex]);

return (
<Alert
role="alert"
type="error"
title={error.title}
message={
<>
{error.message}{" "}
<TextButton onClick={handleClick}>{error.actionText}</TextButton>
</>
}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { styled } from "styled-components";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { Codicon } from "../common";
import { validateModeledMethods } from "../../model-editor/shared/validation";
import { ModeledMethodAlert } from "./ModeledMethodAlert";

export type MultipleModeledMethodsPanelProps = {
method: Method;
Expand All @@ -19,9 +21,14 @@ const Container = styled.div`
gap: 0.25rem;

padding-bottom: 0.5rem;
border-top: 0.05rem solid var(--vscode-panelSection-border);
border-bottom: 0.05rem solid var(--vscode-panelSection-border);
`;

const AlertContainer = styled.div`
margin-top: 0.5rem;
`;

const Footer = styled.div`
display: flex;
flex-direction: row;
Expand Down Expand Up @@ -54,6 +61,11 @@ export const MultipleModeledMethodsPanel = ({
setSelectedIndex((previousIndex) => previousIndex + 1);
}, []);

const validationErrors = useMemo(
() => validateModeledMethods(modeledMethods),
[modeledMethods],
);

const handleAddClick = useCallback(() => {
const newModeledMethod: ModeledMethod = {
type: "none",
Expand Down Expand Up @@ -110,6 +122,17 @@ export const MultipleModeledMethodsPanel = ({

return (
<Container>
{validationErrors.length > 0 && (
<AlertContainer>
{validationErrors.map((error, index) => (
<ModeledMethodAlert
key={index}
error={error}
setSelectedIndex={setSelectedIndex}
/>
))}
</AlertContainer>
)}
{modeledMethods.length > 0 ? (
<MethodModelingInputs
method={method}
Expand Down
Loading
Loading