CodeQL model editor: support saving single/selected models (#3156)

This commit is contained in:
Shati Patel
2024-01-15 16:51:46 +00:00
committed by GitHub
parent ea1419add2
commit 281f8eeb7a
13 changed files with 208 additions and 17 deletions

View File

@@ -2,6 +2,7 @@
## [UNRELEASED]
- In the CodeQL model editor, you can now select individual method rows and save changes to only the selected rows, instead of having to save the entire library model. [#3156](https://github.com/github/vscode-codeql/pull/3156)
- If you run a query without having selected a database, we show a more intuitive prompt to help you select a database. [#3214](https://github.com/github/vscode-codeql/pull/3214)
- The UI for browsing and running CodeQL tests has moved to use VS Code's built-in test UI. This makes the CodeQL test UI more consistent with the test UIs for other languages.
This change means that this extension no longer depends on the "Test Explorer UI" and "Test Adapter Converter" extensions. You can uninstall those two extensions if they are

View File

@@ -37,7 +37,10 @@ export function DataGrid({ gridTemplateColumns, children }: DataGridProps) {
);
}
const StyledDataGridRow = styled.div<{ $focused?: boolean }>`
const StyledDataGridRow = styled.div<{
$focused?: boolean;
$selected?: boolean;
}>`
display: contents;
&:hover > * {
@@ -48,14 +51,18 @@ const StyledDataGridRow = styled.div<{ $focused?: boolean }>`
// Use !important to override the background color set by the hover state
background-color: ${(props) =>
props.$focused
? "var(--vscode-editor-selectionBackground) !important"
: "inherit"};
? "var(--vscode-editor-findMatchHighlightBackground) !important"
: props.$selected
? "var(--vscode-editor-selectionBackground) !important"
: "inherit"};
}
`;
interface DataGridRowProps {
focused?: boolean;
selected?: boolean;
children: ReactNode;
onClick?: () => void;
"data-testid"?: string;
}
@@ -69,10 +76,22 @@ interface DataGridRowProps {
*/
export const DataGridRow = forwardRef(
(
{ focused, children, "data-testid": testId }: DataGridRowProps,
{
focused,
selected,
children,
"data-testid": testId,
onClick,
}: DataGridRowProps,
ref?: React.Ref<HTMLElement | undefined>,
) => (
<StyledDataGridRow $focused={focused} ref={ref} data-testid={testId}>
<StyledDataGridRow
$focused={focused}
$selected={selected}
ref={ref}
data-testid={testId}
onClick={onClick}
>
{children}
</StyledDataGridRow>
),

View File

@@ -25,6 +25,9 @@ type Props = {
"aria-label"?: string;
};
const stopClickPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
/**
* A dropdown implementation styled to look like `VSCodeDropdown`.
*
@@ -50,6 +53,7 @@ export function Dropdown({
value={disabled ? disabledValue : value}
disabled={disabled}
onChange={onChange}
onClick={stopClickPropagation}
className={className}
{...props}
>

View File

@@ -71,11 +71,13 @@ export type LibraryRowProps = {
methods: Method[];
modeledMethodsMap: Record<string, ModeledMethod[]>;
modifiedSignatures: Set<string>;
selectedSignatures: Set<string>;
inProgressMethods: Set<string>;
viewState: ModelEditorViewState;
hideModeledMethods: boolean;
revealedMethodSignature: string | null;
onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void;
onMethodClick: (methodSignature: string) => void;
onSaveModelClick: (methodSignatures: string[]) => void;
onGenerateFromLlmClick: (
dependencyName: string,
@@ -92,11 +94,13 @@ export const LibraryRow = ({
methods,
modeledMethodsMap,
modifiedSignatures,
selectedSignatures,
inProgressMethods,
viewState,
hideModeledMethods,
revealedMethodSignature,
onChange,
onMethodClick,
onSaveModelClick,
onGenerateFromLlmClick,
onStopGenerateFromLlmClick,
@@ -228,16 +232,18 @@ export const LibraryRow = ({
methods={methods}
modeledMethodsMap={modeledMethodsMap}
modifiedSignatures={modifiedSignatures}
selectedSignatures={selectedSignatures}
inProgressMethods={inProgressMethods}
viewState={viewState}
hideModeledMethods={hideModeledMethods}
revealedMethodSignature={revealedMethodSignature}
onChange={onChange}
onMethodClick={onMethodClick}
/>
<SectionDivider />
<ButtonsContainer>
<VSCodeButton onClick={handleSave} disabled={!hasUnsavedChanges}>
Save
{selectedSignatures.size === 0 ? "Save" : "Save selected"}
</VSCodeButton>
</ButtonsContainer>
</>

View File

@@ -70,10 +70,12 @@ export type MethodRowProps = {
methodCanBeModeled: boolean;
modeledMethods: ModeledMethod[];
methodIsUnsaved: boolean;
methodIsSelected: boolean;
modelingInProgress: boolean;
viewState: ModelEditorViewState;
revealedMethodSignature: string | null;
onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void;
onMethodClick: (methodSignature: string) => void;
};
export const MethodRow = (props: MethodRowProps) => {
@@ -103,9 +105,11 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
method,
modeledMethods: modeledMethodsProp,
methodIsUnsaved,
methodIsSelected,
viewState,
revealedMethodSignature,
onChange,
onMethodClick,
} = props;
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
@@ -186,6 +190,10 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
<DataGridRow
data-testid="modelable-method-row"
focused={revealedMethodSignature === method.signature}
selected={methodIsSelected}
onClick={() => {
onMethodClick(method.signature);
}}
>
<DataGridCell
gridRow={`span ${modeledMethods.length + validationErrors.length}`}
@@ -196,11 +204,23 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
<MethodClassifications method={method} />
<MethodName {...props.method} />
{viewState.mode === Mode.Application && (
<UsagesButton onClick={jumpToMethod}>
<UsagesButton
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
jumpToMethod();
}}
>
{method.usages.length}
</UsagesButton>
)}
<ViewLink onClick={jumpToMethod}>View</ViewLink>
<ViewLink
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
jumpToMethod();
}}
>
View
</ViewLink>
{props.modelingInProgress && <ProgressRing />}
</ApiOrMethodRow>
</DataGridCell>
@@ -269,7 +289,10 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
<CodiconRow
appearance="icon"
aria-label="Add new model"
onClick={handleAddModelClick}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
handleAddModelClick();
}}
disabled={addModelButtonDisabled}
>
<Codicon name="add" />
@@ -278,7 +301,10 @@ const ModelableMethodRow = forwardRef<HTMLElement | undefined, MethodRowProps>(
<CodiconRow
appearance="icon"
aria-label="Remove model"
onClick={removeModelClickedHandlers[index]}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
removeModelClickedHandlers[index]();
}}
>
<Codicon name="trash" />
</CodiconRow>

View File

@@ -95,6 +95,10 @@ export function ModelEditor({
new Set(),
);
const [selectedSignatures, setSelectedSignatures] = useState<Set<string>>(
new Set(),
);
const [inProgressMethods, setInProgressMethods] = useState<Set<string>>(
new Set(),
);
@@ -189,6 +193,19 @@ export function ModelEditor({
[],
);
const onMethodClick = useCallback(
(methodSignature: string) => {
const newSelectedSignatures = new Set(selectedSignatures);
if (selectedSignatures.has(methodSignature)) {
newSelectedSignatures.delete(methodSignature);
} else {
newSelectedSignatures.add(methodSignature);
}
setSelectedSignatures(newSelectedSignatures);
},
[selectedSignatures],
);
const onRefreshClick = useCallback(() => {
vscode.postMessage({
t: "refreshMethods",
@@ -198,15 +215,31 @@ export function ModelEditor({
const onSaveAllClick = useCallback(() => {
vscode.postMessage({
t: "saveModeledMethods",
methodSignatures:
selectedSignatures.size === 0
? undefined
: Array.from(selectedSignatures),
});
}, [selectedSignatures]);
const onDeselectAllClick = useCallback(() => {
setSelectedSignatures(new Set());
}, []);
const onSaveModelClick = useCallback((methodSignatures: string[]) => {
vscode.postMessage({
t: "saveModeledMethods",
methodSignatures,
});
}, []);
const onSaveModelClick = useCallback(
(methodSignatures: string[]) => {
vscode.postMessage({
t: "saveModeledMethods",
methodSignatures:
selectedSignatures.size === 0
? methodSignatures
: methodSignatures.filter((signature) =>
selectedSignatures.has(signature),
),
});
},
[selectedSignatures],
);
const onGenerateFromSourceClick = useCallback(() => {
vscode.postMessage({
@@ -309,7 +342,14 @@ export function ModelEditor({
onClick={onSaveAllClick}
disabled={modifiedSignatures.size === 0}
>
Save all
{selectedSignatures.size === 0 ? "Save all" : "Save selected"}
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={onDeselectAllClick}
disabled={selectedSignatures.size === 0}
>
Deselect all
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={onRefreshClick}>
Refresh
@@ -339,11 +379,13 @@ export function ModelEditor({
methods={methods}
modeledMethodsMap={modeledMethods}
modifiedSignatures={modifiedSignatures}
selectedSignatures={selectedSignatures}
inProgressMethods={inProgressMethods}
viewState={viewState}
hideModeledMethods={hideModeledMethods}
revealedMethodSignature={revealedMethodSignature}
onChange={onChange}
onMethodClick={onMethodClick}
onSaveModelClick={onSaveModelClick}
onGenerateFromLlmClick={onGenerateFromLlmClick}
onStopGenerateFromLlmClick={onStopGenerateFromLlmClick}

View File

@@ -16,22 +16,26 @@ export type ModeledMethodDataGridProps = {
methods: Method[];
modeledMethodsMap: Record<string, ModeledMethod[]>;
modifiedSignatures: Set<string>;
selectedSignatures: Set<string>;
inProgressMethods: Set<string>;
viewState: ModelEditorViewState;
hideModeledMethods: boolean;
revealedMethodSignature: string | null;
onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void;
onMethodClick: (methodSignature: string) => void;
};
export const ModeledMethodDataGrid = ({
methods,
modeledMethodsMap,
modifiedSignatures,
selectedSignatures,
inProgressMethods,
viewState,
hideModeledMethods,
revealedMethodSignature,
onChange,
onMethodClick,
}: ModeledMethodDataGridProps) => {
const [methodsWithModelability, numHiddenMethods]: [
Array<{ method: Method; methodCanBeModeled: boolean }>,
@@ -80,10 +84,12 @@ export const ModeledMethodDataGrid = ({
methodCanBeModeled={methodCanBeModeled}
modeledMethods={modeledMethods}
methodIsUnsaved={modifiedSignatures.has(method.signature)}
methodIsSelected={selectedSignatures.has(method.signature)}
modelingInProgress={inProgressMethods.has(method.signature)}
viewState={viewState}
revealedMethodSignature={revealedMethodSignature}
onChange={onChange}
onMethodClick={onMethodClick}
/>
);
})}

View File

@@ -13,11 +13,13 @@ export type ModeledMethodsListProps = {
methods: Method[];
modeledMethodsMap: Record<string, ModeledMethod[]>;
modifiedSignatures: Set<string>;
selectedSignatures: Set<string>;
inProgressMethods: Set<string>;
revealedMethodSignature: string | null;
viewState: ModelEditorViewState;
hideModeledMethods: boolean;
onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void;
onMethodClick: (methodSignature: string) => void;
onSaveModelClick: (methodSignatures: string[]) => void;
onGenerateFromLlmClick: (
packageName: string,
@@ -36,11 +38,13 @@ export const ModeledMethodsList = ({
methods,
modeledMethodsMap,
modifiedSignatures,
selectedSignatures,
inProgressMethods,
viewState,
hideModeledMethods,
revealedMethodSignature,
onChange,
onMethodClick,
onSaveModelClick,
onGenerateFromLlmClick,
onStopGenerateFromLlmClick,
@@ -82,11 +86,13 @@ export const ModeledMethodsList = ({
methods={grouped[libraryName]}
modeledMethodsMap={modeledMethodsMap}
modifiedSignatures={modifiedSignatures}
selectedSignatures={selectedSignatures}
inProgressMethods={inProgressMethods}
viewState={viewState}
hideModeledMethods={hideModeledMethods}
revealedMethodSignature={revealedMethodSignature}
onChange={onChange}
onMethodClick={onMethodClick}
onSaveModelClick={onSaveModelClick}
onGenerateFromLlmClick={onGenerateFromLlmClick}
onStopGenerateFromLlmClick={onStopGenerateFromLlmClick}

View File

@@ -8,6 +8,7 @@ import { createMockModelEditorViewState } from "../../../../test/factories/model
describe(LibraryRow.name, () => {
const method = createMethod();
const onChange = jest.fn();
const onMethodClick = jest.fn();
const onSaveModelClick = jest.fn();
const onGenerateFromLlmClick = jest.fn();
const onStopGenerateFromLlmClick = jest.fn();
@@ -33,11 +34,13 @@ describe(LibraryRow.name, () => {
],
}}
modifiedSignatures={new Set([method.signature])}
selectedSignatures={new Set()}
inProgressMethods={new Set()}
viewState={viewState}
hideModeledMethods={false}
revealedMethodSignature={null}
onChange={onChange}
onMethodClick={onMethodClick}
onSaveModelClick={onSaveModelClick}
onGenerateFromLlmClick={onGenerateFromLlmClick}
onStopGenerateFromLlmClick={onStopGenerateFromLlmClick}

View File

@@ -30,6 +30,7 @@ describe(MethodRow.name, () => {
provenance: "manual",
};
const onChange = jest.fn();
const onMethodClick = jest.fn();
const viewState = createMockModelEditorViewState();
@@ -40,10 +41,12 @@ describe(MethodRow.name, () => {
methodCanBeModeled={true}
modeledMethods={[modeledMethod]}
methodIsUnsaved={false}
methodIsSelected={false}
modelingInProgress={false}
revealedMethodSignature={null}
viewState={viewState}
onChange={onChange}
onMethodClick={onMethodClick}
{...props}
/>,
);

View File

@@ -0,0 +1,69 @@
import { act, render as reactRender, screen } from "@testing-library/react";
import { createMethod } from "../../../../test/factories/model-editor/method-factories";
import { ModelEditor } from "../ModelEditor";
import { createMockModelEditorViewState } from "../../../../test/factories/model-editor/view-state";
import { userEvent } from "@testing-library/user-event";
describe(ModelEditor.name, () => {
const method1 = createMethod({
library: "sql2o",
libraryVersion: "1.6.0",
signature: "org.sql2o.Connection#createQuery(String)",
packageName: "org.sql2o",
typeName: "Connection",
methodName: "createQuery",
methodParameters: "(String)",
supported: false,
});
const method2 = createMethod({
library: "sql2o",
libraryVersion: "1.6.0",
signature: "org.sql2o.Query#executeScalar(Class)",
packageName: "org.sql2o",
typeName: "Query",
methodName: "executeScalar",
methodParameters: "(Class)",
supported: false,
});
const method3 = createMethod({
library: "sql2o",
libraryVersion: "1.6.0",
signature: "org.sql2o.Sql2o#open()",
packageName: "org.sql2o",
typeName: "Sql2o",
methodName: "open",
methodParameters: "()",
supported: true,
});
const viewState = createMockModelEditorViewState();
const render = () =>
reactRender(
<ModelEditor
initialViewState={viewState}
initialMethods={[method1, method2, method3]}
/>,
);
it("renders Save button when no rows are selected", () => {
render();
expect(screen.getByText("Save all")).toBeInTheDocument();
});
it("renders Save button when rows are selected", async () => {
render();
await act(async () => {
await userEvent.click(screen.getAllByLabelText("Expand")[0]);
});
await act(async () => {
await userEvent.click(screen.getAllByTestId("modelable-method-row")[0]);
});
// The top-level Save button and the per-library Save button should have been updated.
expect(screen.getAllByText("Save selected")).toHaveLength(2);
});
});

View File

@@ -36,6 +36,7 @@ describe(ModeledMethodDataGrid.name, () => {
supported: true,
});
const onChange = jest.fn();
const onMethodClick = jest.fn();
const viewState = createMockModelEditorViewState();
@@ -55,11 +56,13 @@ describe(ModeledMethodDataGrid.name, () => {
],
}}
modifiedSignatures={new Set([method1.signature])}
selectedSignatures={new Set()}
inProgressMethods={new Set()}
viewState={viewState}
hideModeledMethods={false}
revealedMethodSignature={null}
onChange={onChange}
onMethodClick={onMethodClick}
{...props}
/>,
);

View File

@@ -33,6 +33,7 @@ describe(ModeledMethodsList.name, () => {
methodParameters: "(String)",
});
const onChange = jest.fn();
const onMethodClick = jest.fn();
const onSaveModelClick = jest.fn();
const onGenerateFromLlmClick = jest.fn();
const onStopGenerateFromLlmClick = jest.fn();
@@ -56,11 +57,13 @@ describe(ModeledMethodsList.name, () => {
],
}}
modifiedSignatures={new Set([method1.signature])}
selectedSignatures={new Set()}
inProgressMethods={new Set()}
viewState={viewState}
hideModeledMethods={false}
revealedMethodSignature={null}
onChange={onChange}
onMethodClick={onMethodClick}
onSaveModelClick={onSaveModelClick}
onGenerateFromLlmClick={onGenerateFromLlmClick}
onStopGenerateFromLlmClick={onStopGenerateFromLlmClick}