mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2024-11-25 06:56:38 +00:00
merge conflict
This commit is contained in:
commit
f7b7d135df
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
|
@ -1,3 +1,5 @@
|
||||
playwright.config.ts
|
||||
|
||||
**/node_modules/
|
||||
src/**/__mocks__/**/*
|
||||
dist/
|
||||
|
140
.github/workflows/ci.yml
vendored
140
.github/workflows/ci.yml
vendored
@ -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
4
.gitignore
vendored
@ -17,3 +17,7 @@ Contracts/*
|
||||
failure.png
|
||||
screenshots/*
|
||||
GettingStarted-ignore*.ipynb
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
@ -1,13 +0,0 @@
|
||||
const isCI = require("is-ci");
|
||||
|
||||
module.exports = {
|
||||
exitOnPageError: false,
|
||||
launchOptions: {
|
||||
headless: isCI,
|
||||
slowMo: 10,
|
||||
timeout: 60000,
|
||||
},
|
||||
contextOptions: {
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
};
|
@ -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"],
|
||||
};
|
@ -130,6 +130,7 @@
|
||||
@ActiveTabWidth: 141px;
|
||||
@TabsHeight: 30px;
|
||||
@TabsWidth: 140px;
|
||||
@ContentWrapper: 111px;
|
||||
@StatusIconContainerSize: 18px;
|
||||
@LoadingErrorIconSize: 14px;
|
||||
@ErrorIconContainer: 16px;
|
||||
|
@ -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,6 +2707,9 @@ a:link {
|
||||
width: @TabsWidth;
|
||||
border-right: @ButtonBorderWidth solid @BaseMedium;
|
||||
white-space: nowrap;
|
||||
.contentWrapper {
|
||||
.flex-display();
|
||||
width: @ContentWrapper;
|
||||
|
||||
.statusIconContainer {
|
||||
width: @StatusIconContainerSize;
|
||||
@ -2765,9 +2768,10 @@ a:link {
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tabIconSection {
|
||||
width: 30px;
|
||||
width: 29px;
|
||||
position: relative;
|
||||
padding-top: 2px;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
.contentWrapper {
|
||||
.statusIconContainer {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabIconSection {
|
||||
.cancelButton {
|
||||
|
1426
package-lock.json
generated
1426
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
53
playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
@ -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 {
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -425,6 +425,7 @@ export interface SelfServeFrameInputs {
|
||||
authorizationToken: string;
|
||||
csmEndpoint: string;
|
||||
flights?: readonly string[];
|
||||
catalogAPIKey: string;
|
||||
}
|
||||
|
||||
export class MonacoEditorSettings {
|
||||
|
@ -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}
|
||||
</>
|
||||
|
@ -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
|
||||
|
@ -20,6 +20,10 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.outlineNone{
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.copyQuery:focus::after,
|
||||
.deleteQuery:focus::after {
|
||||
outline: none !important;
|
||||
|
@ -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
|
||||
<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>
|
||||
.
|
||||
|
@ -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"
|
||||
|
@ -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())}
|
||||
>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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>) => {
|
||||
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,
|
||||
|
@ -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,61 +882,29 @@ 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,
|
||||
<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 });
|
||||
}}
|
||||
onContentChanged={(newVectorEmbeddingPolicy: string) =>
|
||||
this.setVectorEmbeddingPolicy(newVectorEmbeddingPolicy)
|
||||
}
|
||||
/>
|
||||
</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,22 +1336,11 @@ 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" });
|
||||
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
|
||||
this.setState({ errorMessage: "Please fix errors in container vector policy" });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(this.state.vectorEmbeddingPolicy) as DataModels.VectorEmbeddingPolicy;
|
||||
} catch (e) {
|
||||
this.setState({ errorMessage: "Invalid JSON format for vectorEmbeddingPolicy" });
|
||||
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 = {
|
||||
|
@ -275,7 +275,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
<span className="mandatoryStar">* </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>
|
||||
|
@ -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]}
|
||||
|
@ -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"
|
||||
|
@ -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]}
|
||||
|
@ -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}
|
||||
|
@ -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]}
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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]}
|
||||
|
@ -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]}
|
||||
|
@ -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]}
|
||||
|
@ -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]}
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
16
src/Explorer/Panes/VectorSearchPanel/VectorSearchUtils.ts
Normal file
16
src/Explorer/Panes/VectorSearchPanel/VectorSearchUtils.ts
Normal 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,
|
||||
}));
|
||||
}
|
@ -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]}
|
||||
|
@ -504,7 +504,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
{!showFeedbackBar && (
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
AI-generated content can have mistakes. Make sure it'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 && (
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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}>
|
||||
<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={onClick}
|
||||
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%" }}>
|
||||
{!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>
|
||||
|
92
src/Explorer/Tabs/DocumentsTabV2/SelectionHelper.ts
Normal file
92
src/Explorer/Tabs/DocumentsTabV2/SelectionHelper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
@ -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]}
|
||||
|
File diff suppressed because it is too large
Load Diff
84
src/Explorer/Tabs/DocumentsTabV2/selectionHelper.test.ts
Normal file
84
src/Explorer/Tabs/DocumentsTabV2/selectionHelper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
|
@ -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,6 +154,13 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
||||
<li
|
||||
onMouseOver={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
className={active ? "active tabList" : "tabList"}
|
||||
style={active ? { fontWeight: "bolder" } : {}}
|
||||
>
|
||||
<span className="tabNavContentContainer">
|
||||
<div className="tab_Content">
|
||||
<span
|
||||
className="contentWrapper"
|
||||
onClick={() => {
|
||||
if (tab) {
|
||||
tab.onTabClick();
|
||||
@ -167,8 +175,6 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
||||
onKeyPressReactTab(e, tabKind);
|
||||
}
|
||||
}}
|
||||
className={active ? "active tabList" : "tabList"}
|
||||
style={active ? { fontWeight: "bolder" } : {}}
|
||||
title={useObservable(tab?.tabPath || ko.observable(""))}
|
||||
aria-selected={active}
|
||||
aria-expanded={active}
|
||||
@ -177,8 +183,6 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
||||
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) && (
|
||||
@ -194,6 +198,7 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
||||
)}
|
||||
</span>
|
||||
<span className="tabNavText">{useObservable(tab?.tabTitle || getReactTabTitle())}</span>
|
||||
</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();
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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 */}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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({
|
||||
|
@ -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()} />
|
||||
|
@ -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:
|
||||
"armRegionName eq '" +
|
||||
regionItem.locationName.split(" ").join("").toLowerCase() +
|
||||
"' and serviceFamily eq '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: "armRegionName eq '" + 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;
|
||||
}
|
||||
};
|
||||
|
@ -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") {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
15
src/Utils/KeyboardUtils.ts
Normal file
15
src/Utils/KeyboardUtils.ts
Normal 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;
|
@ -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
153
test/README.md
Normal 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.
|
@ -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
151
test/fx.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
41
test/gremlin/container.spec.ts
Normal file
41
test/gremlin/container.spec.ts
Normal 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();
|
||||
});
|
@ -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 () => {
|
||||
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
|
||||
|
||||
(
|
||||
[
|
||||
["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 containerId = generateUniqueName("container");
|
||||
const collectionId = generateUniqueName("collection");
|
||||
|
||||
page.setDefaultTimeout(50000);
|
||||
const explorer = await DataExplorer.open(page, accountType);
|
||||
|
||||
const url = await getTestExplorerUrl(AccountType.Mongo);
|
||||
await page.goto(url);
|
||||
const explorer = await waitForExplorer();
|
||||
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();
|
||||
});
|
||||
|
||||
// Create new database and collection
|
||||
await explorer.click('[data-test="New Collection"]');
|
||||
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
|
||||
await databaseNode.expand();
|
||||
const collectionNode = explorer.treeNode(`DATA/${databaseId}/${collectionId}`);
|
||||
|
||||
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 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();
|
||||
|
||||
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
|
||||
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
|
||||
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();
|
||||
});
|
||||
|
||||
// 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"]');
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
@ -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
|
||||
}
|
@ -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);
|
||||
});
|
@ -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
4
test/resources/README.md
Normal 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.
|
50
test/resources/account.bicep
Normal file
50
test/resources/account.bicep
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
31
test/resources/all-accounts.bicep
Normal file
31
test/resources/all-accounts.bicep
Normal 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
|
||||
}
|
||||
}]
|
31
test/resources/create-resource-group.ps1
Normal file
31
test/resources/create-resource-group.ps1
Normal 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
113
test/resources/deploy.ps1
Normal 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
|
22
test/scripts/check-test-accounts.ps1
Normal file
22
test/scripts/check-test-accounts.ps1
Normal 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)"
|
||||
}
|
||||
}
|
69
test/scripts/set-test-accounts.ps1
Normal file
69
test/scripts/set-test-accounts.ps1
Normal 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
|
@ -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");
|
||||
});
|
@ -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();
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
23
test/sql/selfServeExample.spec.ts
Normal file
23
test/sql/selfServeExample.spec.ts
Normal 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();
|
||||
});
|
@ -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();
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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}"]`);
|
||||
}
|
@ -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",
|
||||
});
|
||||
};
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user