Merge pull request #2921 from github/koesie10/modeling-panel-multiple-models

Add ability for method modeling panel to render multiple modelings of the same method
This commit is contained in:
Koen Vlaswinkel
2023-10-09 14:27:30 +02:00
committed by GitHub
11 changed files with 574 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ import { Meta, StoryFn } from "@storybook/react";
import { MethodModeling as MethodModelingComponent } from "../../view/method-modeling/MethodModeling";
import { createMethod } from "../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../test/factories/model-editor/modeled-method-factories";
export default {
title: "Method Modeling/Method Modeling",
component: MethodModelingComponent,
@@ -18,18 +19,53 @@ const method = createMethod();
export const MethodUnmodeled = Template.bind({});
MethodUnmodeled.args = {
method,
modeledMethods: [],
modelingStatus: "unmodeled",
};
export const MethodModeled = Template.bind({});
MethodModeled.args = {
method,
modeledMethods: [],
modelingStatus: "unsaved",
};
export const MethodSaved = Template.bind({});
MethodSaved.args = {
method,
modeledMethods: [],
modelingStatus: "saved",
};
export const MultipleModelingsUnmodeled = Template.bind({});
MultipleModelingsUnmodeled.args = {
method,
modeledMethods: [],
showMultipleModels: true,
modelingStatus: "saved",
};
export const MultipleModelingsModeledSingle = Template.bind({});
MultipleModelingsModeledSingle.args = {
method,
modeledMethods: [createModeledMethod(method)],
showMultipleModels: true,
modelingStatus: "saved",
};
export const MultipleModelingsModeledMultiple = Template.bind({});
MultipleModelingsModeledMultiple.args = {
method,
modeledMethods: [
createModeledMethod(method),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
],
showMultipleModels: true,
modelingStatus: "saved",
};

View File

@@ -4,7 +4,7 @@ import classNames from "classnames";
type Props = {
name: string;
label: string;
label?: string;
className?: string;
slot?: string;
};

View File

@@ -5,9 +5,9 @@ import { ModelingStatusIndicator } from "../model-editor/ModelingStatusIndicator
import { Method } from "../../model-editor/method";
import { MethodName } from "../model-editor/MethodName";
import { ModeledMethod } from "../../model-editor/modeled-method";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { VSCodeTag } from "@vscode/webview-ui-toolkit/react";
import { ReviewInEditorButton } from "./ReviewInEditorButton";
import { ModeledMethodsPanel } from "./ModeledMethodsPanel";
const Container = styled.div`
padding-top: 0.5rem;
@@ -38,10 +38,6 @@ const DependencyContainer = styled.div`
margin-bottom: 0.8rem;
`;
const StyledMethodModelingInputs = styled(MethodModelingInputs)`
padding-bottom: 0.5rem;
`;
const StyledVSCodeTag = styled(VSCodeTag)<{ visible: boolean }>`
visibility: ${(props) => (props.visible ? "visible" : "hidden")};
`;
@@ -55,15 +51,16 @@ const UnsavedTag = ({ modelingStatus }: { modelingStatus: ModelingStatus }) => (
export type MethodModelingProps = {
modelingStatus: ModelingStatus;
method: Method;
modeledMethod: ModeledMethod | undefined;
modeledMethods: ModeledMethod[];
showMultipleModels?: boolean;
onChange: (modeledMethod: ModeledMethod) => void;
};
export const MethodModeling = ({
modelingStatus,
modeledMethod,
modeledMethods,
method,
showMultipleModels = false,
onChange,
}: MethodModelingProps): JSX.Element => {
return (
@@ -77,9 +74,10 @@ export const MethodModeling = ({
<ModelingStatusIndicator status={modelingStatus} />
<MethodName {...method} />
</DependencyContainer>
<StyledMethodModelingInputs
<ModeledMethodsPanel
method={method}
modeledMethod={modeledMethod}
modeledMethods={modeledMethods}
showMultipleModels={showMultipleModels}
onChange={onChange}
/>
<ReviewInEditorButton method={method} />

View File

@@ -94,7 +94,7 @@ export function MethodModelingView({ initialViewState }: Props): JSX.Element {
<MethodModeling
modelingStatus={modelingStatus}
method={method}
modeledMethod={modeledMethod}
modeledMethods={modeledMethod ? [modeledMethod] : []}
showMultipleModels={viewState?.showMultipleModels}
onChange={onChange}
/>

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { ModeledMethod } from "../../model-editor/modeled-method";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { Method } from "../../model-editor/method";
import { styled } from "styled-components";
import { MultipleModeledMethodsPanel } from "./MultipleModeledMethodsPanel";
export type ModeledMethodsPanelProps = {
method: Method;
modeledMethods: ModeledMethod[];
showMultipleModels: boolean;
onChange: (modeledMethod: ModeledMethod) => void;
};
const SingleMethodModelingInputs = styled(MethodModelingInputs)`
padding-bottom: 0.5rem;
`;
export const ModeledMethodsPanel = ({
method,
modeledMethods,
showMultipleModels,
onChange,
}: ModeledMethodsPanelProps) => {
if (!showMultipleModels) {
return (
<SingleMethodModelingInputs
method={method}
modeledMethod={
modeledMethods.length > 0 ? modeledMethods[0] : undefined
}
onChange={onChange}
/>
);
}
return (
<MultipleModeledMethodsPanel
method={method}
modeledMethods={modeledMethods}
onChange={onChange}
/>
);
};

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import { useCallback, useState } from "react";
import { Method } from "../../model-editor/method";
import { ModeledMethod } from "../../model-editor/modeled-method";
import { styled } from "styled-components";
import { MethodModelingInputs } from "./MethodModelingInputs";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { Codicon } from "../common";
export type MultipleModeledMethodsPanelProps = {
method: Method;
modeledMethods: ModeledMethod[];
onChange: (modeledMethod: ModeledMethod) => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 0.5rem;
border-bottom: 0.05rem solid var(--vscode-panelSection-border);
`;
const Footer = styled.div`
display: flex;
flex-direction: row;
`;
const PaginationActions = styled.div`
display: flex;
flex-direction: row;
gap: 0.5rem;
`;
export const MultipleModeledMethodsPanel = ({
method,
modeledMethods,
onChange,
}: MultipleModeledMethodsPanelProps) => {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const handlePreviousClick = useCallback(() => {
setSelectedIndex((previousIndex) => previousIndex - 1);
}, []);
const handleNextClick = useCallback(() => {
setSelectedIndex((previousIndex) => previousIndex + 1);
}, []);
return (
<Container>
{modeledMethods.length > 0 ? (
<MethodModelingInputs
method={method}
modeledMethod={modeledMethods[selectedIndex]}
onChange={onChange}
/>
) : (
<MethodModelingInputs
method={method}
modeledMethod={undefined}
onChange={onChange}
/>
)}
<Footer>
<PaginationActions>
<VSCodeButton
appearance="icon"
aria-label="Previous modeling"
onClick={handlePreviousClick}
disabled={modeledMethods.length < 2 || selectedIndex === 0}
>
<Codicon name="chevron-left" />
</VSCodeButton>
{modeledMethods.length > 1 && (
<div>
{selectedIndex + 1}/{modeledMethods.length}
</div>
)}
<VSCodeButton
appearance="icon"
aria-label="Next modeling"
onClick={handleNextClick}
disabled={
modeledMethods.length < 2 ||
selectedIndex === modeledMethods.length - 1
}
>
<Codicon name="chevron-right" />
</VSCodeButton>
</PaginationActions>
</Footer>
</Container>
);
};

View File

@@ -16,7 +16,7 @@ describe(MethodModeling.name, () => {
render({
modelingStatus: "saved",
method,
modeledMethod,
modeledMethods: [modeledMethod],
onChange,
});

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { render as reactRender, screen } from "@testing-library/react";
import { createMethod } from "../../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories";
import {
ModeledMethodsPanel,
ModeledMethodsPanelProps,
} from "../ModeledMethodsPanel";
describe(ModeledMethodsPanel.name, () => {
const render = (props: ModeledMethodsPanelProps) =>
reactRender(<ModeledMethodsPanel {...props} />);
const method = createMethod();
const modeledMethods = [createModeledMethod(), createModeledMethod()];
const onChange = jest.fn();
describe("when show multiple models is disabled", () => {
const showMultipleModels = false;
it("renders the method modeling inputs", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
});
it("does not render the pagination", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(
screen.queryByLabelText("Previous modeling"),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Next modeling")).not.toBeInTheDocument();
});
});
describe("when show multiple models is enabled", () => {
const showMultipleModels = true;
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
});
it("renders the pagination", () => {
render({
method,
modeledMethods,
onChange,
showMultipleModels,
});
expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument();
expect(screen.getByLabelText("Next modeling")).toBeInTheDocument();
expect(screen.getByText("1/2")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,312 @@
import * as React from "react";
import { render as reactRender, screen, waitFor } from "@testing-library/react";
import { createMethod } from "../../../../test/factories/model-editor/method-factories";
import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories";
import {
MultipleModeledMethodsPanel,
MultipleModeledMethodsPanelProps,
} from "../MultipleModeledMethodsPanel";
import userEvent from "@testing-library/user-event";
import { ModeledMethod } from "../../../model-editor/modeled-method";
describe(MultipleModeledMethodsPanel.name, () => {
const render = (props: MultipleModeledMethodsPanelProps) =>
reactRender(<MultipleModeledMethodsPanel {...props} />);
const method = createMethod();
const onChange = jest.fn();
describe("with no modeled methods", () => {
const modeledMethods: ModeledMethod[] = [];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("none");
});
it("disables all pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.queryByText("0/0")).not.toBeInTheDocument();
expect(screen.queryByText("1/0")).not.toBeInTheDocument();
});
});
describe("with one modeled method", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("sink");
});
it("disables all pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.queryByText("1/1")).not.toBeInTheDocument();
});
});
describe("with two modeled methods", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
];
it("renders the method modeling inputs once", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getAllByRole("combobox")).toHaveLength(4);
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("sink");
});
it("renders the pagination", () => {
render({
method,
modeledMethods,
onChange,
});
expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument();
expect(screen.getByLabelText("Next modeling")).toBeInTheDocument();
expect(screen.getByText("1/2")).toBeInTheDocument();
});
it("disables the correct pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
});
it("can use the pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
await userEvent.click(screen.getByLabelText("Next modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.getByText("2/2")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("source");
});
});
describe("with three modeled methods", () => {
const modeledMethods = [
createModeledMethod({
...method,
type: "sink",
input: "Argument[this]",
output: "",
kind: "path-injection",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "remote",
}),
createModeledMethod({
...method,
type: "source",
input: "",
output: "ReturnValue",
kind: "local",
}),
];
it("can use the pagination", async () => {
render({
method,
modeledMethods,
onChange,
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeDisabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("1/3")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Next modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("2/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Model type",
}),
).toHaveValue("source");
await userEvent.click(screen.getByLabelText("Next modeling"));
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeDisabled();
expect(screen.getByText("3/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Kind",
}),
).toHaveValue("local");
await userEvent.click(screen.getByLabelText("Previous modeling"));
await waitFor(() => {
expect(
screen
.getByLabelText("Next modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
});
expect(
screen
.getByLabelText("Previous modeling")
.getElementsByTagName("input")[0],
).toBeEnabled();
expect(
screen.getByLabelText("Next modeling").getElementsByTagName("input")[0],
).toBeEnabled();
expect(screen.getByText("2/3")).toBeInTheDocument();
expect(
screen.getByRole("combobox", {
name: "Kind",
}),
).toHaveValue("remote");
});
});
});

View File

@@ -57,6 +57,7 @@ describe(ModelKindDropdown.name, () => {
// Changing the type to sink should update the supported kinds
const updatedModeledMethod = createModeledMethod({
type: "sink",
kind: "local",
});
rerender(

View File

@@ -13,7 +13,7 @@ export function createModeledMethod(
type: "sink",
input: "Argument[0]",
output: "",
kind: "jndi-injection",
kind: "path-injection",
provenance: "manual",
...data,
};