merge conflict

This commit is contained in:
Sung-Hyun Kang 2024-06-17 13:40:18 -05:00
commit f7b7d135df
96 changed files with 3572 additions and 2577 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# NOTE: Prettier reads EditorConfig settings, so be careful adjusting settings here and assuming they'll only affect your editor ;).
# top-most EditorConfig file
root = true
[*.yml]
indent_size = 2
[*.{js,jsx,ts,tsx}]
indent_size = 2

View File

@ -1,3 +1,5 @@
playwright.config.ts
**/node_modules/
src/**/__mocks__/**/*
dist/

View File

@ -104,79 +104,6 @@ jobs:
run: az storage blob upload -c '$web' -f ./preview/config.json --account-name cosmosexplorerpreview --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --account-key="${PREVIEW_STORAGE_KEY}" --overwrite true
env:
PREVIEW_STORAGE_KEY: ${{ secrets.PREVIEW_STORAGE_KEY }}
endtoendemulator:
name: "End To End Emulator Tests"
# Temporarily disabled. This test needs to be rewritten in playwright
if: false
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- uses: southpolesteve/cosmos-emulator-github-action@v1
- name: End to End Tests
run: |
npm ci
npm start &
npm run wait-for-server
npx jest -c ./jest.config.e2e.js --detectOpenHandles test/sql/container.spec.ts
shell: bash
env:
DATA_EXPLORER_ENDPOINT: "https://localhost:1234/explorer.html?platform=Emulator"
PLATFORM: "Emulator"
NODE_TLS_REJECT_UNAUTHORIZED: 0
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: failed-*
endtoend:
name: "E2E"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
strategy:
fail-fast: false
matrix:
test-file:
- ./test/cassandra/container.spec.ts
- ./test/graph/container.spec.ts
- ./test/sql/container.spec.ts
- ./test/mongo/container.spec.ts
- ./test/mongo/container32.spec.ts
- ./test/selfServe/selfServeExample.spec.ts
- ./test/sql/resourceToken.spec.ts
- ./test/tables/container.spec.ts
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npm start &
- run: npm run wait-for-server
- name: ${{ matrix['test-file'] }}
run: |
# Run tests up to three times
for i in $(seq 1 3); do npx jest -c ./jest.config.playwright.js ${{ matrix['test-file'] }} && s=0 && break || s=$? && sleep 1; done; (exit $s)
shell: bash
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: screenshots/
nuget:
name: Publish Nuget
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
@ -226,3 +153,70 @@ jobs:
name: packages
with:
path: "*.nupkg"
playwright-tests:
name: "Run Playwright Tests (Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }})"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npx playwright install --with-deps
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-playwright-reports:
name: "Merge Playwright Reports"
# Merge reports after playwright-tests, even if some shards have failed
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14

4
.gitignore vendored
View File

@ -17,3 +17,7 @@ Contracts/*
failure.png
screenshots/*
GettingStarted-ignore*.ipynb
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -1,13 +0,0 @@
const isCI = require("is-ci");
module.exports = {
exitOnPageError: false,
launchOptions: {
headless: isCI,
slowMo: 10,
timeout: 60000,
},
contextOptions: {
ignoreHTTPSErrors: true,
},
};

View File

@ -1,7 +0,0 @@
module.exports = {
preset: "jest-playwright-preset",
testMatch: ["<rootDir>/test/**/*.spec.[jt]s?(x)"],
setupFiles: ["dotenv/config"],
testEnvironment: "./test/playwrightEnv.js",
setupFilesAfterEnv: ["expect-playwright"],
};

View File

@ -130,6 +130,7 @@
@ActiveTabWidth: 141px;
@TabsHeight: 30px;
@TabsWidth: 140px;
@ContentWrapper: 111px;
@StatusIconContainerSize: 18px;
@LoadingErrorIconSize: 14px;
@ErrorIconContainer: 16px;

View File

@ -2366,9 +2366,9 @@ a:link {
.tabsManagerContainer {
height: 100%;
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 300px;
overflow-y: scroll;
}
.tabs {
@ -2671,7 +2671,7 @@ a:link {
width: @ActiveTabWidth;
}
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .tabNavText {
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText {
font-weight: bolder;
border-bottom: 2px solid rgba(0, 120, 212, 1);
}
@ -2707,67 +2707,71 @@ a:link {
width: @TabsWidth;
border-right: @ButtonBorderWidth solid @BaseMedium;
white-space: nowrap;
.contentWrapper {
.flex-display();
width: @ContentWrapper;
.statusIconContainer {
width: @StatusIconContainerSize;
height: @StatusIconContainerSize;
margin-left: @SmallSpace;
display: inline-flex;
.statusIconContainer {
width: @StatusIconContainerSize;
height: @StatusIconContainerSize;
margin-left: @SmallSpace;
display: inline-flex;
.errorIconContainer {
width: @ErrorIconContainer;
height: @ErrorIconContainer;
margin-top: 1px;
.errorIconContainer {
width: @ErrorIconContainer;
height: @ErrorIconContainer;
margin-top: 1px;
.errorIcon {
width: @ErrorIconWidth;
.errorIcon {
width: @ErrorIconWidth;
height: @LoadingErrorIconSize;
background-image: url(../images/error_no_outline.svg);
background-repeat: no-repeat;
background-position: center;
background-size: 3px;
display: block;
margin: 1px 0px 0px 6px;
}
}
.errorIconContainer.actionsEnabled {
&:hover {
.hover();
}
&:focus {
.focus();
}
&:active {
.active();
}
}
.errorIconContainer[tabindex]:active {
outline: none;
}
.loadingIcon {
width: @LoadingErrorIconSize;
height: @LoadingErrorIconSize;
background-image: url(../images/error_no_outline.svg);
background-repeat: no-repeat;
background-position: center;
background-size: 3px;
display: block;
margin: 1px 0px 0px 6px;
margin: 0px 0px @SmallSpace @SmallSpace;
}
}
.errorIconContainer.actionsEnabled {
&:hover {
.hover();
}
&:focus {
.focus();
}
&:active {
.active();
}
.tabNavText {
margin-left: @SmallSpace;
margin-right: 2px;
color: @BaseDark;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
}
.errorIconContainer[tabindex]:active {
outline: none;
}
.loadingIcon {
width: @LoadingErrorIconSize;
height: @LoadingErrorIconSize;
margin: 0px 0px @SmallSpace @SmallSpace;
}
}
.tabNavText {
margin-left: @SmallSpace;
margin-right: 2px;
color: @BaseDark;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
}
.tabIconSection {
width: 30px;
width: 29px;
position: relative;
padding-top: 2px;

View File

@ -75,7 +75,7 @@ a:focus {
border-bottom: 2px solid @FabricAccentMedium;
}
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content>.tabNavText {
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content>.contentWrapper>.tabNavText {
border-bottom: 0px none transparent;
}
@ -93,9 +93,11 @@ a:focus {
width: calc(@TabsWidth - (@SmallSpace * 2));
padding-bottom: @SmallSpace;
.statusIconContainer {
margin-left: 0px;
}
.contentWrapper {
.statusIconContainer {
margin-left: 0px;
}
}
.tabIconSection {
.cancelButton {

1426
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -117,6 +117,7 @@
"@babel/preset-env": "7.9.0",
"@babel/preset-react": "7.9.4",
"@babel/preset-typescript": "7.9.0",
"@playwright/test": "1.44.0",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@ -161,7 +162,6 @@
"eslint-plugin-no-null": "1.0.2",
"eslint-plugin-prefer-arrow": "1.2.3",
"eslint-plugin-react-hooks": "4.6.0",
"expect-playwright": "0.3.3",
"fast-glob": "3.2.5",
"fs-extra": "7.0.0",
"html-inline-css-webpack-plugin": "1.11.2",
@ -170,7 +170,6 @@
"html-webpack-plugin": "5.5.3",
"jest": "26.6.3",
"jest-canvas-mock": "2.3.1",
"jest-playwright-preset": "1.5.1",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "0.0.7",
"less": "3.8.1",
@ -179,7 +178,6 @@
"mini-css-extract-plugin": "2.1.0",
"monaco-editor-webpack-plugin": "7.1.0",
"node-fetch": "2.6.7",
"playwright": "1.13.0",
"prettier": "3.0.3",
"process": "0.11.10",
"querystring-es3": "0.2.1",
@ -211,6 +209,7 @@
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
"test:file": "jest --coverage=false",
"watch": "npm run start",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
"build:ase": "gulp build:ase",

53
playwright.config.ts Normal file
View File

@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: 'test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
timeout: 10 * 60 * 1000,
use: {
actionTimeout: 5 * 60 * 1000,
trace: 'off',
video: 'off',
screenshot: 'on',
testIdAttribute: 'data-test',
contextOptions: {
ignoreHTTPSErrors: true,
},
},
expect: {
timeout: 5 * 60 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'https://127.0.0.1:1234/_ready',
timeout: 120 * 1000,
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -255,6 +255,7 @@ export class HttpHeaders {
public static partitionKey: string = "x-ms-documentdb-partitionkey";
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
public static xAPIKey: string = "X-API-Key";
}
export class ContentType {

View File

@ -42,6 +42,10 @@ export interface ConfigContext {
ARM_API_VERSION: string;
GRAPH_ENDPOINT: string;
GRAPH_API_VERSION: string;
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
CATALOG_ENDPOINT: string;
CATALOG_API_VERSION: string;
CATALOG_API_KEY: string;
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string;
@ -93,6 +97,9 @@ let configContext: Readonly<ConfigContext> = {
ARM_API_VERSION: "2016-06-01",
GRAPH_ENDPOINT: "https://graph.microsoft.com",
GRAPH_API_VERSION: "1.6",
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
CATALOG_API_VERSION: "2023-05-01-preview",
CATALOG_API_KEY: "",
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
@ -192,6 +199,9 @@ if (process.env.NODE_ENV === "development") {
updateConfigContext({
PROXY_PATH: "/proxy",
EMULATOR_ENDPOINT: "https://localhost:8081",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac,
});
}

View File

@ -425,6 +425,7 @@ export interface SelfServeFrameInputs {
authorizationToken: string;
csmEndpoint: string;
flights?: readonly string[];
catalogAPIKey: string;
}
export class MonacoEditorSettings {

View File

@ -1,4 +1,4 @@
import { Icon, Label, Stack } from "@fluentui/react";
import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react";
import * as React from "react";
import { NormalizedEventKey } from "../../../Common/Constants";
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
@ -8,6 +8,7 @@ export interface CollapsibleSectionProps {
isExpandedByDefault: boolean;
onExpand?: () => void;
children: JSX.Element;
tooltipContent?: string | JSX.Element | JSX.Element[];
}
export interface CollapsibleSectionState {
@ -55,6 +56,19 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
>
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
<Label>{this.props.title}</Label>
{this.props.tooltipContent && (
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.props.tooltipContent}
styles={{
root: {
marginLeft: "0 !important",
},
}}
>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
)}
</Stack>
{this.state.isExpanded && this.props.children}
</>

View File

@ -31,7 +31,7 @@ export interface CommandButtonComponentProps {
/**
* Click handler for command button click
*/
onCommandClick: (e: React.SyntheticEvent | KeyboardEvent) => void;
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent) => void;
/**
* Label for the button

View File

@ -20,6 +20,10 @@
outline-offset: 2px;
}
.outlineNone{
outline: none !important;
}
.copyQuery:focus::after,
.deleteQuery:focus::after {
outline: none !important;

View File

@ -223,6 +223,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
<Text variant="small" aria-label="capacity calculator of azure cosmos db">
Estimate your required RU/s with{" "}
<Link
className="underlinedLink outlineNone"
target="_blank"
href="https://cosmos.azure.com/capacitycalculator/"
aria-label="capacity calculator of azure cosmos db"
@ -271,7 +272,12 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
<Stack className="throughputInputSpacing">
<Text variant="small" aria-label="ruDescription">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/" aria-label="Capacity calculator">
<Link
className="underlinedLink"
target="_blank"
href="https://cosmos.azure.com/capacitycalculator/"
aria-label="Capacity calculator"
>
capacity calculator
</Link>
.

View File

@ -733,11 +733,13 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
<StyledLinkBase
aria-label="capacity calculator of azure cosmos db"
className="underlinedLink outlineNone"
href="https://cosmos.azure.com/capacitycalculator/"
target="_blank"
>
<LinkBase
aria-label="capacity calculator of azure cosmos db"
className="underlinedLink outlineNone"
href="https://cosmos.azure.com/capacitycalculator/"
styles={[Function]}
target="_blank"
@ -1017,7 +1019,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
>
<a
aria-label="capacity calculator of azure cosmos db"
className="ms-Link root-117"
className="ms-Link underlinedLink outlineNone root-117"
href="https://cosmos.azure.com/capacitycalculator/"
onClick={[Function]}
target="_blank"

View File

@ -166,6 +166,7 @@ export class LegacyTreeNodeComponent extends React.Component<
return (
<div
data-test={`Tree/TreeNode:${node.label}`}
className={`${this.props.node.className || ""} main${generation} nodeItem ${showSelected ? "selected" : ""}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.onNodeClick(event, node)}
onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => this.onNodeKeyPress(event, node)}
@ -174,9 +175,9 @@ export class LegacyTreeNodeComponent extends React.Component<
>
<div
className={`treeNodeHeader ${this.state.isMenuShowing ? "showingMenu" : ""}`}
data-test={`Tree/TreeNode/Header:${node.label}`}
style={headerStyle}
tabIndex={node.children ? -1 : 0}
data-test={node.label}
>
{this.renderCollapseExpandIcon(node)}
{node.iconSrc && <img className="nodeIcon" src={node.iconSrc} alt="" />}
@ -264,7 +265,7 @@ export class LegacyTreeNodeComponent extends React.Component<
onMenuDismissed: (contextualMenu?: IContextualMenuProps) => this.setState({ isMenuShowing: false }),
contextualMenuItemAs: (props: IContextualMenuItemProps) => (
<div
data-test={`treeComponentMenuItemContainer`}
data-test={`Tree/TreeNode/MenuItem:${props.item.text}`}
className="treeComponentMenuItemContainer"
onContextMenu={(e) => e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())}
>

View File

@ -124,6 +124,20 @@ describe("TreeNodeComponent", () => {
expect(component).toMatchSnapshot();
});
it("renders a node as expandable if it has empty, but defined, children array", () => {
const node = generateTestNode("root", {
isLoading: true,
children: [
generateTestNode("child1", {
children: [],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("does not render children if the node is loading", () => {
const node = generateTestNode("root", {
isLoading: true,

View File

@ -100,7 +100,8 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
return unsortedChildren;
};
const isBranch = node.children?.length > 0;
// A branch node is any node with a defined children array, even if the array is empty.
const isBranch = !!node.children;
const onOpenChange = useCallback(
(_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
@ -146,9 +147,9 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
const treeItem = (
<TreeItem
data-test={`TreeNodeContainer:${treeNodeId}`}
value={treeNodeId}
itemType={isBranch ? "branch" : "leaf"}
style={{ height: "100%" }}
onOpenChange={onOpenChange}
>
<TreeItemLayout

View File

@ -36,13 +36,14 @@ exports[`LegacyTreeComponent renders a simple tree 1`] = `
exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -138,6 +139,7 @@ exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expanded) 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -145,7 +147,7 @@ exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expande
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -290,13 +292,14 @@ exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expande
exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -363,6 +366,7 @@ exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -370,7 +374,7 @@ exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and p
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -534,13 +538,14 @@ exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and p
exports[`LegacyTreeNodeComponent renders unsorted children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,

View File

@ -2,13 +2,9 @@
exports[`TreeNodeComponent does not render children if the node is loading 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -114,13 +110,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root"
>
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<div
@ -129,15 +121,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc"
data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
role="treeitem"
style={
Object {
"height": "100%",
}
}
tabIndex={-1}
>
<ContextSelector.Provider
@ -228,8 +216,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc"
data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root"
role="treeitem"
style="height: 100%;"
tabindex="-1"
>
<div
@ -282,8 +270,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
role="treeitem"
style="height: 100%;"
tabindex="0"
>
<div
@ -333,8 +321,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
>
<div
@ -383,8 +371,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
>
<div
@ -637,13 +625,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child1Label"
>
<TreeItem
data-test="TreeNodeContainer:root/child1Label"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root/child1Label"
>
<div
@ -652,15 +636,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
role="treeitem"
style={
Object {
"height": "100%",
}
}
tabIndex={-1}
>
<ContextSelector.Provider
@ -751,8 +731,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
role="treeitem"
style="height: 100%;"
tabindex="0"
>
<div
@ -932,13 +912,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child2LoadingLabel"
>
<TreeItem
data-test="TreeNodeContainer:root/child2LoadingLabel"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root/child2LoadingLabel"
>
<div
@ -947,15 +923,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
role="treeitem"
style={
Object {
"height": "100%",
}
}
tabIndex={-1}
>
<ContextSelector.Provider
@ -1046,8 +1018,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
>
<div
@ -1212,13 +1184,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child3ExpandingLabel"
>
<TreeItem
data-test="TreeNodeContainer:root/child3ExpandingLabel"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root/child3ExpandingLabel"
>
<div
@ -1226,15 +1194,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
role="treeitem"
style={
Object {
"height": "100%",
}
}
tabIndex={-1}
>
<ContextSelector.Provider
@ -1332,8 +1296,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
>
<div
@ -1455,13 +1419,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
exports[`TreeNodeComponent renders a loading spinner if the node is loading: loaded 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1493,13 +1453,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
exports[`TreeNodeComponent renders a loading spinner if the node is loading: loading 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1534,6 +1490,40 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
</TreeItem>
`;
exports[`TreeNodeComponent renders a node as expandable if it has empty, but defined, children array 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
value="root"
>
<TreeItemLayout
actions={false}
className="rootClass"
data-test="TreeNode:root"
iconBefore={
<img
alt=""
src="rootIcon"
style={
Object {
"height": 16,
"width": 16,
}
}
/>
}
style={
Object {
"backgroundColor": undefined,
}
}
>
rootLabel
</TreeItemLayout>
</TreeItem>
`;
exports[`TreeNodeComponent renders a node with a menu 1`] = `
<Menu
onOpenChange={[Function]}
@ -1544,13 +1534,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
disableButtonEnhancement={true}
>
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1637,13 +1623,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
exports[`TreeNodeComponent renders a single node 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1675,13 +1657,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1713,13 +1691,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
exports[`TreeNodeComponent renders selected parent node as selected if no descendant nodes are selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1801,13 +1775,9 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
exports[`TreeNodeComponent renders selected parent node as unselected if any descendant node is selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout
@ -1890,13 +1860,9 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
Object {
"height": "100%",
}
}
value="root"
>
<TreeItemLayout

View File

@ -60,21 +60,23 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined,
iconName: btn.iconName,
},
onClick: (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev);
let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
}
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label, copilotEnabled });
},
onClick: btn.onCommandClick
? (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev);
let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
}
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label, copilotEnabled });
}
: undefined,
key: `${btn.commandButtonLabel}${index}`,
text: label,
"data-test": label,
title: btn.tooltipText,
name: label,
disabled: btn.disabled,
ariaLabel: btn.ariaLabel,
"data-test": `CommandBar/Button:${label}`,
buttonStyles: {
root: {
backgroundColor: backgroundColor,

View File

@ -21,7 +21,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { SubscriptionType } from "Contracts/SubscriptionType";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import React from "react";
@ -82,22 +82,6 @@ export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
excludedPaths: [],
};
const DefaultDatabaseVectorIndex: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
},
],
excludedPaths: [
{
path: '/"_etag"/?',
},
],
vectorIndexes: [],
};
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
vectorEmbeddings: [],
};
@ -122,8 +106,9 @@ export interface AddCollectionPanelState {
isExecuting: boolean;
isThroughputCapExceeded: boolean;
teachingBubbleStep: number;
vectorIndexingPolicy: string;
vectorEmbeddingPolicy: string;
vectorIndexingPolicy: DataModels.VectorIndex[];
vectorEmbeddingPolicy: DataModels.VectorEmbedding[];
vectorPolicyValidated: boolean;
}
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
@ -159,8 +144,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
isExecuting: false,
isThroughputCapExceeded: false,
teachingBubbleStep: 0,
vectorIndexingPolicy: JSON.stringify(DefaultDatabaseVectorIndex, null, 2),
vectorEmbeddingPolicy: JSON.stringify(DefaultVectorEmbeddingPolicy, null, 2),
vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [],
vectorPolicyValidated: true,
};
}
@ -896,60 +882,28 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
{this.shouldShowVectorSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title="Indexing Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
Learn more
</Link>
<EditorReact
language={"json"}
content={this.state.vectorIndexingPolicy}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing indexing policy"}
lineNumbers={"on"}
scrollBeyondLastLine={false}
spinnerClassName="panelSectionSpinner"
monacoContainerStyles={{
minHeight: 200,
}}
onContentChanged={(newIndexingPolicy: string) => this.setVectorIndexingPolicy(newIndexingPolicy)}
/>
</Stack>
</CollapsibleSectionComponent>
<CollapsibleSectionComponent
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
Learn more
</Link>
<EditorReact
language={"json"}
content={this.state.vectorEmbeddingPolicy}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing container vector policy"}
lineNumbers={"on"}
scrollBeyondLastLine={false}
spinnerClassName="panelSectionSpinner"
monacoContainerStyles={{
minHeight: 200,
}}
onContentChanged={(newVectorEmbeddingPolicy: string) =>
this.setVectorEmbeddingPolicy(newVectorEmbeddingPolicy)
}
/>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<AddVectorEmbeddingPolicyForm
vectorEmbedding={this.state.vectorEmbeddingPolicy}
vectorIndex={this.state.vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: DataModels.VectorEmbedding[],
vectorIndexingPolicy: DataModels.VectorIndex[],
vectorPolicyValidated: boolean,
) => {
this.setState({ vectorEmbeddingPolicy, vectorIndexingPolicy, vectorPolicyValidated });
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
@ -1159,13 +1113,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: string): void {
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: DataModels.VectorEmbedding[]): void {
this.setState({
vectorEmbeddingPolicy,
});
}
private setVectorIndexingPolicy(vectorIndexingPolicy: string): void {
private setVectorIndexingPolicy(vectorIndexingPolicy: DataModels.VectorIndex[]): void {
this.setState({
vectorIndexingPolicy,
});
@ -1251,6 +1205,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
);
}
private getContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
private shouldShowCollectionThroughputInput(): boolean {
if (isServerlessAccount()) {
return false;
@ -1370,20 +1336,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.shouldShowVectorSearchParameters()) {
try {
JSON.parse(this.state.vectorIndexingPolicy) as DataModels.IndexingPolicy;
} catch (e) {
this.setState({ errorMessage: "Invalid JSON format for indexingPolicy" });
return false;
}
try {
JSON.parse(this.state.vectorEmbeddingPolicy) as DataModels.VectorEmbeddingPolicy;
} catch (e) {
this.setState({ errorMessage: "Invalid JSON format for vectorEmbeddingPolicy" });
return false;
}
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
}
return true;
@ -1461,15 +1416,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
: undefined;
let indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
const indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
? AllPropertiesIndexed
: SharedDatabaseDefault;
let vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
if (this.shouldShowVectorSearchParameters()) {
indexingPolicy = JSON.parse(this.state.vectorIndexingPolicy);
vectorEmbeddingPolicy = JSON.parse(this.state.vectorEmbeddingPolicy);
indexingPolicy.vectorIndexes = this.state.vectorIndexingPolicy;
vectorEmbeddingPolicy = {
vectorEmbeddings: this.state.vectorEmbeddingPolicy,
};
}
const telemetryData = {

View File

@ -275,7 +275,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Enter CQL command to create the table.{" "}
<Link href="https://aka.ms/cassandra-create-table" target="_blank">
<Link className="underlinedLink" href="https://aka.ms/cassandra-create-table" target="_blank">
Learn More
</Link>
</Text>

View File

@ -379,6 +379,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -386,6 +387,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -666,6 +668,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -948,6 +951,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1231,6 +1235,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2105,6 +2110,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
aria-label="OK"
className="ms-Button ms-Button--primary root-122"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -9,6 +9,7 @@ import { DefaultExperienceUtility } from "Shared/DefaultExperienceUtility";
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getDatabaseName } from "Utils/APITypeUtils";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useSidePanel } from "hooks/useSidePanel";
import { useTabs } from "hooks/useTabs";
@ -37,11 +38,11 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
const submit = async (): Promise<void> => {
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
setFormError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
logConsoleError(`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(`Error while deleting ${getDatabaseName()} ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
return;
}
@ -123,17 +124,18 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
const confirmDatabase = "Confirm by typing the database id";
const reasonInfo = "Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?";
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
return (
<RightPaneForm {...props}>
{!formError && <PanelInfoErrorComponent {...errorProps} />}
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the database id</Text>
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
<TextField
id="confirmDatabaseId"
data-test="Input:confirmDatabaseId"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {
@ -149,7 +151,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
What is the reason why you are deleting this database?
What is the reason why you are deleting this {getDatabaseName()}?
</Text>
<TextField
id="deleteDatabaseFeedbackInput"

View File

@ -5312,6 +5312,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5319,6 +5320,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5599,6 +5601,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -5881,6 +5884,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -6164,6 +6168,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Execute"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -7038,6 +7043,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
aria-label="Execute"
className="ms-Button ms-Button--primary root-148"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -16,6 +16,7 @@ export const PanelFooterComponent: React.FunctionComponent<PanelFooterProps> = (
<PrimaryButton
type="submit"
id="sidePanelOkButton"
data-test="Panel/OkButton"
text={buttonLabel}
ariaLabel={buttonLabel}
disabled={!!isButtonDisabled}

View File

@ -21,6 +21,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -28,6 +29,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -308,6 +310,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -590,6 +593,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -873,6 +877,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
<BaseButton
ariaLabel="Load"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1747,6 +1752,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
aria-label="Load"
className="ms-Button ms-Button--primary root-109"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -9,6 +9,7 @@ import {
Toggle,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { SplitterDirection } from "Common/Splitter";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { configContext } from "ConfigContext";
import { useDatabases } from "Explorer/useDatabases";
@ -16,6 +17,7 @@ import {
DefaultRUThreshold,
LocalStorageUtility,
StorageKey,
getDefaultQueryResultsView,
getRUThreshold,
ruThresholdEnabled as isRUThresholdEnabled,
} from "Shared/StorageUtility";
@ -48,6 +50,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled),
);
const [queryTimeout, setQueryTimeout] = useState<number>(LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout));
const [defaultQueryResultsView, setDefaultQueryResultsView] = useState<SplitterDirection>(
getDefaultQueryResultsView(),
);
const [automaticallyCancelQueryAfterTimeout, setAutomaticallyCancelQueryAfterTimeout] = useState<boolean>(
LocalStorageUtility.getEntryBoolean(StorageKey.AutomaticallyCancelQueryAfterTimeout),
);
@ -125,6 +130,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryNumber(StorageKey.MaxDegreeOfParellism, maxDegreeOfParallelism);
LocalStorageUtility.setEntryString(StorageKey.PriorityLevel, priorityLevel.toString());
LocalStorageUtility.setEntryString(StorageKey.CopilotSampleDBEnabled, copilotSampleDBEnabled.toString());
LocalStorageUtility.setEntryString(StorageKey.DefaultQueryResultsView, defaultQueryResultsView);
if (shouldShowGraphAutoVizOption) {
LocalStorageUtility.setEntryBoolean(
@ -201,6 +207,11 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ key: Constants.PriorityLevel.High, text: "High" },
];
const defaultQueryResultsViewOptionList: IChoiceGroupOption[] = [
{ key: SplitterDirection.Vertical, text: "Vertical" },
{ key: SplitterDirection.Horizontal, text: "Horizontal" },
];
const handleOnPriorityLevelOptionChange = (
ev: React.FormEvent<HTMLInputElement>,
option: IChoiceGroupOption,
@ -238,6 +249,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
}
};
const handleOnDefaultQueryResultsViewChange = (
ev: React.MouseEvent<HTMLElement>,
option: IChoiceGroupOption,
): void => {
setDefaultQueryResultsView(option.key as SplitterDirection);
};
const handleOnQueryRetryAttemptsSpinButtonChange = (ev: React.MouseEvent<HTMLElement>, newValue?: string): void => {
const retryAttempts = Number(newValue);
if (!isNaN(retryAttempts)) {
@ -442,6 +460,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)}
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div>
<legend id="defaultQueryResultsView" className="settingsSectionLabel legendLabel">
Default Query Results View
</legend>
<InfoTooltip>Select the default view to use when displaying query results.</InfoTooltip>
</div>
<div>
<ChoiceGroup
ariaLabelledBy="defaultQueryResultsView"
selectedKey={defaultQueryResultsView}
options={defaultQueryResultsViewOptionList}
styles={choiceButtonStyles}
onChange={handleOnDefaultQueryResultsViewChange}
/>
</div>
</div>
</div>
</>
)}
<div className="settingsSection">

View File

@ -205,6 +205,67 @@ exports[`Settings Pane should render Default properly 1`] = `
</div>
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div>
<legend
className="settingsSectionLabel legendLabel"
id="defaultQueryResultsView"
>
Default Query Results View
</legend>
<InfoTooltip>
Select the default view to use when displaying query results.
</InfoTooltip>
</div>
<div>
<StyledChoiceGroup
ariaLabelledBy="defaultQueryResultsView"
onChange={[Function]}
options={
Array [
Object {
"key": "vertical",
"text": "Vertical",
},
Object {
"key": "horizontal",
"text": "Horizontal",
},
]
}
selectedKey="vertical"
styles={
Object {
"flexContainer": Array [
Object {
"selectors": Object {
".ms-ChoiceField": Object {
"marginTop": 0,
},
".ms-ChoiceField-wrapper label": Object {
"fontSize": 12,
"paddingTop": 0,
},
".ms-ChoiceFieldGroup root-133": Object {
"clear": "both",
},
},
},
],
"root": Object {
"clear": "both",
},
}
}
/>
</div>
</div>
</div>
<div
className="settingsSection"
>

View File

@ -688,6 +688,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Create"
@ -695,6 +696,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<PrimaryButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Create"
@ -975,6 +977,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1257,6 +1260,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<DefaultButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1540,6 +1544,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
<BaseButton
ariaLabel="Create"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2414,6 +2419,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
aria-label="Create"
className="ms-Button ms-Button--primary root-122"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -1258,6 +1258,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1265,6 +1266,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1545,6 +1547,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1827,6 +1830,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2110,6 +2114,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2984,6 +2989,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
aria-label="OK"
className="ms-Button ms-Button--primary root-125"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -369,6 +369,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Add Entity"
@ -376,6 +377,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Add Entity"
@ -656,6 +658,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -938,6 +941,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1221,6 +1225,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Add Entity"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2095,6 +2100,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
aria-label="Add Entity"
className="ms-Button ms-Button--primary root-113"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -375,6 +375,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Update"
@ -382,6 +383,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Update"
@ -662,6 +664,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -944,6 +947,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1227,6 +1231,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Update"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2101,6 +2106,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
aria-label="Update"
className="ms-Button ms-Button--primary root-113"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -0,0 +1,84 @@
import "@testing-library/jest-dom/extend-expect";
import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import React from "react";
import { AddVectorEmbeddingPolicyForm } from "./AddVectorEmbeddingPolicyForm";
const mockVectorEmbedding: VectorEmbedding[] = [
{ path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 },
];
const mockVectorIndex: VectorIndex[] = [{ path: "/vector1", type: "flat" }];
const mockOnVectorEmbeddingChange = jest.fn();
describe("AddVectorEmbeddingPolicyForm", () => {
let component: RenderResult;
beforeEach(() => {
component = render(
<AddVectorEmbeddingPolicyForm
vectorEmbedding={mockVectorEmbedding}
vectorIndex={mockVectorIndex}
onVectorEmbeddingChange={mockOnVectorEmbeddingChange}
/>,
);
});
test("renders correctly", () => {
expect(screen.getByText("Vector embedding 1")).toBeInTheDocument();
expect(screen.getByPlaceholderText("/vector1")).toBeInTheDocument();
});
test("calls onVectorEmbeddingChange on adding a new vector embedding", () => {
fireEvent.click(screen.getByText("Add vector embedding"));
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
});
test("calls onDelete when delete button is clicked", async () => {
const deleteButton = component.container.querySelector("#delete-vector-policy-1");
fireEvent.click(deleteButton);
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
expect(screen.queryByText("Vector embedding 1")).toBeNull();
});
test("calls onVectorEmbeddingPathChange on input change", () => {
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/newPath" } });
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
});
test("validates input correctly", async () => {
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "" } });
await waitFor(() => expect(screen.getByText("Vector embedding path should not be empty")).toBeInTheDocument(), {
timeout: 1500,
});
await waitFor(
() =>
expect(
screen.getByText("Vector embedding dimension must be greater than 0 and less than or equal 4096"),
).toBeInTheDocument(),
{
timeout: 1500,
},
);
fireEvent.change(component.container.querySelector("#vector-policy-dimension-1"), { target: { value: "4096" } });
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/vector1" } });
await waitFor(() => expect(screen.queryByText("Vector embedding path should not be empty")).toBeNull(), {
timeout: 1500,
});
await waitFor(
() => expect(screen.queryByText("Maximum allowed dimension for flat index is 505")).toBeInTheDocument(),
{
timeout: 1500,
},
);
});
test("duplicate vector path is not allowed", async () => {
fireEvent.click(screen.getByText("Add vector embedding"));
fireEvent.change(component.container.querySelector("#vector-policy-path-2"), { target: { value: "/vector1" } });
await waitFor(() => expect(screen.queryByText("Vector embedding path is already defined")).toBeNull(), {
timeout: 1500,
});
});
});

View File

@ -0,0 +1,300 @@
import {
DefaultButton,
Dropdown,
IDropdownOption,
IStyleFunctionOrObject,
ITextFieldStyleProps,
ITextFieldStyles,
IconButton,
Label,
Stack,
TextField,
} from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
getDataTypeOptions,
getDistanceFunctionOptions,
getIndexTypeOptions,
} from "Explorer/Panes/VectorSearchPanel/VectorSearchUtils";
import React, { FunctionComponent, useState } from "react";
export interface IAddVectorEmbeddingPolicyFormProps {
vectorEmbedding: VectorEmbedding[];
vectorIndex: VectorIndex[];
onVectorEmbeddingChange: (
vectorEmbeddings: VectorEmbedding[],
vectorIndexingPolicies: VectorIndex[],
validationPassed: boolean,
) => void;
}
export interface VectorEmbeddingPolicyData {
path: string;
dataType: VectorEmbedding["dataType"];
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
pathError: string;
dimensionsError: string;
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
fieldGroup: {
height: 27,
},
field: {
fontSize: 12,
padding: "0 8px",
},
};
const dropdownStyles = {
title: {
height: 27,
lineHeight: "24px",
fontSize: 12,
},
dropdown: {
height: 27,
lineHeight: "24px",
},
dropdownItem: {
fontSize: 12,
},
};
export const AddVectorEmbeddingPolicyForm: FunctionComponent<IAddVectorEmbeddingPolicyFormProps> = ({
vectorEmbedding,
vectorIndex,
onVectorEmbeddingChange,
}): JSX.Element => {
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Vector embedding path should not be empty";
}
if (
index >= 0 &&
vectorEmbeddingPolicyData?.find(
(vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) =>
dataIndex !== index && vectorEmbedding.path === path,
)
) {
error = "Vector embedding path is already defined";
}
return error;
};
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = "Vector embedding dimension must be greater than 0 and less than or equal 4096";
}
if (indexType === "flat" && dimension > 505) {
error = "Maximum allowed dimension for flat index is 505";
}
return error;
};
const initializeData = (vectorEmbedding: VectorEmbedding[], vectorIndex: VectorIndex[]) => {
const mergedData: VectorEmbeddingPolicyData[] = [];
vectorEmbedding.forEach((embedding) => {
const matchingIndex = vectorIndex.find((index) => index.path === embedding.path);
mergedData.push({
...embedding,
indexType: matchingIndex?.type || "none",
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
});
});
return mergedData;
};
const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState<VectorEmbeddingPolicyData[]>(
initializeData(vectorEmbedding, vectorIndex),
);
React.useEffect(() => {
propagateData();
}, [vectorEmbeddingPolicyData]);
const propagateData = () => {
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
dataType: policy.dataType,
dimensions: policy.dimensions,
distanceFunction: policy.distanceFunction,
path: policy.path,
}));
const vectorIndexingPolicies: VectorIndex[] = vectorEmbeddingPolicyData
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
.map(
(policy) =>
({
path: policy.path,
type: policy.indexType,
}) as VectorIndex,
);
const validationPassed = vectorEmbeddingPolicyData.every(
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexingPolicies, validationPassed);
};
const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) {
vectorEmbeddings[index].path = "/" + value;
} else {
vectorEmbeddings[index].path = value;
}
const error = onVectorEmbeddingPathError(value, index);
vectorEmbeddings[index].pathError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].dimensions = value;
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].indexType = option.key as never;
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingPolicyChange = (
index: number,
option: IDropdownOption,
property: VectorEmbeddingPolicyProperty,
): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onAdd = () => {
setVectorEmbeddingPolicyData([
...vectorEmbeddingPolicyData,
{
path: "",
dataType: "float32",
distanceFunction: "euclidean",
dimensions: 0,
indexType: "none",
pathError: onVectorEmbeddingPathError(""),
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
},
]);
};
const onDelete = (index: number) => {
const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
return (
<Stack tokens={{ childrenGap: 4 }}>
{vectorEmbeddingPolicyData.length > 0 &&
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
<CollapsibleSectionComponent key={index} isExpandedByDefault={true} title={`Vector embedding ${index + 1}`}>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
},
}}
>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Path</Label>
<TextField
id={`vector-policy-path-${index + 1}`}
required={true}
placeholder="/vector1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onVectorEmbeddingPathChange(index, event)}
value={vectorEmbeddingPolicy.path || ""}
errorMessage={vectorEmbeddingPolicy.pathError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Data type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDataTypeOptions()}
selectedKey={vectorEmbeddingPolicy.dataType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Distance function</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDistanceFunctionOptions()}
selectedKey={vectorEmbeddingPolicy.distanceFunction}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "distanceFunction")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Dimensions</Label>
<TextField
id={`vector-policy-dimension-${index + 1}`}
required={true}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.dimensions || 0)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onVectorEmbeddingDimensionsChange(index, event)
}
errorMessage={vectorEmbeddingPolicy.dimensionsError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Index type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getIndexTypeOptions()}
selectedKey={vectorEmbeddingPolicy.indexType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingIndexTypeChange(index, option)
}
></Dropdown>
</Stack>
</Stack>
<IconButton
id={`delete-vector-policy-${index + 1}`}
iconProps={{ iconName: "Delete" }}
style={{ height: 27, margin: "auto" }}
onClick={() => onDelete(index)}
/>
</Stack>
</CollapsibleSectionComponent>
))}
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
Add vector embedding
</DefaultButton>
</Stack>
);
};

View File

@ -0,0 +1,16 @@
import { IDropdownOption } from "@fluentui/react";
const dataTypes = ["float32", "uint8", "int8"];
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes);
export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions);
export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes);
function createDropdownOptionsFromLiterals<T extends string>(literals: T[]): IDropdownOption[] {
return literals.map((value) => ({
key: value,
text: value,
}));
}

View File

@ -361,12 +361,15 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<span
className="css-113"
>
Confirm by typing the database id
Confirm by typing the
Database
id
</span>
</Text>
<StyledTextFieldBase
ariaLabel="Confirm by typing the database id"
ariaLabel="Confirm by typing the Database id"
autoFocus={true}
data-test="Input:confirmDatabaseId"
id="confirmDatabaseId"
onChange={[Function]}
required={true}
@ -379,8 +382,9 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
}
>
<TextFieldBase
ariaLabel="Confirm by typing the database id"
ariaLabel="Confirm by typing the Database id"
autoFocus={true}
data-test="Input:confirmDatabaseId"
deferredValidationTime={200}
id="confirmDatabaseId"
onChange={[Function]}
@ -673,9 +677,10 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<input
aria-invalid={false}
aria-label="Confirm by typing the database id"
aria-label="Confirm by typing the Database id"
autoFocus={true}
className="ms-TextField-field field-117"
data-test="Input:confirmDatabaseId"
id="confirmDatabaseId"
onBlur={[Function]}
onChange={[Function]}
@ -711,11 +716,13 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<span
className="css-126"
>
What is the reason why you are deleting this database?
What is the reason why you are deleting this
Database
?
</span>
</Text>
<StyledTextFieldBase
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
id="deleteDatabaseFeedbackInput"
multiline={true}
onChange={[Function]}
@ -729,7 +736,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
}
>
<TextFieldBase
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
deferredValidationTime={200}
id="deleteDatabaseFeedbackInput"
multiline={true}
@ -1023,7 +1030,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<textarea
aria-invalid={false}
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
className="ms-TextField-field field-128"
id="deleteDatabaseFeedbackInput"
onBlur={[Function]}
@ -1049,6 +1056,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1056,6 +1064,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1336,6 +1345,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1618,6 +1628,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1901,6 +1912,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2775,6 +2787,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
aria-label="OK"
className="ms-Button ms-Button--primary root-130"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -504,7 +504,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
{!showFeedbackBar && (
<Text style={{ fontSize: 12 }}>
AI-generated content can have mistakes. Make sure it&apos;s accurate and appropriate before using it.{" "}
<Link href="https://aka.ms/cdb-copilot-preview-terms" target="_blank" style={{ color: "#0072D4" }}>
<Link
href="https://aka.ms/cdb-copilot-preview-terms"
target="_blank"
style={{ color: "#0072D4" }}
className="underlinedLink"
>
Read preview terms
</Link>
{showErrorMessageBar && (

View File

@ -355,15 +355,15 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
) : (
<div className="moreStuffContainer">
<div className="moreStuffColumn commonTasks">
<div className="title">Recents</div>
<h2 className="title">Recents</h2>
{this.getRecentItems()}
</div>
<div className="moreStuffColumn">
<div className="title">Top 3 things you need to know</div>
<h2 className="title">Top 3 things you need to know</h2>
{this.top3Items()}
</div>
<div className="moreStuffColumn tipsContainer">
<div className="title">Learning Resources</div>
<h2 className="title">Learning Resources</h2>
{this.getLearningResourceItems()}
</div>
</div>

View File

@ -1,5 +1,6 @@
import ko from "knockout";
import { isEnvironmentAltPressed, isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import * as Constants from "../Constants";
import * as Entities from "../Entities";
import * as Utilities from "../Utilities";
@ -28,7 +29,7 @@ export default class DataTableOperationManager {
var elem: JQuery<Element> = $(event.currentTarget);
this.updateLastSelectedItem(elem, event.shiftKey);
if (Utilities.isEnvironmentCtrlPressed(event)) {
if (isEnvironmentCtrlPressed(event)) {
this.applyCtrlSelection(elem);
} else if (event.shiftKey) {
this.applyShiftSelection(elem);
@ -74,9 +75,9 @@ export default class DataTableOperationManager {
DataTableOperations.scrollToRowIfNeeded(dataTableRows, safeIndex, isUpArrowKey);
}
} else if (
Utilities.isEnvironmentCtrlPressed(event) &&
!Utilities.isEnvironmentShiftPressed(event) &&
!Utilities.isEnvironmentAltPressed(event) &&
isEnvironmentCtrlPressed(event) &&
!isEnvironmentShiftPressed(event) &&
!isEnvironmentAltPressed(event) &&
event.keyCode === Constants.keyCodes.A
) {
this.applySelectAll();

View File

@ -1,8 +1,8 @@
import * as _ from "underscore";
import Q from "q";
import * as _ from "underscore";
import * as Constants from "./Constants";
import * as Entities from "./Entities";
import { CassandraTableKey } from "./TableDataClient";
import * as Constants from "./Constants";
/**
* Generates a pseudo-random GUID.
@ -180,30 +180,6 @@ export function onEsc(
return onKey(event, Constants.keyCodes.Esc, action, metaKey, shiftKey, altKey);
}
/**
* Is the environment 'ctrl' key press. This key is used for multi selection, like select one more item, select all.
* For Windows and Linux, it's ctrl. For Mac, it's command.
*/
export function isEnvironmentCtrlPressed(event: JQueryEventObject): boolean {
return isMac() ? event.metaKey : event.ctrlKey;
}
export function isEnvironmentShiftPressed(event: JQueryEventObject): boolean {
return event.shiftKey;
}
export function isEnvironmentAltPressed(event: JQueryEventObject): boolean {
return event.altKey;
}
/**
* Returns whether the current platform is MacOS.
*/
export function isMac(): boolean {
var platform = navigator.platform.toUpperCase();
return platform.indexOf("MAC") >= 0;
}
// MAX_SAFE_INTEGER and MIN_SAFE_INTEGER will be provided by ECMAScript 6's Number
export var MAX_SAFE_INTEGER = Math.pow(2, 53) - 1;
export var MIN_SAFE_INTEGER = -MAX_SAFE_INTEGER;

View File

@ -86,6 +86,9 @@ export class DocumentsTabV2 extends TabsBase {
}
}
// Use this value to initialize the very time the component is rendered
const RESET_INDEX = -1;
const filterButtonStyle: CSSProperties = {
marginLeft: 8,
};
@ -465,7 +468,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const [selectedDocumentContentBaseline, setSelectedDocumentContentBaseline] = useState<string>(undefined);
// Table user clicked on this row
const [clickedRow, setClickedRow] = useState<TableRowId>(undefined);
const [clickedRowIndex, setClickedRowIndex] = useState<number>(RESET_INDEX);
// Table multiple selection
const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>([0]));
@ -490,6 +493,23 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [isFilterFocused]);
// Clicked row must be defined
useEffect(() => {
if (documentIds.length > 0) {
let currentClickedRowIndex = clickedRowIndex;
if (
(currentClickedRowIndex === RESET_INDEX &&
editorState === ViewModels.DocumentExplorerState.noDocumentSelected) ||
currentClickedRowIndex > documentIds.length - 1
) {
// reset clicked row or the current clicked row is out of bounds
currentClickedRowIndex = 0;
setSelectedRows(new Set([0]));
onDocumentClicked(currentClickedRowIndex, documentIds);
}
}
}, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = {
@ -550,7 +570,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
if (!documentsIterator) {
try {
refreshDocumentsGrid();
refreshDocumentsGrid(false);
} catch (error) {
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceFailure(
@ -657,7 +677,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedDocumentContent(defaultDocument);
setSelectedDocumentContentBaseline(defaultDocument);
setSelectedRows(new Set());
setClickedRow(undefined);
setClickedRowIndex(undefined);
setEditorState(ViewModels.DocumentExplorerState.newDocumentValid);
};
@ -673,8 +693,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return createDocument(_collection, document)
.then(
(savedDocument: DataModels.DocumentId) => {
// TODO: Reuse initDocumentEditor() to remove code duplication
const value: string = renderObjectForEditor(savedDocument || {}, null, 4);
setSelectedDocumentContentBaseline(value);
setSelectedDocumentContent(value);
setInitialDocumentContent(value);
const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues(
savedDocument,
@ -738,7 +760,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
partitionKey as PartitionKeyDefinition,
);
const selectedDocumentId = documentIds[clickedRow as number];
const selectedDocumentId = documentIds[clickedRowIndex as number];
selectedDocumentId.partitionKeyValue = partitionKeyValueArray;
onExecutionErrorChange(false);
@ -786,7 +808,15 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
},
)
.finally(() => setIsExecuting(false));
}, [onExecutionErrorChange, tabTitle, selectedDocumentContent, _collection, partitionKey, documentIds, clickedRow]);
}, [
onExecutionErrorChange,
tabTitle,
selectedDocumentContent,
_collection,
partitionKey,
documentIds,
clickedRowIndex,
]);
const onRevertExistingDocumentClick = useCallback((): void => {
setSelectedDocumentContentBaseline(initialDocumentContent);
@ -845,12 +875,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
_deleteDocuments(toDeleteDocumentIds)
.then(
(deletedIds: DocumentId[]) => {
const deletedRids = new Set(deletedIds.map((documentId) => documentId.rid));
const newDocumentIds = [...documentIds.filter((documentId) => !deletedRids.has(documentId.rid))];
const deletedIdsSet = new Set(deletedIds.map((documentId) => documentId.id));
const newDocumentIds = [...documentIds.filter((documentId) => !deletedIdsSet.has(documentId.id))];
setDocumentIds(newDocumentIds);
setSelectedDocumentContent(undefined);
setClickedRow(undefined);
setClickedRowIndex(undefined);
setSelectedRows(new Set());
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
useDialog
@ -974,8 +1004,27 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return true;
};
const updateDocumentIds = (newDocumentsIds: DocumentId[]): void => {
setDocumentIds(newDocumentsIds);
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceSuccess(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
};
let loadNextPage = useCallback(
(iterator: QueryIterator<ItemDefinition & Resource>, applyFilterButtonClicked?: boolean): Promise<unknown> => {
(iterator: QueryIterator<ItemDefinition & Resource>, applyFilterButtonClicked: boolean): Promise<unknown> => {
setIsExecuting(true);
onExecutionErrorChange(false);
let automaticallyCancelQueryAfterTimeout: boolean;
@ -1028,21 +1077,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
});
const merged = currentDocuments.concat(nextDocumentIds);
setDocumentIds(merged);
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceSuccess(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
updateDocumentIds(merged);
},
(error) => {
onExecutionErrorChange(true);
@ -1112,7 +1147,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
loadNextPage(documentsIterator.iterator);
loadNextPage(documentsIterator.iterator, false);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
@ -1158,10 +1193,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
* Document has been clicked on in table
* @param tabRowId
*/
const onDocumentClicked = (tabRowId: TableRowId) => {
const onDocumentClicked = (tabRowId: TableRowId, currentDocumentIds: DocumentId[]) => {
const index = tabRowId as number;
setClickedRow(index);
loadDocument(documentIds[index]);
setClickedRowIndex(index);
loadDocument(currentDocumentIds[index]);
};
let loadDocument = (documentId: DocumentId) =>
@ -1267,13 +1302,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
confirmDiscardingChange(() => {
if (selectedRows.size === 0) {
setSelectedDocumentContent(undefined);
setClickedRow(undefined);
setClickedRowIndex(undefined);
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
}
// Find if clickedRow is in selectedRows.If not, clear clickedRow and content
if (clickedRow !== undefined && !selectedRows.has(clickedRow)) {
setClickedRow(undefined);
if (clickedRowIndex !== undefined && !selectedRows.has(clickedRowIndex)) {
setClickedRowIndex(undefined);
setSelectedDocumentContent(undefined);
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
}
@ -1281,6 +1316,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// If only one selection, we consider as a click
if (selectedRows.size === 1) {
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
onDocumentClicked(selectedRows.values().next().value, documentIds);
}
setSelectedRows(selectedRows);
@ -1441,6 +1477,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const value: string = renderObjectForEditor(savedDocument || {}, null, 4);
setSelectedDocumentContentBaseline(value);
setSelectedDocumentContent(value);
setInitialDocumentContent(value);
setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
@ -1491,7 +1529,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
tabTitle,
});
const selectedDocumentId = documentIds[clickedRow as number];
const selectedDocumentId = documentIds[clickedRowIndex as number];
return MongoProxyClient.updateDocument(
_collection.databaseId,
_collection as ViewModels.Collection,
@ -1559,8 +1597,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
.then(
({ continuationToken: newContinuationToken, documents }) => {
setContinuationToken(newContinuationToken);
let currentDocuments = documentIds;
const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid);
const currentDocumentsRids = documentIds.map((currentDocument) => currentDocument.rid);
const nextDocumentIds = documents
.filter((d: { _rid: string }) => {
return currentDocumentsRids.indexOf(d._rid) < 0;
@ -1569,34 +1606,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
.map((rawDocument: any) => {
const partitionKeyValue = rawDocument._partitionKeyValue;
return newDocumentId(rawDocument, partitionKeyProperties, [partitionKeyValue]);
// return new DocumentId(this, rawDocument, [partitionKeyValue]);
});
const merged = currentDocuments.concat(nextDocumentIds);
setDocumentIds(merged);
currentDocuments = merged;
if (filterContent.length > 0 && currentDocuments.length > 0) {
currentDocuments[0].click();
} else {
setSelectedDocumentContent("");
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
}
if (_onLoadStartKey !== null && _onLoadStartKey !== undefined) {
TelemetryProcessor.traceSuccess(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
_onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
const merged = documentIds.concat(nextDocumentIds);
updateDocumentIds(merged);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error: any) => {
@ -1624,7 +1637,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// ***************** Mongo ***************************
const refreshDocumentsGrid = useCallback(
async (applyFilterButtonPressed?: boolean): Promise<void> => {
(applyFilterButtonPressed: boolean): void => {
// clear documents grid
setDocumentIds([]);
setContinuationToken(undefined); // For mongo
@ -1638,6 +1651,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// collapse filter
setAppliedFilter(filterContent);
setIsFilterExpanded(false);
// If apply filter is pressed, reset current selected document
if (applyFilterButtonPressed) {
setClickedRowIndex(RESET_INDEX);
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
setSelectedDocumentContent(undefined);
}
} catch (error) {
console.error(error);
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
@ -1774,11 +1794,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
>
<DocumentsTableComponent
items={tableItems}
onItemClicked={onDocumentClicked}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
columnHeaders={columnHeaders}
isSelectionDisabled={
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
}
/>
{tableItems.length > 0 && (
<a

View File

@ -24,6 +24,7 @@ describe("DocumentsTableComponent", () => {
idHeader: ID_HEADER,
partitionKeyHeaders: [PARTITION_KEY_HEADER],
},
isSelectionDisabled: false,
});
it("should render documents and partition keys in header", () => {
@ -31,4 +32,11 @@ describe("DocumentsTableComponent", () => {
const wrapper = mount(<DocumentsTableComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("should not render selection column when isSelectionDisabled is true", () => {
const props: IDocumentsTableComponentProps = createMockProps();
props.isSelectionDisabled = true;
const wrapper = mount(<DocumentsTableComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -22,7 +22,10 @@ import {
useTableFeatures,
useTableSelection,
} from "@fluentui/react-components";
import React, { useCallback, useEffect, useMemo } from "react";
import { NormalizedEventKey } from "Common/Constants";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
export type DocumentsTableComponentItem = {
@ -41,6 +44,7 @@ export interface IDocumentsTableComponentProps {
size: { height: number; width: number };
columnHeaders: ColumnHeaders;
style?: React.CSSProperties;
isSelectionDisabled?: boolean;
}
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@ -55,15 +59,13 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items,
onItemClicked,
onSelectedRowsChange,
selectedRows,
style,
size,
columnHeaders,
isSelectionDisabled,
}: IDocumentsTableComponentProps) => {
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(undefined);
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
@ -121,24 +123,73 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
[columnHeaders],
);
const onIdClicked = useCallback((index: number) => onSelectedRowsChange(new Set([index])), [onSelectedRowsChange]);
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(undefined);
const onTableCellClicked = useCallback(
(e: React.MouseEvent, index: number) => {
if (isSelectionDisabled) {
// Only allow click
onSelectedRowsChange(new Set<TableRowId>([index]));
setSelectionStartIndex(index);
return;
}
const result = selectionHelper(
selectedRows as Set<number>,
index,
isEnvironmentShiftPressed(e),
isEnvironmentCtrlPressed(e),
selectionStartIndex,
);
onSelectedRowsChange(result.selection);
if (result.selectionStartIndex !== undefined) {
setSelectionStartIndex(result.selectionStartIndex);
}
},
[isSelectionDisabled, selectedRows, selectionStartIndex, onSelectedRowsChange],
);
/**
* Callback for when:
* - a key has been pressed on the cell
* - a key is down and the cell is clicked by the mouse
*/
const onIdClicked = useCallback(
(e: React.KeyboardEvent<Element>, index: number) => {
if (e.key === NormalizedEventKey.Enter || e.key === NormalizedEventKey.Space) {
onSelectedRowsChange(new Set<TableRowId>([index]));
}
},
[onSelectedRowsChange],
);
const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => {
const { item, selected, appearance, onClick, onKeyDown } = data[index];
return (
<TableRow aria-rowindex={index + 2} style={style} key={item.id} aria-selected={selected} appearance={appearance}>
<TableSelectionCell
checked={selected}
checkboxIndicator={{ "aria-label": "Select row" }}
onClick={onClick}
onKeyDown={onKeyDown}
/>
<TableRow
aria-rowindex={index + 2}
style={{ ...style, cursor: "pointer", userSelect: "none" }}
key={item.id}
aria-selected={selected}
appearance={appearance}
>
{!isSelectionDisabled && (
<TableSelectionCell
checked={selected}
checkboxIndicator={{ "aria-label": "Select row" }}
onClick={(e: React.MouseEvent) => {
setSelectionStartIndex(index);
onClick(e);
}}
onKeyDown={onKeyDown}
/>
)}
{columns.map((column) => (
<TableCell
key={column.columnId}
className="documentsTableCell"
onClick={(/* e */) => onSelectedRowsChange(new Set<TableRowId>([index]))}
onKeyDown={() => onIdClicked(index)}
// When clicking on a cell with shift/ctrl key, onKeyDown is called instead of onClick.
onClick={(e: React.MouseEvent<Element, MouseEvent>) => onTableCellClicked(e, index)}
onKeyPress={(e: React.KeyboardEvent<Element>) => onIdClicked(e, index)}
{...columnSizing.getTableCellProps(column.columnId)}
tabIndex={column.columnId === "id" ? 0 : -1}
>
@ -162,7 +213,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
[
useTableColumnSizing_unstable({ columnSizingOptions, onColumnResize }),
useTableSelection({
selectionMode: "multiselect",
selectionMode: isSelectionDisabled ? "single" : "multiselect",
selectedItems: selectedRows,
// eslint-disable-next-line react/prop-types
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
@ -196,17 +247,6 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
[toggleAllRows],
);
// Load document depending on selection
useEffect(() => {
if (selectedRows.size === 1 && items.length > 0) {
const newActiveItemIndex = selectedRows.values().next().value;
if (newActiveItemIndex !== activeItemIndex) {
onItemClicked(newActiveItemIndex);
setActiveItemIndex(newActiveItemIndex);
}
}
}, [selectedRows, items]);
// Cell keyboard navigation
const keyboardNavAttr = useArrowNavigationGroup({ axis: "grid" });
@ -226,12 +266,14 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
<Table className="documentsTable" noNativeElements {...tableProps}>
<TableHeader className="documentsTableHeader">
<TableRow style={{ width: size ? size.width - 15 : "100%" }}>
<TableSelectionCell
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
onClick={toggleAllRows}
onKeyDown={toggleAllKeydown}
checkboxIndicator={{ "aria-label": "Select all rows " }}
/>
{!isSelectionDisabled && (
<TableSelectionCell
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
onClick={toggleAllRows}
onKeyDown={toggleAllKeydown}
checkboxIndicator={{ "aria-label": "Select all rows " }}
/>
)}
{columns.map((column /* index */) => (
<Menu openOnContext key={column.columnId}>
<MenuTrigger>

View File

@ -0,0 +1,92 @@
/**
* Utility class to help with selection.
* This emulates File Explorer selection behavior.
* ctrl: toggle selection of index.
* shift: select all rows between selectionStartIndex and index
* shift + ctrl: select or deselect all rows between selectionStartIndex and index depending on whether selectionStartIndex is selected
* No modifier only selects the clicked row
* ctrl: updates selection start index
* shift: do not update selection start index
*
* @param currentSelection current selection
* @param clickedIndex index of clicked row
* @param isShiftKey shift key is pressed
* @param isCtrlKey ctrl key is pressed
* @param selectionStartIndex index of current selected row
* @returns new selection and selection start
*/
export const selectionHelper = (
currentSelection: Set<number>,
clickedIndex: number,
isShiftKey: boolean,
isCtrlKey: boolean,
selectionStartIndex: number,
): {
selection: Set<number>;
selectionStartIndex: number;
} => {
if (isShiftKey) {
// Shift is about selecting range of rows
if (isCtrlKey) {
// shift + ctrl
const isSelectionStartIndexSelected = currentSelection.has(selectionStartIndex);
const min = Math.min(clickedIndex, selectionStartIndex);
const max = Math.max(clickedIndex, selectionStartIndex);
const newSelection = new Set<number>(currentSelection);
for (let i = min; i <= max; i++) {
// Select or deselect range depending on how selectionStartIndex is selected
if (isSelectionStartIndexSelected) {
// Select range
newSelection.add(i);
} else {
// Deselect range
newSelection.delete(i);
}
}
return {
selection: newSelection,
selectionStartIndex: undefined,
};
} else {
// shift only
// Shift only: enable everything between lastClickedIndex and clickedIndex and disable everything else
const min = Math.min(clickedIndex, selectionStartIndex);
const max = Math.max(clickedIndex, selectionStartIndex);
const newSelection = new Set<number>();
for (let i = min; i <= max; i++) {
newSelection.add(i);
}
return {
selection: newSelection,
selectionStartIndex: undefined, // do not change selection start
};
}
} else {
if (isCtrlKey) {
// Ctrl only: toggle selection where we clicked
const isNotSelected = !currentSelection.has(clickedIndex);
if (isNotSelected) {
return {
selection: new Set(currentSelection.add(clickedIndex)),
selectionStartIndex: clickedIndex,
};
} else {
// Remove
currentSelection.delete(clickedIndex);
return {
selection: new Set(currentSelection),
selectionStartIndex: clickedIndex,
};
}
} else {
// If no modifier keys are pressed, select only the clicked row
return {
selection: new Set<number>([clickedIndex]),
selectionStartIndex: clickedIndex,
};
}
}
};

View File

@ -532,6 +532,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
"partitionKeyHeaders": Array [],
}
}
isSelectionDisabled={true}
items={Array []}
onItemClicked={[Function]}
onSelectedRowsChange={[Function]}

View File

@ -0,0 +1,84 @@
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
describe("Selection helper", () => {
describe("when shift:off", () => {
it("ctrl:off: should return clicked items and update selection start", () => {
const currentSelection = new Set<number>([1, 2, 3]);
const clickedIndex = 4;
const isShiftKey = false;
const isCtrlKey = false;
const selectionStartIndex = 1;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([4]));
expect(result.selectionStartIndex).toEqual(4);
});
it("ctrl:on: should turn on selection and update selection start on not selected item", () => {
const currentSelection = new Set<number>([1, 3]);
const clickedIndex = 2;
const isShiftKey = false;
const isCtrlKey = true;
const selectionStartIndex = 1;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([1, 2, 3]));
expect(result.selectionStartIndex).toEqual(2);
});
it("ctrl:on: should turn off selection and update selection start on selected item", () => {
const currentSelection = new Set<number>([1, 2, 3]);
const clickedIndex = 2;
const isShiftKey = false;
const isCtrlKey = true;
const selectionStartIndex = 1;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([1, 3]));
expect(result.selectionStartIndex).toEqual(2);
});
});
describe("when shift:on", () => {
it("ctrl:off: should only select between selection start and clicked index (selection start < clicked index)", () => {
const currentSelection = new Set<number>([7, 8, 10]);
const clickedIndex = 9;
const isShiftKey = true;
const isCtrlKey = false;
const selectionStartIndex = 5;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([5, 6, 7, 8, 9]));
expect(result.selectionStartIndex).toEqual(undefined);
});
it("ctrl:off: should only select between selection start and clicked index (selection start > clicked index)", () => {
const currentSelection = new Set<number>([4, 6, 8]);
const clickedIndex = 2;
const isShiftKey = true;
const isCtrlKey = false;
const selectionStartIndex = 5;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([2, 3, 4, 5]));
expect(result.selectionStartIndex).toEqual(undefined);
});
it("ctrl:on: selection start on selected item should keep current selection and select range, and not update selection start", () => {
const currentSelection = new Set<number>([1, 4, 5, 7]);
const clickedIndex = 9;
const isShiftKey = true;
const isCtrlKey = true;
const selectionStartIndex = 5;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([1, 4, 5, 6, 7, 8, 9]));
expect(result.selectionStartIndex).toEqual(undefined);
});
it("ctrl:on: selection start on deselected item should deselect range, and not update selection start", () => {
const currentSelection = new Set<number>([1, 4, 6, 7, 10]);
const clickedIndex = 9;
const isShiftKey = true;
const isCtrlKey = true;
const selectionStartIndex = 5;
const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex);
expect(result.selection).toEqual(new Set<number>([1, 4, 10]));
expect(result.selectionStartIndex).toEqual(undefined);
});
});
});

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
@ -12,7 +13,13 @@ import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
import {
LocalStorageUtility,
StorageKey,
getDefaultQueryResultsView,
getRUThreshold,
ruThresholdEnabled,
} from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { TabsState, useTabs } from "hooks/useTabs";
@ -25,6 +32,7 @@ import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
import DownloadQueryIcon from "../../../../images/DownloadQuery.svg";
import CancelQueryIcon from "../../../../images/Entity_cancel.svg";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
import CheckIcon from "../../../../images/check-1.svg";
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
import { NormalizedEventKey } from "../../../Common/Constants";
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
@ -103,6 +111,7 @@ interface IQueryTabStates {
cancelQueryTimeoutID: NodeJS.Timeout;
copilotActive: boolean;
currentTabActive: boolean;
queryResultsView: SplitterDirection;
}
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
@ -147,6 +156,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
cancelQueryTimeoutID: undefined,
copilotActive: this._queryCopilotActive(),
currentTabActive: true,
queryResultsView: getDefaultQueryResultsView(),
};
this.isCloseClicked = false;
this.splitterId = this.props.tabId + "_splitter";
@ -508,9 +518,45 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
});
}
buttons.push(this.createViewButtons());
return buttons;
}
private createViewButtons(): CommandButtonComponentProps {
const verticalButton: CommandButtonComponentProps = {
isSelected: this.state.queryResultsView === SplitterDirection.Vertical,
iconSrc: this.state.queryResultsView === SplitterDirection.Vertical ? CheckIcon : undefined,
commandButtonLabel: "Vertical",
ariaLabel: "Vertical",
onCommandClick: () => this._setViewLayout(SplitterDirection.Vertical),
hasPopup: false,
};
const horizontalButton: CommandButtonComponentProps = {
isSelected: this.state.queryResultsView === SplitterDirection.Horizontal,
iconSrc: this.state.queryResultsView === SplitterDirection.Horizontal ? CheckIcon : undefined,
commandButtonLabel: "Horizontal",
ariaLabel: "Horizontal",
onCommandClick: () => this._setViewLayout(SplitterDirection.Horizontal),
hasPopup: false,
};
return {
commandButtonLabel: "View",
ariaLabel: "View",
hasPopup: true,
children: [verticalButton, horizontalButton],
};
}
private _setViewLayout(direction: SplitterDirection): void {
this.setState({ queryResultsView: direction });
// We'll need to refresh the context buttons to update the selected state of the view buttons
setTimeout(() => {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}, 100);
}
private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active);
@ -634,7 +680,12 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
></QueryCopilotPromptbar>
)}
<div className="tabPaneContentContainer">
<SplitterLayout vertical={true} primaryIndex={0} primaryMinSize={100} secondaryMinSize={200}>
<SplitterLayout
vertical={this.state.queryResultsView === SplitterDirection.Vertical}
primaryIndex={0}
primaryMinSize={100}
secondaryMinSize={200}
>
<Fragment>
<div className="queryEditor" style={{ height: "100%" }}>
<EditorReact

View File

@ -92,6 +92,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
the limit, go to the Settings cog on the right and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
target="_blank"
>
@ -153,47 +154,51 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
<li
onMouseOver={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => {
if (tab) {
tab.onTabClick();
} else if (tabKind !== undefined) {
useTabs.getState().activateReactTab(tabKind);
}
}}
onKeyPress={({ nativeEvent: e }) => {
if (tab) {
tab.onKeyPressActivate(undefined, e);
} else if (tabKind !== undefined) {
onKeyPressReactTab(e, tabKind);
}
}}
className={active ? "active tabList" : "tabList"}
style={active ? { fontWeight: "bolder" } : {}}
title={useObservable(tab?.tabPath || ko.observable(""))}
aria-selected={active}
aria-expanded={active}
aria-controls={tabId}
tabIndex={0}
role="tab"
ref={focusTab}
>
<span className="tabNavContentContainer">
<div className="tab_Content">
<span className="statusIconContainer" style={{ width: tabKind === ReactTabKind.Home ? 0 : 18 }}>
{useObservable(tab?.isExecutionError || ko.observable(false)) && <ErrorIcon tab={tab} active={active} />}
{isTabExecuting(tab, tabKind) && (
<img className="loadingIcon" title="Loading" src={loadingIcon} alt="Loading" />
)}
{isQueryErrorThrown(tab, tabKind) && (
<img
src={errorQuery}
title="Error"
alt="Error"
style={{ marginTop: 4, marginLeft: 4, width: 10, height: 11 }}
/>
)}
<span
className="contentWrapper"
onClick={() => {
if (tab) {
tab.onTabClick();
} else if (tabKind !== undefined) {
useTabs.getState().activateReactTab(tabKind);
}
}}
onKeyPress={({ nativeEvent: e }) => {
if (tab) {
tab.onKeyPressActivate(undefined, e);
} else if (tabKind !== undefined) {
onKeyPressReactTab(e, tabKind);
}
}}
title={useObservable(tab?.tabPath || ko.observable(""))}
aria-selected={active}
aria-expanded={active}
aria-controls={tabId}
tabIndex={0}
role="tab"
ref={focusTab}
>
<span className="statusIconContainer" style={{ width: tabKind === ReactTabKind.Home ? 0 : 18 }}>
{useObservable(tab?.isExecutionError || ko.observable(false)) && <ErrorIcon tab={tab} active={active} />}
{isTabExecuting(tab, tabKind) && (
<img className="loadingIcon" title="Loading" src={loadingIcon} alt="Loading" />
)}
{isQueryErrorThrown(tab, tabKind) && (
<img
src={errorQuery}
title="Error"
alt="Error"
style={{ marginTop: 4, marginLeft: 4, width: 10, height: 11 }}
/>
)}
</span>
<span className="tabNavText">{useObservable(tab?.tabTitle || getReactTabTitle())}</span>
</span>
<span className="tabNavText">{useObservable(tab?.tabTitle || getReactTabTitle())}</span>
<span className="tabIconSection">
<CloseButton tab={tab} active={active} hovering={hovering} tabKind={tabKind} />
</span>
@ -280,7 +285,7 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
}
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
if (e.key === "Enter" || e.key === "Space") {
if (e.key === "Enter" || e.code === "Space") {
useTabs.getState().activateReactTab(tabKind);
e.stopPropagation();
}

View File

@ -166,7 +166,7 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: Resourc
return (
<>
<FluentProvider theme={lightTheme} style={{ overflow: "hidden" }}>
<FluentProvider theme={lightTheme} style={{ overflow: "auto" }}>
<Tree
aria-label="CosmosDB resources"
openItems={openItems}

View File

@ -128,6 +128,7 @@ const App: React.FunctionComponent = () => {
// Setting key is needed so React will re-render this element on any account change
key={databaseAccount?.id || encryptedTokenMetadata?.accountName || authType}
ref={ref}
data-test="DataExplorerFrame"
id="explorerMenu"
name="explorer"
className="iframe"

View File

@ -93,7 +93,7 @@ const App: React.FunctionComponent = () => {
return (
<KeyboardShortcutRoot>
<div className="flexContainer" aria-hidden="false">
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
<div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */}

View File

@ -133,7 +133,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
<div id="connectWithAad">
<input className="filterbtnstyle" type="button" value="Sign In" onClick={login} />
{enableConnectionStringLogin && (
<p className="switchConnectTypeText" onClick={showForm}>
<p className="switchConnectTypeText" data-test="Link:SwitchConnectionType" onClick={showForm}>
Connect to your account with connection string
</p>
)}

View File

@ -117,6 +117,7 @@ const handleMessage = async (event: MessageEvent): Promise<void> => {
updateConfigContext({
ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT),
CATALOG_API_KEY: inputs.catalogAPIKey,
});
updateUserContext({

View File

@ -463,7 +463,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
);
}
return (
<div style={{ overflowX: "auto" }}>
<div style={{ overflowX: "auto" }} data-test="DataExplorerRoot">
<Stack tokens={containerStackTokens}>
<Stack.Item>
<CommandBar styles={commandBarStyles} items={this.getCommandBarItems()} />

View File

@ -1,11 +1,15 @@
import { configContext } from "../../ConfigContext";
import { userContext } from "../../UserContext";
import { armRequestWithoutPolling } from "../../Utils/arm/request";
import { get } from "../../Utils/arm/generatedClients/cosmos/locations";
import { armRequestWithoutPolling, getOfferingIdsRequest } from "../../Utils/arm/request";
import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor";
import { RefreshResult } from "../SelfServeTypes";
import SqlX from "./SqlX";
import {
FetchPricesResponse,
GetOfferingIdsResponse,
OfferingIdMap,
OfferingIdRequest,
PriceMapAndCurrencyCode,
RegionItem,
RegionsResponse,
@ -166,11 +170,21 @@ export const getRegions = async (): Promise<Array<RegionItem>> => {
}
};
export const getRegionShortName = async (regionDisplayName: string): Promise<string> => {
const locationsList = await get(userContext.subscriptionId, regionDisplayName);
if ("id" in locationsList) {
const locationId = locationsList.id;
return locationId.substring(locationId.lastIndexOf("/") + 1);
}
return undefined;
};
const getFetchPricesPathForRegion = (subscriptionId: string): string => {
return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`;
};
export const getPriceMapAndCurrencyCode = async (regions: Array<RegionItem>): Promise<PriceMapAndCurrencyCode> => {
export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise<PriceMapAndCurrencyCode> => {
const telemetryData = {
feature: "Calculate approximate cost",
function: "getPriceMapAndCurrencyCode",
@ -181,39 +195,94 @@ export const getPriceMapAndCurrencyCode = async (regions: Array<RegionItem>): Pr
try {
const priceMap = new Map<string, Map<string, number>>();
let currencyCode;
for (const regionItem of regions) {
let billingCurrency;
for (const region of map.keys()) {
const regionPriceMap = new Map<string, number>();
const regionShortName = await getRegionShortName(region);
const requestBody: OfferingIdRequest = {
location: regionShortName,
ids: Array.from(map.get(region).keys()),
};
const response = await armRequestWithoutPolling<FetchPricesResponse>({
host: configContext.ARM_ENDPOINT,
path: getFetchPricesPathForRegion(userContext.subscriptionId),
method: "POST",
apiVersion: "2020-01-01-preview",
queryParams: {
filter:
"armRegionNameeq '" +
regionItem.locationName.split(" ").join("").toLowerCase() +
"'andserviceFamilyeq 'Databases' and productName eq 'Azure Cosmos DB Dedicated Gateway - General Purpose'",
},
apiVersion: "2023-04-01-preview",
body: requestBody,
});
for (const item of response.result.Items) {
if (currencyCode === undefined) {
currencyCode = item.currencyCode;
} else if (item.currencyCode !== currencyCode) {
for (const item of response.result) {
if (item.error) {
continue;
}
if (billingCurrency === undefined) {
billingCurrency = item.billingCurrency;
} else if (item.billingCurrency !== billingCurrency) {
throw Error("Currency Code Mismatch: Currency code not same for all regions / skus.");
}
regionPriceMap.set(item.skuName, item.retailPrice);
const offeringId = item.id;
const skuName = map.get(region).get(offeringId);
const unitPriceinBillingCurrency = item.prices.find((x) => x.type === "Consumption")
?.unitPriceinBillingCurrency;
regionPriceMap.set(skuName, unitPriceinBillingCurrency);
}
priceMap.set(regionItem.locationName, regionPriceMap);
priceMap.set(region, regionPriceMap);
}
selfServeTraceSuccess(telemetryData, getPriceMapAndCurrencyCodeTimestamp);
return { priceMap: priceMap, currencyCode: currencyCode };
return { priceMap: priceMap, billingCurrency: billingCurrency };
} catch (err) {
const failureTelemetry = { err, selfServeClassName: SqlX.name };
selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp);
return { priceMap: undefined, currencyCode: undefined };
return { priceMap: undefined, billingCurrency: undefined };
}
};
const getOfferingIdPathForRegion = (): string => {
return `/skus?serviceFamily=Databases&service=Azure Cosmos DB`;
};
export const getOfferingIds = async (regions: Array<RegionItem>): Promise<OfferingIdMap> => {
const telemetryData = {
feature: "Get Offering Ids to calculate approximate cost",
function: "getOfferingIds",
description: "fetch offering ids API call",
selfServeClassName: SqlX.name,
};
const getOfferingIdsCodeTimestamp = selfServeTraceStart(telemetryData);
try {
const offeringIdMap = new Map<string, Map<string, string>>();
for (const regionItem of regions) {
const regionOfferingIdMap = new Map<string, string>();
const regionShortName = await getRegionShortName(regionItem.locationName);
const response = await getOfferingIdsRequest<GetOfferingIdsResponse>({
host: configContext.CATALOG_ENDPOINT,
path: getOfferingIdPathForRegion(),
method: "GET",
apiVersion: "2023-05-01-preview",
queryParams: {
filter: "armRegionNameeq '" + regionShortName + "'",
},
});
for (const item of response.result.items) {
if (item.offeringProperties?.length > 0) {
regionOfferingIdMap.set(item.offeringProperties[0].offeringId, item.skuName);
}
}
offeringIdMap.set(regionItem.locationName, regionOfferingIdMap);
}
selfServeTraceSuccess(telemetryData, getOfferingIdsCodeTimestamp);
return offeringIdMap;
} catch (err) {
const failureTelemetry = { err, selfServeClassName: SqlX.name };
selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp);
return undefined;
}
};

View File

@ -24,6 +24,7 @@ import { BladeType, generateBladeLink } from "../SelfServeUtils";
import {
deleteDedicatedGatewayResource,
getCurrentProvisioningState,
getOfferingIds,
getPriceMapAndCurrencyCode,
getRegions,
refreshDedicatedGatewayProvisioning,
@ -370,9 +371,10 @@ export default class SqlX extends SelfServeBaseClass {
});
regions = await getRegions();
const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(regions);
const offeringIdMap = await getOfferingIds(regions);
const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(offeringIdMap);
priceMap = priceMapAndCurrencyCode.priceMap;
currencyCode = priceMapAndCurrencyCode.currencyCode;
currencyCode = priceMapAndCurrencyCode.billingCurrency;
const response = await getCurrentProvisioningState();
if (response.status && response.status !== "Deleting") {

View File

@ -30,23 +30,51 @@ export type UpdateDedicatedGatewayRequestProperties = {
serviceType: string;
};
export type FetchPricesResponse = {
Items: Array<PriceItem>;
NextPageLink: string | undefined;
Count: number;
export type FetchPricesResponse = Array<PriceItem>;
export type PriceItem = {
prices: Array<PriceType>;
id: string;
billingCurrency: string;
error: PriceError;
};
export type PriceType = {
type: string;
unitPriceinBillingCurrency: number;
};
export type PriceError = {
type: string;
description: string;
};
export type PriceMapAndCurrencyCode = {
priceMap: Map<string, Map<string, number>>;
currencyCode: string;
billingCurrency: string;
};
export type PriceItem = {
retailPrice: number;
skuName: string;
currencyCode: string;
export type GetOfferingIdsResponse = {
items: Array<OfferingIdItem>;
nextPageLink: string | undefined;
};
export type OfferingIdItem = {
skuName: string;
offeringProperties: Array<OfferingProperties>;
};
export type OfferingProperties = {
offeringId: string;
};
export type OfferingIdRequest = {
ids: Array<string>;
location: string;
};
export type OfferingIdMap = Map<string, Map<string, string>>;
export type RegionsResponse = {
properties: RegionsProperties;
};

View File

@ -2,6 +2,7 @@
exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -338,6 +339,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -732,6 +734,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
exports[`SelfServeComponent message bar and spinner snapshots 3`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -832,6 +835,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",

View File

@ -1,3 +1,4 @@
import { SplitterDirection } from "Common/Splitter";
import * as LocalStorageUtility from "./LocalStorageUtility";
import * as SessionStorageUtility from "./SessionStorageUtility";
import * as StringUtility from "./StringUtility";
@ -27,6 +28,7 @@ export enum StorageKey {
GalleryCalloutDismissed,
VisitedAccounts,
PriorityLevel,
DefaultQueryResultsView,
}
export const hasRUThresholdBeenConfigured = (): boolean => {
@ -51,4 +53,12 @@ export const getRUThreshold = (): number => {
return DefaultRUThreshold;
};
export const getDefaultQueryResultsView = (): SplitterDirection => {
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) {
return SplitterDirection.Horizontal;
}
return SplitterDirection.Vertical;
};
export const DefaultRUThreshold = 5000;

View File

@ -0,0 +1,15 @@
/**
* Is the environment 'ctrl' key press. This key is used for multi selection, like select one more item, select all.
* For Windows and Linux, it's ctrl. For Mac, it's command.
*/
export const isEnvironmentCtrlPressed = (event: JQueryEventObject | React.MouseEvent): boolean =>
isMac() ? event.metaKey : event.ctrlKey;
export const isEnvironmentShiftPressed = (event: JQueryEventObject | React.MouseEvent): boolean => event.shiftKey;
export const isEnvironmentAltPressed = (event: JQueryEventObject | React.MouseEvent): boolean => event.altKey;
/**
* Returns whether the current platform is MacOS.
*/
export const isMac = (): boolean => navigator.platform.toUpperCase().indexOf("MAC") >= 0;

View File

@ -160,3 +160,52 @@ async function getOperationStatus(operationStatusUrl: string) {
}
throw new Error(`Operation Response: ${JSON.stringify(body)}. Retrying.`);
}
export async function getOfferingIdsRequest<T>({
host,
path,
apiVersion,
method,
body: requestBody,
queryParams,
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
const url = new URL(path, host);
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
if (queryParams) {
queryParams.filter && url.searchParams.append("$filter", queryParams.filter);
queryParams.metricNames && url.searchParams.append("metricnames", queryParams.metricNames);
}
if (!configContext.CATALOG_API_KEY) {
throw new Error("No catalog API key provided");
}
const response = await window.fetch(url.href, {
method,
headers: {
[HttpHeaders.xAPIKey]: configContext.CATALOG_API_KEY,
},
body: requestBody ? JSON.stringify(requestBody) : undefined,
});
if (!response.ok) {
let error: ARMError;
try {
const errorResponse = (await response.json()) as ParsedErrorResponse;
if ("error" in errorResponse) {
error = new ARMError(errorResponse.error.message);
error.code = errorResponse.error.code;
} else {
error = new ARMError(errorResponse.message);
error.code = errorResponse.code;
}
} catch (error) {
throw new Error(await response.text());
}
throw error;
}
const operationStatusUrl = (response.headers && response.headers.get("location")) || "";
const responseBody = (await response.json()) as T;
return { result: responseBody, operationStatusUrl: operationStatusUrl };
}

153
test/README.md Normal file
View File

@ -0,0 +1,153 @@
# End-to-End Test Suite
This directory contains end-to-end tests for Cosmos Data Explorer.
These tests **require** that you either deploy, or have access to, several Cosmos test Accounts.
The tests run in [Playwright](https://playwright.dev/), using the official Playwright test framework.
## Required Resources
To run all the tests, you need:
* A CosmosDB Account using the Cassandra API
* A CosmosDB Account using the Gremlin API
* A CosmosDB Account using the MongoDB API, API version 6.0
* A CosmosDB Account using the MongoDB API, API version 3.2
* A CosmosDB Account using the NoSQL API
* A CosmosDB Account using the Tables API
Each Account must have at least 1000 RU/s of throughput available for new databases/collections/etc.
The tests create new databases/keyspaces/etc. for each test, and delete them when the test is done.
So it should be safe to use these accounts for other testing purposes, as long as you make sure to have enough throughput available when running the tests.
You can specify the resource to use using the Environment Variables configuration below.
Or, you can deploy resources specifically for testing using the `deploy` script.
### Using the deploy script
> [!NOTE]
> This script currently only works on Windows.
The `resources` directory contains a `deploy.ps1` script that will deploy the required resources for testing.
They use a Bicep template to deploy the resources.
All you need to provide is a resource group to deploy in to.
To use this script, there are a few prerequisites that must be done at least once:
1. This script requires Powershell 7+. Install it [here](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows).
2. [Install Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/install-azps-windows?view=azps-12.0.0&tabs=powershell&pivots=windows-psgallery) if you don't already have it.
3. [Install Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install#install-manually) if it is not already installed.
4. Connect to your Azure account using `Connect-AzAccount`.
5. Ensure you have a Resource Group _ready_ to deploy into, the deploy script requires an existing resource group. This resource group should be named `[username]-e2e-testing`, where `[username]` is your Windows username, (**Microsoft employees:** This should be your alias). The easiest way to do this is by running the `create-resource-group.ps1` script, specifying the Subscription (Name or ID) and Location in which you want to create the Resource Group. For example:
```powershell
.\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3"
```
Then, whenever you want to create/update the resources, you can run the `deploy.ps1` script in the `resources` directory. As long as you're using the default naming convention (`[username]-e2e-testing`), you just need to specify the Subscription. For example:
```powershell
.\test\resources\deploy.ps1 -SubscriptionName "My Subscription"
```
You'll get a confirmation prompt before anything is deployed:
```
Found a resource group with the default name (ashleyst-e2e-testing). Using that resource group. If you want to use a different resource group, specify it as a parameter.
Deploying test resource sets: tables cassandra gremlin mongo mongo32 sql
in West US 3
to resource group ashleyst-e2e-testing
in subscription ... (...)
Do you want to continue? (y/n):
```
This prompt shows:
* The resources that will be deployed, in this case, all of them. You can filter to deploy only a subset by specifying the `-ResourceTypes` parameter. For example `-ResourceTypes @("cassandra", "sql")`.
* The location the resources will be deployed to, `West US 3` in this case.
* The resource group that will be used, `ashleyst-e2e-testing` in this case.
* The subscription that will be used.
Once you confirm, the resources will be deployed using Azure PowerShell and the Bicep templates in the `resources` directory. The script will wait for all the deployments to complete before exiting.
You can re-run this script at any time to update the resources, if the Bicep templates have changed.
## Preparing the test environment
Before running the tests, you need to configure your environment to specify the accounts to use for testing.
The following environment variables are used:
* `DE_TEST_RESOURCE_GROUP` - The resource group to use for testing. This should be the same resource group that the resources were deployed to.
* `DE_TEST_SUBSCRIPTION_ID` - The subscription ID to use for testing. This should be the same subscription that the resources were deployed to.
* `DE_TEST_ACCOUNT_PREFIX` - If you used the default naming scheme provided by the `deploy.ps1` script, this should be your Windows username (or whatever value you passed in for the `-ResourcePrefix` argument when deploying). This is used to find the accounts that were deployed.
In the event you didn't use the `deploy.ps1` script, you can specify the accounts directly using the following environment variables:
* `DE_TEST_ACCOUNT_NAME_CASSANDRA` - The name of the CosmosDB Account using the Cassandra API.
* `DE_TEST_ACCOUNT_NAME_GREMLIN` - The name of the CosmosDB Account using the Gremlin API.
* `DE_TEST_ACCOUNT_NAME_MONGO` - The name of the CosmosDB Account using the MongoDB API, API version 6.0.
* `DE_TEST_ACCOUNT_NAME_MONGO32` - The name of the CosmosDB Account using the MongoDB API, API version 3.2.
* `DE_TEST_ACCOUNT_NAME_SQL` - The name of the CosmosDB Account using the NoSQL API.
* `DE_TEST_ACCOUNT_NAME_TABLES` - The name of the CosmosDB Account using the Tables API.
If you used all the standard deployment scripts and naming scheme, you can set these environment variables using the following command:
```powershell
.\test\scripts\set-test-accounts.ps1
```
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
```powershell
.\test\scripts\set-test-subscription.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then set the necessary environment variables:
```
The currently-selected subscription is ... (...)
Do you want to use this subscription? (y/n): y
Found a resource with the default resource prefix (ashleyst-e2e-). Configuring that prefix for E2E testing.
Configuring for E2E Testing
Subscription: ... (...)
Resource Group: ashleyst-e2e-testing
Resource Prefix: ashleyst-e2e-
Found CosmosDB Account: ashleyst-e2e-cassandra
Found CosmosDB Account: ashleyst-e2e-gremlin
Found CosmosDB Account: ashleyst-e2e-mongo
Found CosmosDB Account: ashleyst-e2e-mongo32
Found CosmosDB Account: ashleyst-e2e-sql
Found CosmosDB Account: ashleyst-e2e-tables
```
## Running the tests
If Azure CLI is not installed, please [install it](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli).
Log into Az CLI with the following command:
```powershell
az login --scope https://management.core.windows.net//.default
```
To run all tests in a headless browser, run the following command from the root of the repo:
```powershell
npx playwright test
```
> [!NOTE]
> You may be prompted to install the Playwright browsers the first time you run the tests.
The tests will use your existing server if you have one running, or start a new server if you don't.
When running individual tests, you may find it most useful to use the Playwright UI to run the tests.
You can do this by running the following command:
```powershell
npx playwright test --ui
```
The UI allows you to select a specific test to run and to see the results of the test in the browser.
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.

View File

@ -1,57 +1,39 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import { expect, test } from "@playwright/test";
test("Cassandra keyspace and table CRUD", async () => {
const keyspaceId = generateUniqueName("keyspace");
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("Cassandra keyspace and table CRUD", async ({ page }) => {
const keyspaceId = generateDatabaseNameWithTimestamp();
const tableId = generateUniqueName("table");
page.setDefaultTimeout(50000);
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
const url = await getTestExplorerUrl(AccountType.Cassandra);
await page.goto(url);
await page.waitForSelector("iframe");
const explorer = await waitForExplorer();
await explorer.commandBarButton("New Table").click();
await explorer.whilePanelOpen("Add Table", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
});
await explorer.click('[data-test="New Table"]');
const keyspaceNode = explorer.treeNode(`DATA/${keyspaceId}`);
await keyspaceNode.expand();
const tableNode = explorer.treeNode(`DATA/${keyspaceId}/${tableId}`);
await explorer.waitForSelector(getPanelSelector("Add Table"));
await explorer.click('[aria-label="Keyspace id"]');
await explorer.fill('[aria-label="Keyspace id"]', keyspaceId);
await explorer.click('[aria-label="addCollection-table Id Create table"]');
await explorer.fill('[aria-label="addCollection-table Id Create table"]', tableId);
await explorer.fill('[aria-label="Table max RU/s"]', "1000");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Add Table"), { state: "detached" });
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await expect(tableNode.element).not.toBeAttached();
await explorer.click(getTreeNodeSelector(`DATA/${keyspaceId}`));
await openContextMenu(explorer, `DATA/${keyspaceId}/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}/${tableId}`, "Delete Table"));
await keyspaceNode.openContextMenu();
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
await explorer.whilePanelOpen("Delete Keyspace", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await openContextMenu(explorer, `DATA/${keyspaceId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}`, "Delete Keyspace"));
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', keyspaceId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", keyspaceId);
await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
await expect(keyspaceNode.element).not.toBeAttached();
});

151
test/fx.ts Normal file
View File

@ -0,0 +1,151 @@
import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import { expect, Frame, Locator, Page } from "@playwright/test";
import crypto from "crypto";
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
// We use '_' as the separator because it's supported across all the API types.
return `${baseName}${crypto.randomBytes(length).toString("hex")}_${Date.now()}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
return await AzureCliCredentials.create();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = await getAzureCLICredentials();
const token = (await credentials.getToken()).accessToken;
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
Mongo32 = "Mongo32",
SQL = "SQL",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "portal-tables-runner",
[TestAccount.Cassandra]: "portal-cassandra-runner",
[TestAccount.Gremlin]: "portal-gremlin-runner",
[TestAccount.Mongo]: "portal-mongo-runner",
[TestAccount.Mongo32]: "portal-mongo32-runner",
[TestAccount.SQL]: "portal-sql-runner-west-us",
};
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "runners";
export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
function tryGetStandardName(accountType: TestAccount) {
if (process.env.DE_TEST_ACCOUNT_PREFIX) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise<string> {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const baseUrl = `https://localhost:1234/testExplorer.html?accountName=${accountName}&resourceGroup=${resourceGroupName}&subscriptionId=${subscriptionId}&token=${token}`;
if (iframeSrc) {
return `${baseUrl}&iframeSrc=${iframeSrc}`;
}
return baseUrl;
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
// Sometimes, the expand button doesn't load at all, because the node didn't have children when it was initially loaded.
// Still, clicking the node will trigger loading and expansion. So if the node isn't expanded, we click it.
// The "aria-expanded" attribute is applied to the TreeItem. But we have the TreeItemLayout selected because the TreeItem contains the child tree as well.
// So, we need to find the TreeItem that contains this TreeItemLayout.
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
}
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
}
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
export class DataExplorer {
constructor(public frame: Frame) {}
commandBarButton(label: string): Locator {
return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button"));
}
panel(title: string): Locator {
return this.frame.getByTestId(`Panel:${title}`);
}
treeNode(id: string): TreeNode {
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
}
async whilePanelOpen(title: string, action: (panel: Locator, okButton: Locator) => Promise<void>): Promise<void> {
const panel = this.panel(title);
await panel.waitFor();
const okButton = panel.getByTestId("Panel/OkButton");
await action(panel, okButton);
await panel.waitFor({ state: "detached" });
}
static async waitForExplorer(page: Page) {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
return new DataExplorer(explorerFrame);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, iframeSrc);
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}

View File

@ -1,58 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
generateDatabaseNameWithTimestamp,
generateUniqueName,
getAzureCLICredentialsToken,
getPanelSelector,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
test("Graph CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-gremlin-runner&token=${token}`);
const explorer = await waitForExplorer();
// Create new database and graph
await explorer.click('[data-test="New Graph"]');
await explorer.waitForSelector(getPanelSelector("New Graph"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Graph id, Example Graph1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Graph"), { state: "detached" });
// Delete database and graph
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Graph"));
await explorer.waitForSelector(getPanelSelector("Delete Graph"));
await explorer.fill('text=* Confirm by typing the graph id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Graph"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
});

View File

@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("Gremlin graph CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const graphId = generateUniqueName("graph");
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
// Create new database and graph
await explorer.commandBarButton("New Graph").click();
await explorer.whilePanelOpen("New Graph", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const graphNode = explorer.treeNode(`DATA/${databaseId}/${graphId}`);
await graphNode.openContextMenu();
await graphNode.contextMenuItem("Delete Graph").click();
await explorer.whilePanelOpen("Delete Graph", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
});
await expect(graphNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await expect(databaseNode.element).not.toBeAttached();
});

View File

@ -1,72 +1,47 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
import { expect, test } from "@playwright/test";
test("Mongo CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
page.setDefaultTimeout(50000);
(
[
["latest API version", TestAccount.Mongo],
["3.2 API", TestAccount.Mongo32],
] as [string, TestAccount][]
).forEach(([apiVersionDescription, accountType]) => {
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const collectionId = generateUniqueName("collection");
const url = await getTestExplorerUrl(AccountType.Mongo);
await page.goto(url);
const explorer = await waitForExplorer();
const explorer = await DataExplorer.open(page, accountType);
// Create new database and collection
await explorer.click('[data-test="New Collection"]');
await explorer.commandBarButton("New Collection").click();
await explorer.whilePanelOpen("New Collection", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const collectionNode = explorer.treeNode(`DATA/${databaseId}/${collectionId}`);
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
await collectionNode.openContextMenu();
await collectionNode.contextMenuItem("Delete Collection").click();
await explorer.whilePanelOpen("Delete Collection", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
});
await expect(collectionNode.element).not.toBeAttached();
// Create indexing policy
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}/Settings`));
await explorer.click('button[role="tab"]:has-text("Indexing Policy")');
await explorer.click('[aria-label="Index Field Name 0"]');
await explorer.fill('[aria-label="Index Field Name 0"]', "foo");
await explorer.click("text=Select an index type");
await explorer.click('button[role="option"]:has-text("Single Field")');
await explorer.click('[data-test="Save"]');
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
// Remove indexing policy
await explorer.click('[aria-label="Delete index Button"]');
await explorer.click('[data-test="Save"]');
// Delete database and collection
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
await expect(databaseNode.element).not.toBeAttached();
});
});

View File

@ -1,59 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
test("Mongo CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
page.setDefaultTimeout(50000);
const url = await getTestExplorerUrl(AccountType.Mongo32);
await page.goto(url);
const explorer = await waitForExplorer();
// Create new database and collection
await explorer.click('[data-test="New Collection"]');
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
// Delete database and collection
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
});

View File

@ -1,110 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Getting started with Cosmos notebooks\nIn this notebook, we'll learn how to use Cosmos notebook features. We'll create a database and container, import some sample data in a container in Azure Cosmos DB and run some queries over it."
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Create new database and container\n\nTo connect to the service, you can use our built-in instance of ```cosmos_client```. This is a ready to use instance of [CosmosClient](https://docs.microsoft.com/python/api/azure-cosmos/azure.cosmos.cosmos_client.cosmosclient?view=azure-python) from our Python SDK. It already has the context of this account baked in. We'll use ```cosmos_client``` to create a new database called **RetailDemo** and container called **WebsiteData**.\n\nOur dataset will contain events that occurred on the website - e.g. a user viewing an item, adding it to their cart, or purchasing it. We will partition by CartId, which represents the individual cart of each user. This will give us an even distribution of throughput and storage in our container. Learn more about how to [choose a good partition key.](https://docs.microsoft.com/azure/cosmos-db/partition-data)"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "import azure.cosmos\nfrom azure.cosmos.partition_key import PartitionKey\n\ndatabase = cosmos_client.create_database_if_not_exists('RetailDemo')\nprint('Database RetailDemo created')\n\ncontainer = database.create_container_if_not_exists(id='WebsiteData', partition_key=PartitionKey(path='/CartID'))\nprint('Container WebsiteData created')\n"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "#### Set the default database and container context to the new resources\n\nWe can use the ```%database {database_id}``` and ```%container {container_id}``` syntax."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%database RetailDemo"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%container WebsiteData"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Load in sample JSON data and insert into the container. \nWe'll use the **%%upload** magic function to insert items into the container"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"collapsed": false,
"inputHidden": false,
"outputHidden": false,
"trusted": false
},
"outputs": [],
"source": "%%upload --databaseName RetailDemo --containerName WebsiteData --url https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/websiteData-small.json"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "The new database and container should show up under the **Data** section. Use the refresh icon after completing the previous cell. \n\n<img src=\"https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/refreshData.png\" alt=\"Refresh Data resource tree to see newly created resources\" width=\"40%\"/>"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Run a query using the built-in Azure Cosmos notebook magic\n"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%%sql\nSELECT c.Action, c.Price as ItemRevenue, c.Country, c.Item FROM c"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "We can get more information about the %%sql command using ```%%sql?```"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Next steps\n\nNow that you've learned how to use basic notebook functionality, follow the **Visualization.ipynb** notebook to further analyze and visualize our data. You can find it under the **Sample Notebooks** section."
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"version": "3.6.8"
},
"nteract": {
"version": "dataExplorer 1.0"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

View File

@ -1,25 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import fs from "fs";
import path from "path";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
const filename = "GettingStarted.ipynb";
const fileToUpload = `GettingStarted-ignore${Math.floor(Math.random() * 100000)}.ipynb`;
fs.copyFileSync(path.join(__dirname, filename), path.join(__dirname, fileToUpload));
test("Notebooks", async () => {
await page.goto("https://localhost:1234/testExplorer.html?accountName=portal-sql-runner-west-us");
const explorer = await waitForExplorer();
// Upload and Delete Notebook
await explorer.click('[data-test="My Notebooks"] [aria-label="More"]');
await explorer.click('button[role="menuitem"]:has-text("Upload File")');
await explorer.setInputFiles("#importFileInput", path.join(__dirname, fileToUpload));
await explorer.click('[aria-label="Upload"]');
await explorer.click(`[data-test="${fileToUpload}"] [aria-label="More"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete")');
await explorer.click('button:has-text("Delete")');
await expect(explorer).not.toHaveText(".notebookResourceTree", fileToUpload);
});

View File

@ -1,26 +0,0 @@
const PlaywrightEnvironment = require("jest-playwright-preset/lib/PlaywrightEnvironment").default;
class CustomEnvironment extends PlaywrightEnvironment {
async setup() {
await super.setup();
// Your setup
}
async teardown() {
// Your teardown
await super.teardown();
}
async handleTestEvent(event) {
if (event.name === "test_done" && event.test.errors.length > 0) {
const parentName = event.test.parent.name.replace(/\W/g, "-");
const specName = event.test.name.replace(/\W/g, "-");
await this.global.page.screenshot({
path: `screenshots/${parentName}_${specName}.png`,
});
}
}
}
module.exports = CustomEnvironment;

4
test/resources/README.md Normal file
View File

@ -0,0 +1,4 @@
# Azure Resources for E2E Testing
This directory contains Bicep files for creating the necessary Azure resources for end-to-end testing.
To deploy the resources, run the './deploy.ps1' script in this directory and follow the prompts.

View File

@ -0,0 +1,50 @@
targetScope = 'resourceGroup'
@description('The name of the account to create/update. If the account already exists, it will be updated.')
param accountName string
@description('The name of the owner of this account, usually a Microsoft alias, but can be any string.')
param ownerName string
@description('The Azure location in which to create the account.')
param location string
@description('The total throughput limit for the account. Defaults to 10000 RU/s.')
param totalThroughputLimit int = 10000
@allowed([
'tables'
'cassandra'
'gremlin'
'mongo'
'mongo32'
'sql'
])
@description('The type of account to create.')
param testAccountType string
var kind = (testAccountType == 'mongo' || testAccountType == 'mongo32') ? 'MongoDB' : 'GlobalDocumentDB'
var capabilities = (testAccountType == 'tables') ? [{name: 'EnableTable'}] : (testAccountType == 'cassandra') ? [{name: 'EnableCassandra'}] : (testAccountType == 'gremlin') ? [{name: 'EnableGremlin'}] : []
var serverVersion = (testAccountType == 'mongo32') ? '3.2' : (testAccountType == 'mongo') ? '6.0' : null
resource testCosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-02-15-preview' = {
name: accountName
location: location
tags: {
'DataExplorer:TestAccountType': testAccountType
Owner: ownerName
}
kind: kind
properties: {
databaseAccountOfferType: 'Standard'
locations: [
{
locationName: location
failoverPriority: 0
}
]
apiProperties: {
serverVersion: serverVersion
}
capabilities: capabilities
capacity: {
totalThroughputLimit: totalThroughputLimit
}
}
}

View File

@ -0,0 +1,31 @@
@description('A prefix to apply to the name of each account.')
param accountPrefix string
@description('The name of the owner of this account, usually a Microsoft alias, but can be any string.')
param ownerName string
@description('The Azure location in which to create the account.')
param location string
@description('The total throughput limit for the account. Defaults to 10000 RU/s.')
param totalThroughputLimit int = 10000
@allowed([
'tables'
'cassandra'
'gremlin'
'mongo'
'mongo32'
'sql'
])
@description('The type of accounts to create.')
param testAccountTypes string[]
var actualPrefix = endsWith(accountPrefix, '-') ? accountPrefix : '${accountPrefix}-'
module testAccount './account.bicep' = [for testAccountType in testAccountTypes: {
name: '${actualPrefix}${testAccountType}'
params: {
accountName: '${actualPrefix}${testAccountType}'
ownerName: ownerName
location: location
totalThroughputLimit: totalThroughputLimit
testAccountType: testAccountType
}
}]

View File

@ -0,0 +1,31 @@
param(
[Parameter(Mandatory=$true)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourceGroupName,
[Parameter(Mandatory=$true)][string]$Location
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue)
if(-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Select-AzSubscription -SubscriptionId $Subscription
if(-not $ResourceGroupName) {
$ResourceGroupName = $env:USERNAME + "-e2e-testing"
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if($AzResourceGroup) {
Write-Host "The resource group '$ResourceGroupName' already exists."
return
}
$confirm = Read-Host "Do you want to create the resource group '$ResourceGroupName' in the location '$Location'? (y/n)"
if($confirm -ne "y") {
throw "The resource group creation was cancelled."
}
$AzResourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $Location

113
test/resources/deploy.ps1 Normal file
View File

@ -0,0 +1,113 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$Location,
[Parameter(Mandatory=$false)][string]$ResourcePrefix,
[ValidateSet("tables", "cassandra", "gremlin", "mongo", "mongo32", "sql")] # This must be a constant so we can't re-use the $AllResourceTypes variable :(
[Parameter(Mandatory=$false)][string[]]$ResourceSets,
[Parameter(Mandatory=$false)][string]$OwnerName,
[Parameter(Mandatory=$false)][int]$TotalThroughputLimit = 10000,
[Parameter(Mandatory=$false)][switch]$WhatIf
)
$AllResourceTypes = @(
"tables",
"cassandra",
"gremlin",
"mongo",
"mongo32",
"sql"
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not (Get-Command bicep -ErrorAction SilentlyContinue)) {
throw "The Bicep CLI is required to run this script. Please install it first."
}
if (-not (Get-AzContext)) {
throw "Please login to your Azure account using Connect-AzAccount before running this script."
}
if(-not $ResourcePrefix) {
$ResourcePrefix = $env:USERNAME + "-e2e-"
}
if(-not $OwnerName) {
$OwnerName = $env:USERNAME
}
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue)
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Set-AzContext $AzSubscription | Out-Null
if (-not $ResourceGroup) {
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
Write-Host "Found a resource group with the default name ($DefaultResourceGroupName). Using that resource group. If you want to use a different resource group, specify it as a parameter."
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to deploy the resources to."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $Location) {
$Location = $AzResourceGroup.Location
}
$AzLocation = Get-AzLocation | Where-Object { $_.Location -eq $Location }
if (-not $AzLocation) {
throw "The location '$Location' could not be found."
}
if (-not $ResourceSets) {
$ResourceSets = $AllResourceTypes
} else {
# Normalize the resource set names to the value in AllResourceTypes
$ResourceSets = $ResourceSets | ForEach-Object { $_.ToLower() }
}
Write-Host "Deploying test resource sets: $ResourceSets"
Write-Host " in $($AzLocation.DisplayName)"
Write-Host " to resource group $ResourceGroup"
Write-Host " in subscription $($AzSubscription.Name) ($($AzSubscription.Id))."
if($WhatIf) {
Write-Host " (What-If mode enabled)"
}
$continue = Read-Host "Do you want to continue? (y/n)"
if ($continue -ne "y") {
throw "Deployment cancelled."
}
$bicepFile = Join-Path $PSScriptRoot "all-accounts.bicep"
Write-Host "Deploying resources using $bicepFile"
New-AzResourceGroupDeployment `
-ResourceGroupName $AzResourceGroup.ResourceGroupName `
-TemplateFile $bicepFile `
-WhatIf:$WhatIf `
-accountPrefix $ResourcePrefix `
-testAccountTypes $ResourceSets `
-location $AzLocation.Location `
-ownerName $OwnerName `
-totalThroughputLimit $TotalThroughputLimit

View File

@ -0,0 +1,22 @@
Write-Host "Your test account configuration:"
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
Get-ChildItem env:\DE_TEST_* | ForEach-Object {
if ($_.Name -eq "DE_TEST_SUBSCRIPTION_ID") {
$AzSubscription = Get-AzSubscription -SubscriptionId $_.Value -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $AzSubscription) {
Write-Warning "The subscription '$($_.Value)' could not be found."
}
Write-Host "* $($_.Name): $($_.Value) ($($AzSubscription.Name))"
} elseif ($_.Name -eq "DE_TEST_RESOURCE_GROUP") {
$AzResourceGroup = Get-AzResourceGroup -Name $_.Value -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
Write-Warning "The resource group '$($_.Value)' could not be found."
}
Write-Host "* $($_.Name): $($_.Value) (Confirmed)"
} else {
Write-Host "* $($_.Name): $($_.Value)"
}
}

View File

@ -0,0 +1,69 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourcePrefix
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Set-AzContext $AzSubscription.Id | Out-Null
if (-not $ResourceGroup) {
# Check for the default resource group name
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to find the resources in."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $ResourcePrefix) {
$defaultResourcePrefix = $env:USERNAME + "-e2e-"
# Check for one of the default resources
$defaultResource = Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceName "$($defaultResourcePrefix)cassandra" -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue
if ($defaultResource) {
Write-Host "Found a resource with the default resource prefix ($defaultResourcePrefix). Configuring that prefix for E2E testing."
$ResourcePrefix = $defaultResourcePrefix
} else {
$ResourcePrefix = Read-Host "Specify the resource prefix used in the resource names."
}
}
Write-Host "Configuring for E2E Testing"
Write-Host " Subscription: $($AzSubscription.Name) ($($AzSubscription.Id))"
Write-Host " Resource Group: $($AzResourceGroup.ResourceGroupName)"
Write-Host " Resource Prefix: $ResourcePrefix"
Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Name -like "$ResourcePrefix*") {
Write-Host " Found CosmosDB Account: $($_.Name)"
}
}
$env:DE_TEST_RESOURCE_GROUP = $AzResourceGroup.ResourceGroupName
$env:DE_TEST_SUBSCRIPTION_ID = $AzSubscription.Id
$env:DE_TEST_ACCOUNT_PREFIX = $ResourcePrefix

View File

@ -1,41 +0,0 @@
import { getAzureCLICredentialsToken } from "../utils/shared";
test("Self Serve", async () => {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
await page.goto(`https://localhost:1234/testExplorer.html?iframeSrc=selfServe.html&token=${token}`);
const handle = await page.waitForSelector("iframe");
const frame = await handle.contentFrame();
// wait for refresh RP call to end
await page.waitForTimeout(10000);
// id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE}
await frame.waitForSelector("#description-text-display");
const regions = await frame.waitForSelector("#regions-dropdown-input");
const currentRegionsDescription = await frame.$$("#currentRegionText-text-display");
expect(currentRegionsDescription).toHaveLength(0);
let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(0);
await regions.click();
const regionsDropdownElement1 = await frame.waitForSelector("#regions-dropdown-input-list0");
await regionsDropdownElement1.click();
await frame.waitForSelector("#currentRegionText-text-display");
disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(1);
await frame.waitForSelector("#accountName-textField-input");
const enableDbLevelThroughput = await frame.waitForSelector("#enableDbLevelThroughput-toggle-input");
const dbThroughput = await frame.$$("#dbThroughput-slider-input");
expect(dbThroughput).toHaveLength(0);
await enableDbLevelThroughput.click();
await frame.waitForSelector("#dbThroughput-slider-input");
await frame.waitForSelector("#collectionThroughput-spinner-input");
});

View File

@ -1,55 +1,40 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import { expect, test } from "@playwright/test";
test("SQL CRUD", async () => {
const databaseId = generateUniqueName("db");
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("SQL database and container CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
page.setDefaultTimeout(50000);
const explorer = await DataExplorer.open(page, TestAccount.SQL);
const url = await getTestExplorerUrl(AccountType.SQL);
await page.goto(url);
const explorer = await waitForExplorer();
await explorer.commandBarButton("New Container").click();
await explorer.whilePanelOpen("New Container", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.click('[data-test="New Container"]');
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const containerNode = explorer.treeNode(`DATA/${databaseId}/${containerId}`);
await explorer.waitForSelector(getPanelSelector("New Container"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Container id, Example Container1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Container"), { state: "detached" });
await containerNode.openContextMenu();
await containerNode.contextMenuItem("Delete Container").click();
await explorer.whilePanelOpen("Delete Container", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
});
await expect(containerNode.element).not.toBeAttached();
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.hover(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Container"));
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Container"));
await explorer.fill('text=* Confirm by typing the container id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Container"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@ -1,19 +1,23 @@
import { expect, test } from "@playwright/test";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { CosmosClient, PermissionMode } from "@azure/cosmos";
import { jest } from "@jest/globals";
import "expect-playwright";
import { generateUniqueName, getAzureCLICredentials, getTreeNodeSelector } from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import {
DataExplorer,
TestAccount,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../fx";
const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"] ?? "";
const resourceGroupName = "runners";
test("Resource token", async () => {
test("SQL account using Resource token", async ({ page }) => {
const credentials = await getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const account = await armClient.databaseAccounts.get(resourceGroupName, "portal-sql-runner-west-us");
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, "portal-sql-runner-west-us");
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const dbId = generateUniqueName("db");
const collectionId = generateUniqueName("col");
const client = new CosmosClient({
@ -28,15 +32,24 @@ test("Resource token", async () => {
permissionMode: PermissionMode.All,
resource: container.url,
});
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${database.id};CollectionId=${container.id};${containerPermission._token}`;
await expect(containerPermission).toBeDefined();
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${
database.id
};CollectionId=${container.id};${containerPermission!._token}`;
await page.goto("https://localhost:1234/hostedExplorer.html");
await page.waitForSelector("div > p.switchConnectTypeText");
await page.click("div > p.switchConnectTypeText");
await page.fill("input[class='inputToken']", resourceTokenConnectionString);
await page.click("input[value='Connect']");
const explorer = await waitForExplorer();
const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
await switchConnectionLink.waitFor();
await switchConnectionLink.click();
await page.getByPlaceholder("Please enter a connection string").fill(resourceTokenConnectionString);
await page.getByRole("button", { name: "Connect" }).click();
const collectionNodeLabel = await explorer.textContent(getTreeNodeSelector(`DATA/${collectionId}`));
expect(collectionNodeLabel).toBe(collectionId);
const explorer = await DataExplorer.waitForExplorer(page);
const collectionNode = explorer.treeNode(`DATA/${collectionId}`);
await collectionNode.element.waitFor();
await expect(collectionNode.element).toBeAttached();
await database.delete();
});

View File

@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount } from "../fx";
test("Self Serve", async ({ page }) => {
const explorer = await DataExplorer.open(page, TestAccount.SQL, "selfServe.html");
const loggingToggle = explorer.frame.locator("#enableLogging-toggle-input");
await expect(loggingToggle).toBeEnabled();
const regionDropdown = explorer.frame.getByText("Select a region");
await regionDropdown.click();
await explorer.frame.getByRole("option").first().click();
const currentRegionLabel = explorer.frame.getByLabel("Current Region");
await currentRegionLabel.waitFor();
await expect(currentRegionLabel).toHaveText(/current region selected is .*/);
await expect(loggingToggle).toBeDisabled();
await explorer.frame.locator("#enableDbLevelThroughput-toggle-input").click();
const slider = explorer.frame.getByLabel("Database Throughput");
await slider.waitFor();
await expect(slider).toBeAttached();
});

View File

@ -1,40 +1,28 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
import { expect, test } from "@playwright/test";
jest.setTimeout(120000);
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Tables CRUD", async () => {
test("Tables CRUD", async ({ page }) => {
const tableId = generateUniqueName("table");
page.setDefaultTimeout(50000);
const url = await getTestExplorerUrl(AccountType.Tables);
await page.goto(url);
const explorer = await waitForExplorer();
const explorer = await DataExplorer.open(page, TestAccount.Tables);
await page.waitForSelector('text="Querying databases"', { state: "detached" });
await explorer.click('[data-test="New Table"]');
await explorer.commandBarButton("New Table").click();
await explorer.whilePanelOpen("New Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("New Table"));
await explorer.fill('[aria-label="Table id, Example Table1"]', tableId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Table"), { state: "detached" });
const tableNode = explorer.treeNode(`DATA/TablesDB/${tableId}`);
await expect(tableNode.element).toBeAttached();
await openContextMenu(explorer, `DATA/TablesDB/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/TablesDB/${tableId}`, "Delete Table"));
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
await expect(tableNode.element).not.toBeAttached();
});

View File

@ -23,6 +23,11 @@ const initTestExplorer = async (): Promise<void> => {
const databaseAccount = await get(subscriptionId, resourceGroup, accountName);
const keys = await listKeys(subscriptionId, resourceGroup, accountName);
// Disable the quickstart carousel.
if (databaseAccount?.id) {
localStorage.setItem(databaseAccount.id, "true");
}
const initTestExplorerContent = {
inputs: {
databaseAccount: databaseAccount,
@ -65,6 +70,10 @@ const initTestExplorer = async (): Promise<void> => {
// This simulates the same action that happens in the portal
console.dir(event.data);
if (event.data?.kind === "ready") {
if (!iframe.contentWindow || !iframe.contentDocument) {
throw new Error("iframe is not loaded");
}
iframe.contentWindow.postMessage(
{
signature: "pcIframe",
@ -78,6 +87,7 @@ const initTestExplorer = async (): Promise<void> => {
);
iframe.id = "explorerMenu";
iframe.name = "explorer";
iframe.setAttribute("data-test", "DataExplorerFrame");
iframe.classList.add("iframe");
iframe.title = "explorer";
iframe.src = iframeSrc;

View File

@ -1,21 +0,0 @@
let client;
const appInsightsKey = process.env.PORTAL_RUNNER_APP_INSIGHTS_KEY;
if (!appInsightsKey) {
console.warn(`PORTAL_RUNNER_APP_INSIGHTS_KEY env var not set. Runner telemetry will not be reported`);
} else {
const appInsights = require("applicationinsights");
appInsights.setup(process.env.PORTAL_RUNNER_APP_INSIGHTS_KEY).start();
client = appInsights.defaultClient;
}
module.exports.trackEvent = (...args) => {
if (client) {
client.trackEvent(...args);
}
};
module.exports.trackException = exception => {
if (client) {
client.trackException({ exception });
}
};

View File

@ -1,69 +0,0 @@
import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import crypto from "crypto";
import { Frame } from "playwright";
export enum AccountType {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
Mongo32 = "Mongo32",
SQL = "SQL",
}
export const defaultAccounts: Record<AccountType, string> = {
[AccountType.Tables]: "portal-tables-runner",
[AccountType.Cassandra]: "portal-cassandra-runner",
[AccountType.Gremlin]: "portal-gremlin-runner",
[AccountType.Mongo]: "portal-mongo-runner",
[AccountType.Mongo32]: "portal-mongo32-runner",
[AccountType.SQL]: "portal-sql-runner-west-us",
};
const resourceGroup = process.env.DE_TEST_RESOURCE_GROUP ?? "runners";
const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
export async function getTestExplorerUrl(accountType: AccountType) {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName =
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ?? defaultAccounts[accountType];
return `https://localhost:1234/testExplorer.html?accountName=${accountName}&resourceGroup=${resourceGroup}&subscriptionId=${subscriptionId}&token=${token}`;
}
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}-${Date.now()}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
return await AzureCliCredentials.create();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = await getAzureCLICredentials();
const token = (await credentials.getToken()).accessToken;
return token;
}
export function getPanelSelector(title: string) {
return `[data-test="Panel:${title}"]`;
}
export function getTreeNodeSelector(id: string) {
return `[data-test="TreeNode:${id}"]`;
}
export function getTreeMenuItemSelector(nodeId: string, itemLabel: string) {
return `[data-test="TreeNode/ContextMenu:${nodeId}"] [data-test="TreeNode/ContextMenuItem:${itemLabel}"]`;
}
export async function openContextMenu(explorer: Frame, nodeIdentifier: string) {
const nodeSelector = getTreeNodeSelector(nodeIdentifier);
await explorer.hover(nodeSelector);
await explorer.click(`${nodeSelector} [data-test="TreeNode/ContextMenuTrigger"]`);
await explorer.waitForSelector(`[data-test="TreeNode/ContextMenu:${nodeIdentifier}"]`);
}

View File

@ -1,9 +0,0 @@
import { Frame } from "playwright";
export const waitForExplorer = async (): Promise<Frame> => {
await page.waitForSelector("iframe");
await page.waitForTimeout(5000);
return page.frame({
name: "explorer",
});
};

View File

@ -87,6 +87,7 @@ const typescriptRule = {
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/** @type {(_env: Record<string, string>, argv: Record<string, unknown>) => import("webpack").Configuration} */
module.exports = function (_env = {}, argv = {}) {
const mode = argv.mode || "development";
const rules = [fontRule, lessRule, imagesRule, cssRule, htmlRule, typescriptRule];
@ -265,6 +266,8 @@ module.exports = function (_env = {}, argv = {}) {
watch: false,
// Hack since it is hard to disable watch entirely with webpack dev server https://github.com/webpack/webpack-dev-server/issues/1251#issuecomment-654240734
watchOptions: isCI ? { poll: 24 * 60 * 60 * 1000 } : {},
/** @type {import("webpack-dev-server").Configuration}*/
devServer: {
hot: false,
// disableHostCheck is removed in webpack 5, use: allowedHosts: "all",
@ -282,6 +285,26 @@ module.exports = function (_env = {}, argv = {}) {
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*",
},
setupMiddlewares: (middlewares, server) => {
// Provide an HTTP API that will wait for compilation of all bundles to be completed.
// This is used by Playwright to know when the server is ready to be tested.
let compilationComplete = false;
server.compiler.hooks.done.tap("done", () => {
setImmediate(() => {
compilationComplete = true;
});
});
server.app.get("/_ready", (_, res) => {
if (compilationComplete) {
res.status(200).send("Compilation complete.");
} else {
res.status(503).send("Compilation not complete.");
}
});
return middlewares;
},
proxy: {
"/api": {
target: "https://main.documentdb.ext.azure.com",