Skip to content

Commit b72e035

Browse files
authored
Merge branch 'main' into koesie10/storybook-a11y
2 parents 56697a9 + 95f43b7 commit b72e035

12 files changed

Lines changed: 758 additions & 21 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/stories/model-editor/MethodRow.stories.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { MethodRow as MethodRowComponent } from "../../view/model-editor/MethodR
66
import { CallClassification, Method } from "../../model-editor/method";
77
import { ModeledMethod } from "../../model-editor/modeled-method";
88
import { VSCodeDataGrid } from "@vscode/webview-ui-toolkit/react";
9-
import { GRID_TEMPLATE_COLUMNS } from "../../view/model-editor/ModeledMethodDataGrid";
9+
import {
10+
MULTIPLE_MODELS_GRID_TEMPLATE_COLUMNS,
11+
SINGLE_MODEL_GRID_TEMPLATE_COLUMNS,
12+
} from "../../view/model-editor/ModeledMethodDataGrid";
1013
import { ModelEditorViewState } from "../../model-editor/shared/view-state";
1114
import { createMockExtensionPack } from "../../../test/factories/model-editor/extension-pack";
1215
import { Mode } from "../../model-editor/shared/mode";
@@ -16,11 +19,16 @@ export default {
1619
component: MethodRowComponent,
1720
} as Meta<typeof MethodRowComponent>;
1821

19-
const Template: StoryFn<typeof MethodRowComponent> = (args) => (
20-
<VSCodeDataGrid gridTemplateColumns={GRID_TEMPLATE_COLUMNS}>
21-
<MethodRowComponent {...args} />
22-
</VSCodeDataGrid>
23-
);
22+
const Template: StoryFn<typeof MethodRowComponent> = (args) => {
23+
const gridTemplateColumns = args.viewState?.showMultipleModels
24+
? MULTIPLE_MODELS_GRID_TEMPLATE_COLUMNS
25+
: SINGLE_MODEL_GRID_TEMPLATE_COLUMNS;
26+
return (
27+
<VSCodeDataGrid gridTemplateColumns={gridTemplateColumns}>
28+
<MethodRowComponent {...args} />
29+
</VSCodeDataGrid>
30+
);
31+
};
2432

2533
const method: Method = {
2634
library: "sql2o-1.6.0.jar",

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { styled } from "styled-components";
2+
3+
/**
4+
* An element that will be hidden from sighted users, but visible to screen readers.
5+
*/
6+
export const ScreenReaderOnly = styled.div`
7+
position: absolute;
8+
left: -10000px;
9+
top: auto;
10+
width: 1px;
11+
height: 1px;
12+
overflow: hidden;
13+
`;
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: 27 additions & 8 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",
@@ -88,13 +100,6 @@ export const MultipleModeledMethodsPanel = ({
88100
setSelectedIndex(newSelectedIndex);
89101
}, [onChange, modeledMethods, selectedIndex]);
90102

91-
const anyUnmodeled = useMemo(
92-
() =>
93-
modeledMethods.length === 0 ||
94-
modeledMethods.some((m) => m.type === "none"),
95-
[modeledMethods],
96-
);
97-
98103
const handleChange = useCallback(
99104
(modeledMethod: ModeledMethod) => {
100105
if (modeledMethods.length > 0) {
@@ -110,6 +115,17 @@ export const MultipleModeledMethodsPanel = ({
110115

111116
return (
112117
<Container>
118+
{validationErrors.length > 0 && (
119+
<AlertContainer>
120+
{validationErrors.map((error, index) => (
121+
<ModeledMethodAlert
122+
key={index}
123+
error={error}
124+
setSelectedIndex={setSelectedIndex}
125+
/>
126+
))}
127+
</AlertContainer>
128+
)}
113129
{modeledMethods.length > 0 ? (
114130
<MethodModelingInputs
115131
method={method}
@@ -163,7 +179,10 @@ export const MultipleModeledMethodsPanel = ({
163179
appearance="icon"
164180
aria-label="Add modeling"
165181
onClick={handleAddClick}
166-
disabled={anyUnmodeled}
182+
disabled={
183+
modeledMethods.length === 0 ||
184+
(modeledMethods.length === 1 && modeledMethods[0].type === "none")
185+
}
167186
>
168187
<Codicon name="add" />
169188
</VSCodeButton>

0 commit comments

Comments
 (0)