mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2024-11-30 01:17:01 +00:00
Merge branch 'master' into users/languy/save-documentstab-prefs
This commit is contained in:
commit
996f785aac
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"],
|
||||
};
|
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,
|
||||
},
|
||||
});
|
@ -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
|
||||
|
@ -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 = {
|
||||
|
@ -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,12 +9,14 @@ 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 {
|
||||
DefaultRUThreshold,
|
||||
LocalStorageUtility,
|
||||
StorageKey,
|
||||
getDefaultQueryResultsView,
|
||||
getRUThreshold,
|
||||
ruThresholdEnabled as isRUThresholdEnabled,
|
||||
} from "Shared/StorageUtility";
|
||||
@ -47,6 +49,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),
|
||||
);
|
||||
@ -121,6 +126,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(
|
||||
@ -197,6 +203,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,
|
||||
@ -234,6 +245,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)) {
|
||||
@ -438,6 +456,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]}
|
||||
|
@ -91,6 +91,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,
|
||||
};
|
||||
@ -470,7 +473,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]));
|
||||
|
||||
@ -498,6 +501,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 = {
|
||||
@ -558,7 +578,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
|
||||
if (!documentsIterator) {
|
||||
try {
|
||||
refreshDocumentsGrid();
|
||||
refreshDocumentsGrid(false);
|
||||
} catch (error) {
|
||||
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
@ -665,7 +685,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
setSelectedDocumentContent(defaultDocument);
|
||||
setSelectedDocumentContentBaseline(defaultDocument);
|
||||
setSelectedRows(new Set());
|
||||
setClickedRow(undefined);
|
||||
setClickedRowIndex(undefined);
|
||||
setEditorState(ViewModels.DocumentExplorerState.newDocumentValid);
|
||||
};
|
||||
|
||||
@ -681,8 +701,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,
|
||||
@ -746,7 +768,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);
|
||||
@ -794,7 +816,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);
|
||||
@ -858,7 +888,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
setDocumentIds(newDocumentIds);
|
||||
|
||||
setSelectedDocumentContent(undefined);
|
||||
setClickedRow(undefined);
|
||||
setClickedRowIndex(undefined);
|
||||
setSelectedRows(new Set());
|
||||
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
useDialog
|
||||
@ -982,8 +1012,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;
|
||||
@ -1036,21 +1085,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);
|
||||
@ -1120,7 +1155,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();
|
||||
@ -1166,10 +1201,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) =>
|
||||
@ -1286,13 +1321,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);
|
||||
}
|
||||
@ -1300,6 +1335,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);
|
||||
@ -1460,6 +1496,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);
|
||||
@ -1510,7 +1548,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,
|
||||
@ -1578,8 +1616,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;
|
||||
@ -1588,34 +1625,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) => {
|
||||
@ -1643,7 +1656,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
|
||||
@ -1657,6 +1670,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));
|
||||
@ -1804,7 +1824,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
items={tableItems}
|
||||
onItemClicked={onDocumentClicked}
|
||||
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
selectedRows={selectedRows}
|
||||
size={tableContainerSizePx}
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
@ -62,7 +62,6 @@ const MIN_COLUMN_WIDTH_PX = 50;
|
||||
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
items,
|
||||
onItemClicked,
|
||||
onSelectedRowsChange,
|
||||
selectedRows,
|
||||
style,
|
||||
@ -70,8 +69,6 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
columnsDefinition,
|
||||
isSelectionDisabled,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(undefined);
|
||||
|
||||
const initialSizingOptions: TableColumnSizingOptions = {};
|
||||
columnsDefinition.forEach((column) => {
|
||||
initialSizingOptions[column.id] = {
|
||||
@ -232,18 +229,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);
|
||||
setSelectionStartIndex(newActiveItemIndex);
|
||||
}
|
||||
}
|
||||
}, [selectedRows, items, activeItemIndex, onItemClicked]);
|
||||
|
||||
// Cell keyboard navigation
|
||||
const keyboardNavAttr = useArrowNavigationGroup({ axis: "grid" });
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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()} />
|
||||
|
@ -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";
|
||||
@ -28,6 +29,7 @@ export enum StorageKey {
|
||||
VisitedAccounts,
|
||||
PriorityLevel,
|
||||
DocumentsTabPrefs,
|
||||
DefaultQueryResultsView,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
@ -52,4 +54,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;
|
||||
|
143
test/README.md
Normal file
143
test/README.md
Normal file
@ -0,0 +1,143 @@
|
||||
# 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. [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.
|
||||
2. Connect to your Azure account using `Connect-AzAccount`.
|
||||
3. 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
|
||||
|
||||
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.click('[data-test="New Table"]');
|
||||
|
||||
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 explorer.click(getTreeNodeSelector(`DATA/${keyspaceId}`));
|
||||
await openContextMenu(explorer, `DATA/${keyspaceId}/${tableId}`);
|
||||
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}/${tableId}`, "Delete Table"));
|
||||
|
||||
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 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();
|
||||
});
|
||||
|
||||
const keyspaceNode = explorer.treeNode(`DATA/${keyspaceId}`);
|
||||
await keyspaceNode.expand();
|
||||
const tableNode = explorer.treeNode(`DATA/${keyspaceId}/${tableId}`);
|
||||
|
||||
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 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 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();
|
||||
|
||||
// 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}`));
|
||||
|
||||
// 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 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();
|
||||
});
|
||||
|
||||
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
|
||||
await databaseNode.expand();
|
||||
const collectionNode = explorer.treeNode(`DATA/${databaseId}/${collectionId}`);
|
||||
|
||||
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 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,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.click('[data-test="New Container"]');
|
||||
|
||||
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 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 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 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();
|
||||
});
|
||||
|
||||
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
|
||||
await databaseNode.expand();
|
||||
const containerNode = explorer.treeNode(`DATA/${databaseId}/${containerId}`);
|
||||
|
||||
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 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,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.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" });
|
||||
|
||||
await openContextMenu(explorer, `DATA/TablesDB/${tableId}`);
|
||||
await explorer.click(getTreeMenuItemSelector(`DATA/TablesDB/${tableId}`, "Delete Table"));
|
||||
|
||||
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 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();
|
||||
});
|
||||
|
||||
const tableNode = explorer.treeNode(`DATA/TablesDB/${tableId}`);
|
||||
await expect(tableNode.element).toBeAttached();
|
||||
|
||||
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();
|
||||
});
|
||||
|
@ -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